feat(ql3): add bounded local web console

This commit is contained in:
whyour
2026-08-28 05:21:43 +08:00
parent b7a8104d9f
commit 84d1a61372
19 changed files with 2333 additions and 34 deletions
@@ -0,0 +1,98 @@
import { createHash } from 'node:crypto';
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import path from 'node:path';
const MAX_ASSET_BYTES = 96 * 1_024;
const MAX_TOTAL_BYTES = 192 * 1_024;
const DEFINITIONS = Object.freeze([
Object.freeze({
requestPath: '/',
file: 'index.html',
contentType: 'text/html; charset=utf-8',
}),
Object.freeze({
requestPath: '/console.css',
file: 'console.css',
contentType: 'text/css; charset=utf-8',
}),
Object.freeze({
requestPath: '/console.js',
file: 'console.js',
contentType: 'text/javascript; charset=utf-8',
}),
]);
export interface LocalConsoleAsset {
readonly contentType: string;
readonly etag: string;
readonly body: Buffer;
}
export type LocalConsoleAssets = ReadonlyMap<string, LocalConsoleAsset>;
export class LocalConsoleAssetError extends Error {
readonly code = 'QL3_LOCAL_CONSOLE_ASSET_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Local Console asset is invalid: ${message}`, options);
this.name = 'LocalConsoleAssetError';
}
}
function loadAsset(
root: string,
definition: (typeof DEFINITIONS)[number],
): Readonly<LocalConsoleAsset> {
const filePath = path.join(root, definition.file);
let stat;
let body: Buffer;
try {
stat = lstatSync(filePath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 2 ||
stat.size > MAX_ASSET_BYTES ||
realpathSync(filePath) !== filePath
) {
throw new TypeError('asset identity is incompatible');
}
body = readFileSync(filePath);
} catch (error) {
throw new LocalConsoleAssetError(definition.file, { cause: error });
}
if (body.byteLength !== stat.size) {
throw new LocalConsoleAssetError(
`${definition.file} changed while loading`,
);
}
return Object.freeze({
contentType: definition.contentType,
etag: `"${createHash('sha256').update(body).digest('hex')}"`,
body,
});
}
export function loadLocalConsoleAssets(): LocalConsoleAssets {
const root = path.resolve(__dirname, '../../assets/console');
let canonicalRoot: string;
try {
const stat = lstatSync(root);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new TypeError();
canonicalRoot = realpathSync(root);
} catch (error) {
throw new LocalConsoleAssetError('asset root', { cause: error });
}
const assets = new Map<string, Readonly<LocalConsoleAsset>>();
let totalBytes = 0;
for (const definition of DEFINITIONS) {
const asset = loadAsset(canonicalRoot, definition);
totalBytes += asset.body.byteLength;
if (totalBytes > MAX_TOTAL_BYTES) {
throw new LocalConsoleAssetError('asset set exceeds its byte budget');
}
assets.set(definition.requestPath, asset);
}
return assets;
}
@@ -13,6 +13,10 @@ import type { BoundedRunListInput } from '@qinglong/runtime-core/bounded-run-lis
import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection';
import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
import {
loadLocalConsoleAssets,
type LocalConsoleAsset,
} from '../console/localConsoleAssets';
import type { LocalApiResponse } from './contract';
const MAX_HEADER_BYTES = 8 * 1_024;
@@ -38,6 +42,8 @@ const TASK_START_ROUTE_PATTERN =
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs$/;
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY =
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
type LocalApiRouteResolution =
| LocalApiAdmissionOperation
@@ -559,6 +565,45 @@ function send(
response.end(body);
}
function sendConsoleAsset(
response: ServerResponse,
requestId: string,
asset: Readonly<LocalConsoleAsset>,
): void {
if (response.destroyed || response.headersSent) return;
response.statusCode = 200;
response.setHeader('content-type', asset.contentType);
response.setHeader('cache-control', 'no-store');
response.setHeader(
'content-security-policy',
LOCAL_CONSOLE_CONTENT_SECURITY_POLICY,
);
response.setHeader('cross-origin-opener-policy', 'same-origin');
response.setHeader('cross-origin-resource-policy', 'same-origin');
response.setHeader(
'permissions-policy',
'camera=(), geolocation=(), microphone=()',
);
response.setHeader('referrer-policy', 'no-referrer');
response.setHeader('x-content-type-options', 'nosniff');
response.setHeader('x-frame-options', 'DENY');
response.setHeader('x-request-id', requestId);
response.setHeader('etag', asset.etag);
response.setHeader('content-length', asset.body.byteLength);
response.end(asset.body);
}
function sendConsoleFavicon(response: ServerResponse, requestId: string): void {
if (response.destroyed || response.headersSent) return;
response.statusCode = 204;
response.setHeader('cache-control', 'no-store');
response.setHeader('cross-origin-resource-policy', 'same-origin');
response.setHeader('referrer-policy', 'no-referrer');
response.setHeader('x-content-type-options', 'nosniff');
response.setHeader('x-request-id', requestId);
response.end();
}
function errorResponse(statusCode: number, code: string): LocalApiResponse {
return Object.freeze({
statusCode,
@@ -588,6 +633,7 @@ export async function startLocalApiHttpSurface(
options: LocalApiHttpSurfaceOptions,
): Promise<Readonly<ActiveLocalApiHttpSurface>> {
validateOptions(options);
const consoleAssets = loadLocalConsoleAssets();
const uuid = options.randomUuid ?? randomUUID;
const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32;
const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000;
@@ -611,6 +657,28 @@ export async function startLocalApiHttpSurface(
send(response, requestId, errorResponse(503, 'server_overloaded'));
return;
}
const consoleAsset =
request.method === 'GET' && typeof request.url === 'string'
? consoleAssets.get(request.url)
: undefined;
if (consoleAsset) {
if (hasRequestBody(request)) {
send(response, requestId, errorResponse(400, 'invalid_request_body'));
request.resume();
return;
}
sendConsoleAsset(response, requestId, consoleAsset);
return;
}
if (request.method === 'GET' && request.url === '/favicon.ico') {
if (hasRequestBody(request)) {
send(response, requestId, errorResponse(400, 'invalid_request_body'));
request.resume();
return;
}
sendConsoleFavicon(response, requestId);
return;
}
const resolvedRoute = route(request, options.profile);
if (!resolvedRoute) {
send(response, requestId, errorResponse(404, 'route_not_found'));