feat: merge service (#14384)

This commit is contained in:
DarkSky
2026-02-07 04:52:25 +08:00
committed by GitHub
parent a0cf5681c4
commit 1a2410f541
49 changed files with 966 additions and 924 deletions
@@ -44,3 +44,12 @@ e2e('should init renderer service', async t => {
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'renderer');
});
e2e('should init front service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'front';
await using app = await createApp();
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'front');
});
@@ -68,12 +68,14 @@ test('should read DEPLOYMENT_TYPE', t => {
test('should read FLAVOR', t => {
t.deepEqual(
['allinone', 'graphql', 'sync', 'renderer', 'doc', 'script'].map(envVal => {
process.env.SERVER_FLAVOR = envVal;
const env = new Env();
return env.FLAVOR;
}),
['allinone', 'graphql', 'sync', 'renderer', 'doc', 'script']
['allinone', 'graphql', 'sync', 'renderer', 'front', 'doc', 'script'].map(
envVal => {
process.env.SERVER_FLAVOR = envVal;
const env = new Env();
return env.FLAVOR;
}
),
['allinone', 'graphql', 'sync', 'renderer', 'front', 'doc', 'script']
);
t.throws(
@@ -83,7 +85,7 @@ test('should read FLAVOR', t => {
},
{
message:
'Invalid value "unknown" for environment variable SERVER_FLAVOR, expected one of ["allinone","graphql","sync","renderer","doc","script"]',
'Invalid value "unknown" for environment variable SERVER_FLAVOR, expected one of ["allinone","graphql","sync","renderer","front","doc","script"]',
}
);
});
@@ -110,6 +112,7 @@ test('should tell flavors correctly', t => {
graphql: true,
sync: true,
renderer: true,
front: false,
doc: true,
script: false,
});
@@ -119,6 +122,17 @@ test('should tell flavors correctly', t => {
graphql: true,
sync: false,
renderer: false,
front: false,
doc: false,
script: false,
});
process.env.SERVER_FLAVOR = 'front';
t.deepEqual(new Env().flavors, {
graphql: false,
sync: false,
renderer: false,
front: true,
doc: false,
script: false,
});
@@ -128,6 +142,7 @@ test('should tell flavors correctly', t => {
graphql: false,
sync: false,
renderer: false,
front: false,
doc: false,
script: true,
});
+12 -5
View File
@@ -43,6 +43,7 @@ import { PermissionModule } from './core/permission';
import { QueueDashboardModule } from './core/queue-dashboard';
import { QuotaModule } from './core/quota';
import { SelfhostModule } from './core/selfhost';
import { StaticFileModule } from './core/static-files';
import { StorageModule } from './core/storage';
import { SyncModule } from './core/sync';
import { TelemetryModule } from './core/telemetry';
@@ -173,10 +174,14 @@ export function buildAppModule(env: Env) {
NotificationModule,
MailModule
)
// renderer server only
.useIf(() => env.flavors.renderer, DocRendererModule)
// sync server only
.useIf(() => env.flavors.sync, SyncModule, TelemetryModule)
// renderer server and front server
.useIf(() => env.flavors.renderer || env.flavors.front, DocRendererModule)
// sync server and front server
.useIf(
() => env.flavors.sync || env.flavors.front,
SyncModule,
TelemetryModule
)
// graphql server only
.useIf(
() => env.flavors.graphql,
@@ -199,8 +204,10 @@ export function buildAppModule(env: Env) {
)
// doc service only
.useIf(() => env.flavors.doc, DocServiceModule)
// self hosted server only
// worker for and self-hosted API only for self-host and local development only
.useIf(() => env.dev || env.selfhosted, WorkerModule, SelfhostModule)
// static frontend routes for front flavor
.useIf(() => env.flavors.front, StaticFileModule)
// gcloud
.useIf(() => env.gcp, GCloudModule);
@@ -8,6 +8,11 @@ export class SetupMiddleware implements NestMiddleware {
constructor(private readonly server: ServerService) {}
use = (req: Request, res: Response, next: (error?: Error | any) => void) => {
if (!env.selfhosted) {
next();
return;
}
// never throw
this.server
.initialized()
@@ -0,0 +1,193 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import test from 'ava';
import express from 'express';
import request from 'supertest';
import { Namespace } from '../../../env';
import { StaticFilesResolver } from '../static';
const mobileUA =
'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
function initStaticFixture(root: string) {
const staticRoot = join(root, 'static');
const files: Array<[string, string]> = [
['index.html', 'web-index'],
['admin/index.html', 'admin-index'],
['assets/main.js', 'web-asset'],
['mobile/index.html', 'mobile-index'],
['mobile/assets/main.js', 'mobile-asset'],
];
for (const [file, content] of files) {
const fullPath = join(staticRoot, file);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, content);
}
}
async function createApp(basePath = '') {
const app = express();
const resolver = new StaticFilesResolver(
{ server: { path: basePath } } as any,
{
httpAdapter: {
getInstance: () => app,
},
} as any
);
resolver.onModuleInit();
return app;
}
test.serial('serves admin files and admin route fallback', async t => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'affine-static-files-'));
initStaticFixture(fixtureRoot);
const prevProjectRoot = env.projectRoot;
const prevNamespace = env.NAMESPACE;
try {
// @ts-expect-error test override
env.projectRoot = fixtureRoot;
// @ts-expect-error test override
env.NAMESPACE = Namespace.Production;
const app = await createApp();
const indexRes = await request(app).get('/admin/index.html').expect(200);
t.is(indexRes.text, 'admin-index');
const fallbackRes = await request(app).get('/admin/settings').expect(200);
t.is(fallbackRes.text, 'admin-index');
} finally {
// @ts-expect-error test override
env.projectRoot = prevProjectRoot;
// @ts-expect-error test override
env.NAMESPACE = prevNamespace;
rmSync(fixtureRoot, { recursive: true, force: true });
}
});
test.serial(
'serves static assets from prefixed paths and returns 404 on missing',
async t => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'affine-static-files-'));
initStaticFixture(fixtureRoot);
const prevProjectRoot = env.projectRoot;
const prevNamespace = env.NAMESPACE;
try {
// @ts-expect-error test override
env.projectRoot = fixtureRoot;
// @ts-expect-error test override
env.NAMESPACE = Namespace.Production;
const app = await createApp();
const assetRes = await request(app).get('/assets/main.js').expect(200);
t.is(assetRes.text, 'web-asset');
await request(app).get('/assets/missing.js').expect(404);
} finally {
// @ts-expect-error test override
env.projectRoot = prevProjectRoot;
// @ts-expect-error test override
env.NAMESPACE = prevNamespace;
rmSync(fixtureRoot, { recursive: true, force: true });
}
}
);
test.serial(
'matches front container index behavior and cache header',
async t => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'affine-static-files-'));
initStaticFixture(fixtureRoot);
const prevProjectRoot = env.projectRoot;
const prevNamespace = env.NAMESPACE;
try {
// @ts-expect-error test override
env.projectRoot = fixtureRoot;
// @ts-expect-error test override
env.NAMESPACE = Namespace.Production;
const app = await createApp();
const indexRes = await request(app).get('/index.html').expect(200);
t.is(indexRes.text, 'web-index');
t.is(
indexRes.headers['cache-control'],
'private, no-cache, no-store, max-age=0, must-revalidate'
);
const fallbackRes = await request(app).get('/workspace/path').expect(200);
t.is(fallbackRes.text, 'web-index');
} finally {
// @ts-expect-error test override
env.projectRoot = prevProjectRoot;
// @ts-expect-error test override
env.NAMESPACE = prevNamespace;
rmSync(fixtureRoot, { recursive: true, force: true });
}
}
);
test.serial('uses mobile root only in dev namespace for mobile UA', async t => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'affine-static-files-'));
initStaticFixture(fixtureRoot);
const prevProjectRoot = env.projectRoot;
const prevNamespace = env.NAMESPACE;
try {
// @ts-expect-error test override
env.projectRoot = fixtureRoot;
// @ts-expect-error test override
env.NAMESPACE = Namespace.Dev;
const app = await createApp();
const mobileAssetRes = await request(app)
.get('/assets/main.js')
.set('user-agent', mobileUA)
.expect(200);
t.is(mobileAssetRes.text, 'mobile-asset');
const webAssetRes = await request(app).get('/assets/main.js').expect(200);
t.is(webAssetRes.text, 'web-asset');
const mobileFromHint = await request(app)
.get('/assets/main.js')
.set('user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
.set('sec-ch-ua-mobile', '?1')
.expect(200);
t.is(mobileFromHint.text, 'mobile-asset');
const desktopFromHint = await request(app)
.get('/assets/main.js')
.set('user-agent', mobileUA)
.set('sec-ch-ua-mobile', '?0')
.expect(200);
t.is(desktopFromHint.text, 'web-asset');
const mobileFromPlatformHint = await request(app)
.get('/assets/main.js')
.set('sec-ch-ua-platform', '"Android"')
.expect(200);
t.is(mobileFromPlatformHint.text, 'mobile-asset');
} finally {
// @ts-expect-error test override
env.projectRoot = prevProjectRoot;
// @ts-expect-error test override
env.NAMESPACE = prevNamespace;
rmSync(fixtureRoot, { recursive: true, force: true });
}
});
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { StaticFilesResolver } from './static';
@Module({
providers: [StaticFilesResolver],
})
export class StaticFileModule {}
@@ -0,0 +1,119 @@
import { join } from 'node:path';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import type { Application, Request, Response } from 'express';
import { static as serveStatic } from 'express';
import { Config } from '../../base';
import { isMobileRequest } from '../utils/user-agent';
const staticPathRegex = /^\/(_plugin|assets|imgs|js|plugins|static)\//;
@Injectable()
export class StaticFilesResolver implements OnModuleInit {
constructor(
private readonly config: Config,
private readonly adapterHost: HttpAdapterHost
) {}
onModuleInit() {
if (!this.adapterHost.httpAdapter) {
return;
}
const app = this.adapterHost.httpAdapter.getInstance<Application>();
const basePath = this.config.server.path;
const rootPath = basePath || '/';
const staticPath = join(env.projectRoot, 'static');
const adminPath = join(staticPath, 'admin');
const mobilePath = env.namespaces.canary
? join(staticPath, 'mobile')
: staticPath;
const staticAsset = serveStatic(staticPath, {
redirect: false,
index: false,
fallthrough: true,
});
const mobileAsset = serveStatic(mobilePath, {
redirect: false,
index: false,
fallthrough: true,
});
const staticAssetStrict = serveStatic(staticPath, {
redirect: false,
index: false,
fallthrough: false,
});
const mobileAssetStrict = serveStatic(mobilePath, {
redirect: false,
index: false,
fallthrough: false,
});
const adminAsset = serveStatic(adminPath, {
redirect: false,
index: false,
fallthrough: true,
});
const routeByUA = (
req: Request,
res: Response,
next: (err?: unknown) => void,
strict = false
) => {
const isMobile = isMobileRequest(req.headers);
if (strict) {
return isMobile
? mobileAssetStrict(req, res, next)
: staticAssetStrict(req, res, next);
}
return isMobile
? mobileAsset(req, res, next)
: staticAsset(req, res, next);
};
// /admin
app.use(basePath + '/admin', adminAsset);
app.get([basePath + '/admin', basePath + '/admin/*path'], (_req, res) => {
res.sendFile(join(adminPath, 'index.html'));
});
// /_plugin|/assets|/imgs|/js|/plugins|/static
app.use(rootPath, (req, res, next) => {
if (!staticPathRegex.test(req.path)) {
next();
return;
}
routeByUA(req, res, next, true);
});
// /
app.use(rootPath, (req, res, next) => {
if (req.path.startsWith('/admin')) {
next();
return;
}
res.setHeader(
'Cache-Control',
'private, no-cache, no-store, max-age=0, must-revalidate'
);
routeByUA(req, res, next, false);
});
app.get(
[basePath || '/', basePath + '/*path'],
(req: Request, res: Response) => {
if (req.path.startsWith('/admin')) {
res.status(404).end();
return;
}
const root = isMobileRequest(req.headers) ? mobilePath : staticPath;
res.sendFile(join(root, 'index.html'));
}
);
}
}
@@ -0,0 +1,65 @@
import test from 'ava';
import {
isMobileRequest,
isMobileUserAgent,
parseRequestUserAgent,
parseUserAgent,
} from '../user-agent';
const mobileUserAgent =
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
const desktopUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
test('returns desktop for empty user agent values', t => {
t.false(isMobileUserAgent(undefined));
t.deepEqual(parseUserAgent(undefined), {
ua: '',
deviceType: 'desktop',
isMobile: false,
});
});
test('detects mobile and desktop user agents', t => {
const mobile = parseUserAgent(mobileUserAgent);
t.true(mobile.isMobile);
t.is(mobile.deviceType, 'mobile');
const desktop = parseUserAgent(desktopUserAgent);
t.false(desktop.isMobile);
t.is(desktop.deviceType, 'desktop');
});
test('prefers sec-ch-ua-mobile over user-agent when available', t => {
const mobileFromHint = parseRequestUserAgent({
'user-agent': desktopUserAgent,
'sec-ch-ua-mobile': '?1',
});
t.true(mobileFromHint.isMobile);
t.is(mobileFromHint.deviceType, 'mobile');
t.true(isMobileRequest({ 'sec-ch-ua-mobile': '?1' }));
const desktopFromHint = parseRequestUserAgent({
'user-agent': mobileUserAgent,
'sec-ch-ua-mobile': '?0',
});
t.false(desktopFromHint.isMobile);
t.is(desktopFromHint.deviceType, 'desktop');
});
test('uses sec-ch-ua-platform as fallback hint', t => {
const parsed = parseRequestUserAgent({
'sec-ch-ua-platform': '"Android"',
});
t.true(parsed.isMobile);
t.is(parsed.deviceType, 'mobile');
const desktop = parseRequestUserAgent({
'sec-ch-ua-platform': '"Windows"',
});
t.false(desktop.isMobile);
t.is(desktop.deviceType, 'desktop');
t.false(isMobileUserAgent(undefined));
});
@@ -0,0 +1,207 @@
import type { IncomingHttpHeaders } from 'node:http';
import isMobile from 'is-mobile';
export type UserAgentHeader = IncomingHttpHeaders['user-agent'];
export type UserAgentDeviceType = 'desktop' | 'mobile';
export interface ParsedUserAgent {
readonly ua: string;
readonly deviceType: UserAgentDeviceType;
readonly isMobile: boolean;
}
type HeaderValue = string | string[] | undefined;
const USER_AGENT_MAX_LENGTH = 512;
const USER_AGENT_CACHE_MAX_SIZE = 1024;
const CLIENT_HINT_MOBILE_TRUE = new Set(['?1', '1', 'true']);
const MOBILE_PLATFORM_HINTS = new Set(['android', 'ios', 'ipados']);
const EMPTY_USER_AGENT: ParsedUserAgent = {
ua: '',
deviceType: 'desktop',
isMobile: false,
};
const parsedUserAgentCache = new Map<string, ParsedUserAgent>();
interface UserAgentSignals {
ua: string;
clientHintMobile?: boolean;
clientHintPlatform?: string;
}
function pickHeaderValue(value: HeaderValue): string {
if (typeof value === 'string') {
return value;
}
if (!Array.isArray(value)) {
return '';
}
return value.find(item => typeof item === 'string' && item.length > 0) ?? '';
}
function normalizeHeaderValue(value: HeaderValue): string {
const header = pickHeaderValue(value);
if (!header) return '';
return header.trim().toLowerCase();
}
function unquoteHeaderValue(value: string): string {
if (!value) return value;
if (value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1);
}
return value;
}
function normalizeUserAgentHeader(value: UserAgentHeader): string {
const normalized = normalizeHeaderValue(value);
if (!normalized) return '';
if (normalized.length <= USER_AGENT_MAX_LENGTH) return normalized;
return normalized.slice(0, USER_AGENT_MAX_LENGTH);
}
function parseClientHintMobile(value: HeaderValue): boolean | undefined {
const normalized = unquoteHeaderValue(normalizeHeaderValue(value));
if (!normalized) return;
if (CLIENT_HINT_MOBILE_TRUE.has(normalized)) return true;
return false;
}
function parseClientHintPlatform(value: HeaderValue): string | undefined {
const normalized = unquoteHeaderValue(normalizeHeaderValue(value));
if (!normalized) {
return;
}
return normalized;
}
function parseUserAgentSignals(headers: IncomingHttpHeaders): UserAgentSignals {
return {
ua: normalizeUserAgentHeader(headers['user-agent']),
clientHintMobile: parseClientHintMobile(headers['sec-ch-ua-mobile']),
clientHintPlatform: parseClientHintPlatform(headers['sec-ch-ua-platform']),
};
}
function getDeviceType(signals: UserAgentSignals): UserAgentDeviceType {
if (signals.clientHintMobile !== undefined) {
return signals.clientHintMobile ? 'mobile' : 'desktop';
}
if (
signals.clientHintPlatform &&
MOBILE_PLATFORM_HINTS.has(signals.clientHintPlatform)
) {
return 'mobile';
}
if (!signals.ua) {
return 'desktop';
}
const mobile = isMobile({
ua: signals.ua,
tablet: true,
featureDetect: false,
});
return mobile ? 'mobile' : 'desktop';
}
function getCacheKey(signals: UserAgentSignals): string {
const hintMobile =
signals.clientHintMobile === undefined
? ''
: signals.clientHintMobile
? '1'
: '0';
const hintPlatform = signals.clientHintPlatform ?? '';
return `${signals.ua}|${hintMobile}|${hintPlatform}`;
}
function getCachedParsedUserAgent(
cacheKey: string
): ParsedUserAgent | undefined {
const cached = parsedUserAgentCache.get(cacheKey);
if (!cached) return;
// Keep recently-used entries hot in the bounded cache.
parsedUserAgentCache.delete(cacheKey);
parsedUserAgentCache.set(cacheKey, cached);
return cached;
}
function cacheParsedUserAgent(
cacheKey: string,
parsedUserAgent: ParsedUserAgent
) {
if (parsedUserAgentCache.has(cacheKey)) {
parsedUserAgentCache.delete(cacheKey);
} else if (parsedUserAgentCache.size >= USER_AGENT_CACHE_MAX_SIZE) {
const oldestUserAgent = parsedUserAgentCache.keys().next();
if (!oldestUserAgent.done) {
parsedUserAgentCache.delete(oldestUserAgent.value);
}
}
parsedUserAgentCache.set(cacheKey, parsedUserAgent);
}
function parseUserAgentWithSignals(signals: UserAgentSignals): ParsedUserAgent {
const cacheKey = getCacheKey(signals);
const cached = getCachedParsedUserAgent(cacheKey);
if (cached) return cached;
const deviceType = getDeviceType(signals);
const parsed: ParsedUserAgent = {
ua: signals.ua,
deviceType,
isMobile: deviceType === 'mobile',
};
cacheParsedUserAgent(cacheKey, parsed);
return parsed;
}
export function parseUserAgent(
userAgentHeader: UserAgentHeader
): ParsedUserAgent {
const ua = normalizeUserAgentHeader(userAgentHeader);
if (!ua) {
return EMPTY_USER_AGENT;
}
return parseUserAgentWithSignals({ ua });
}
export function isMobileUserAgent(userAgentHeader: UserAgentHeader): boolean {
return parseUserAgent(userAgentHeader).isMobile;
}
export function parseRequestUserAgent(
headers: IncomingHttpHeaders
): ParsedUserAgent {
const signals = parseUserAgentSignals(headers);
if (
!signals.ua &&
signals.clientHintMobile === undefined &&
!signals.clientHintPlatform
) {
return EMPTY_USER_AGENT;
}
return parseUserAgentWithSignals(signals);
}
export function isMobileRequest(headers: IncomingHttpHeaders): boolean {
return parseRequestUserAgent(headers).isMobile;
}
+2
View File
@@ -22,6 +22,7 @@ export enum Flavor {
Graphql = 'graphql',
Sync = 'sync',
Renderer = 'renderer',
Front = 'front',
Doc = 'doc',
Script = 'script',
}
@@ -108,6 +109,7 @@ export class Env implements AppEnv {
graphql: this.isFlavor(Flavor.Graphql),
sync: this.isFlavor(Flavor.Sync),
renderer: this.isFlavor(Flavor.Renderer),
front: this.FLAVOR === Flavor.Front,
doc: this.isFlavor(Flavor.Doc),
// Script in a special flavor, return true only when it is set explicitly
script: this.FLAVOR === Flavor.Script,