mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-30 00:29:46 +08:00
feat: merge service (#14384)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user