feat(runtime): add production control-plane foundation

This commit is contained in:
chick
2026-07-18 12:43:46 +08:00
parent 9451e1ee42
commit 293c015f89
13 changed files with 513 additions and 31 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
"scripts": {
"test": "vitest run --root ../.. apps/api/src",
"typecheck": "tsc -p tsconfig.json",
"canary": "node --experimental-strip-types src/canary-cli.ts"
"canary": "node --experimental-strip-types src/canary-cli.ts",
"start:production": "node --experimental-strip-types src/production-cli.ts"
},
"dependencies": {
"@multi-simadmin/contracts": "workspace:*",
+39 -2
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
import type { Writable } from 'node:stream';
import Fastify, {
type FastifyInstance,
@@ -12,6 +13,7 @@ export const API_DEFAULT_PORT = 8790 as const;
const LEGACY_PORT = 8788;
export const REDACT_PATHS = Object.freeze([
`req.headers.${GATEWAY_AUTH_HEADER}`,
'req.headers.authorization',
'req.headers.cookie',
'req.headers.x-confirmation-token',
@@ -26,11 +28,24 @@ export interface ReadinessResult {
}
export interface BuildAppOptions {
readonly gatewayToken?: string;
readonly readiness?: () => ReadinessResult | Promise<ReadinessResult>;
readonly registerRoutes?: (app: FastifyInstance) => void | Promise<void>;
readonly logger?: false | { readonly stream?: Writable };
}
export function secureTokenEqual(
supplied: string,
expected: string,
compare: (
actual: NodeJS.ArrayBufferView,
expected: NodeJS.ArrayBufferView,
) => boolean = timingSafeEqual,
): boolean {
const digest = (value: string): Buffer => createHash('sha256').update(value, 'utf8').digest();
return compare(digest(supplied), digest(expected));
}
export interface ListenEnvironment {
readonly API_HOST?: string;
readonly API_PORT?: string;
@@ -171,10 +186,32 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
},
};
const app = Fastify(fastifyOptions);
const readiness = options.readiness ?? (() => ({ ready: true, checks: { bootstrap: 'ready' } }));
const readiness =
options.readiness ?? (() => ({ ready: false, checks: { bootstrap: 'unconfigured' } }));
app.addHook('onRequest', async (request, reply) => {
reply.header('X-Request-Id', request.id);
if (options.gatewayToken !== undefined) {
const supplied = request.headers[GATEWAY_AUTH_HEADER];
const authenticated = secureTokenEqual(
typeof supplied === 'string' ? supplied : '',
options.gatewayToken,
);
if (!authenticated) {
return reply
.code(401)
.type('application/problem+json')
.send(
sendProblem(
request,
401,
'Unauthorized',
'GATEWAY_AUTH_REQUIRED',
'Trusted gateway authentication is required.',
),
);
}
}
const host = request.headers.host;
if (!host || !isLoopbackAuthority(host)) {
return reply
+3 -20
View File
@@ -1,24 +1,7 @@
import { resolve } from 'node:path';
import { createCanaryGateway } from './canary-gateway.ts';
import { readCanaryRuntimeOptions } from './canary-runtime.ts';
import {
CANARY_DEFAULT_PORT,
CANARY_DEFAULT_UPSTREAM_PORT,
createCanaryGateway,
} from './canary-gateway.ts';
function environmentPort(name: string, fallback: number): number {
const value = process.env[name];
if (value === undefined || value === '') return fallback;
const port = Number(value);
if (!Number.isInteger(port)) throw new Error(`${name} must be an integer port`);
return port;
}
const gateway = createCanaryGateway({
distDir: resolve(process.env.CANARY_DIST_DIR ?? 'apps/web/dist'),
port: environmentPort('CANARY_PORT', CANARY_DEFAULT_PORT),
upstreamPort: environmentPort('CANARY_UPSTREAM_PORT', CANARY_DEFAULT_UPSTREAM_PORT),
});
const gateway = createCanaryGateway(readCanaryRuntimeOptions(process.env));
let stopping = false;
async function stop(signal: string): Promise<void> {
+20 -3
View File
@@ -10,6 +10,7 @@ import {
} from 'node:http';
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
export const CANARY_DEFAULT_HOST = '127.0.0.1';
export const CANARY_DEFAULT_PORT = 8789;
@@ -55,6 +56,7 @@ function isNumericLoopback(host: string): boolean {
}
export interface CanaryGatewayOptions {
readonly gatewayToken?: string;
readonly distDir: string;
readonly host?: string;
readonly port?: number;
@@ -87,6 +89,16 @@ function withoutHopByHop(headers: IncomingHttpHeaders): IncomingHttpHeaders {
);
}
function trustedUpstreamHeaders(
headers: IncomingHttpHeaders,
gatewayToken: string | undefined,
): IncomingHttpHeaders {
const sanitized = withoutHopByHop(headers);
delete sanitized[GATEWAY_AUTH_HEADER];
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
return sanitized;
}
function isProxyPath(pathname: string): boolean {
return pathname === '/healthz' || pathname === '/readyz' || pathname.startsWith('/api/v1/');
}
@@ -114,6 +126,7 @@ async function proxyRequest(
response: ServerResponse,
upstreamHost: string,
upstreamPort: number,
gatewayToken: string | undefined,
): Promise<void> {
await new Promise<void>((resolvePromise) => {
const upstream = httpRequest(
@@ -122,7 +135,7 @@ async function proxyRequest(
port: upstreamPort,
method: incoming.method,
path: incoming.url,
headers: withoutHopByHop(incoming.headers),
headers: trustedUpstreamHeaders(incoming.headers, gatewayToken),
},
(upstreamResponse) => {
response.writeHead(
@@ -235,7 +248,7 @@ export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGatewa
}
if (isProxyPath(pathname)) {
await proxyRequest(request, response, upstreamHost, upstreamPort);
await proxyRequest(request, response, upstreamHost, upstreamPort, options.gatewayToken);
return;
}
if (pathname.startsWith('/api')) return sendText(response, 404, 'Not Found');
@@ -245,7 +258,11 @@ export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGatewa
const distRoot = await realpath(resolve(options.distDir)).catch(() => resolve(options.distDir));
if (pathname !== '/' && (await serveFile(request, response, distRoot, pathname))) return;
if (!(await serveFile(request, response, distRoot, '/index.html'))) {
if (
pathname.startsWith('/assets/') ||
extname(pathname) !== '' ||
!(await serveFile(request, response, distRoot, '/index.html'))
) {
sendText(response, 404, 'Not Found');
}
}
+43
View File
@@ -0,0 +1,43 @@
import { resolve } from 'node:path';
import {
CANARY_DEFAULT_PORT,
CANARY_DEFAULT_UPSTREAM_PORT,
type CanaryGatewayOptions,
} from './canary-gateway.js';
export interface CanaryRuntimeEnvironment {
readonly CANARY_DIST_DIR?: string;
readonly CANARY_PORT?: string;
readonly CANARY_UPSTREAM_PORT?: string;
readonly MULTI_SIMADMIN_GATEWAY_TOKEN?: string;
}
function environmentPort(value: string | undefined, name: string, fallback: number): number {
if (value === undefined || value === '') return fallback;
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65_535)
throw new Error(`${name} must be an integer port`);
return port;
}
export function readCanaryRuntimeOptions(
environment: CanaryRuntimeEnvironment,
): CanaryGatewayOptions {
const gatewayToken = environment.MULTI_SIMADMIN_GATEWAY_TOKEN;
if (
!gatewayToken ||
Buffer.byteLength(gatewayToken) < 32 ||
Buffer.byteLength(gatewayToken) > 512
)
throw new Error('Gateway token must contain between 32 and 512 bytes');
return Object.freeze({
distDir: resolve(environment.CANARY_DIST_DIR ?? 'apps/web/dist'),
port: environmentPort(environment.CANARY_PORT, 'CANARY_PORT', CANARY_DEFAULT_PORT),
upstreamPort: environmentPort(
environment.CANARY_UPSTREAM_PORT,
'CANARY_UPSTREAM_PORT',
CANARY_DEFAULT_UPSTREAM_PORT,
),
gatewayToken,
});
}
+19
View File
@@ -0,0 +1,19 @@
import { buildProductionControlPlane } from './production-control-plane.ts';
import { createRuntimeConfig } from './runtime-config.ts';
import { installBoundedShutdown } from './shutdown.ts';
try {
const config = createRuntimeConfig(process.env);
const app = buildProductionControlPlane({
databasePath: config.databasePath,
gatewayToken: config.gatewayToken,
});
await app.listen(config.api);
installBoundedShutdown(process, app, {
terminate: () => process.exit(1),
});
process.stdout.write(`API listening at http://${config.api.host}:${config.api.port}\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
+14 -2
View File
@@ -9,11 +9,14 @@ import { migrateDatabase } from './infrastructure/database/migrations.js';
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js';
export interface ProductionControlPlaneOptions {
readonly databasePath: string;
readonly store?: SecretStore;
readonly upstream?: SafeControlPlaneUpstream;
readonly gatewayToken?: string;
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
}
export function buildProductionControlPlane(
@@ -29,11 +32,20 @@ export function buildProductionControlPlane(
const db = openDatabase(options.databasePath);
try {
migrateDatabase(db);
const store = options.store ?? new MacOSKeychainSecretStore();
const app = buildControlPlaneApp({
db,
store: options.store ?? new MacOSKeychainSecretStore(),
store,
upstream: options.upstream ?? createProductionUpstream(),
app: { logger: {} },
app: {
logger: {},
...(options.gatewayToken ? { gatewayToken: options.gatewayToken } : {}),
readiness: createProductionReadiness({
db,
gatewayToken: options.gatewayToken ?? '',
keychainMetadataCheck: options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
}),
},
});
app.addHook('onClose', async () => {
if (db.open) db.close();
+62
View File
@@ -0,0 +1,62 @@
import type Database from 'better-sqlite3';
import { stat as fsStat } from 'node:fs/promises';
import { MIGRATIONS } from './infrastructure/database/migrations.js';
import type { ReadinessResult } from './app.js';
export interface ProductionReadinessOptions {
readonly db: Database.Database;
readonly gatewayToken: string;
/** Metadata/availability probe only. This callback must never retrieve a secret. */
readonly keychainMetadataCheck: () => boolean | Promise<boolean>;
}
interface ExecutableMetadata {
isFile(): boolean;
readonly mode: number;
}
export async function defaultKeychainMetadataCheck(
platform: NodeJS.Platform = process.platform,
stat: (path: string) => Promise<ExecutableMetadata> = fsStat,
): Promise<boolean> {
if (platform !== 'darwin') return false;
try {
const metadata = await stat('/usr/bin/security');
return metadata.isFile() && (metadata.mode & 0o111) !== 0;
} catch {
return false;
}
}
export function createProductionReadiness(
options: ProductionReadinessOptions,
): () => Promise<ReadinessResult> {
return async () => {
const checks: Record<string, string> = {};
let ready = true;
try {
options.db.prepare('SELECT 1').get();
checks.database = 'ready';
} catch {
checks.database = 'unavailable';
ready = false;
}
try {
const expected = MIGRATIONS.at(-1)?.id ?? 0;
const row = options.db.prepare('SELECT MAX(id) AS id FROM schema_migrations').get() as {
id: number | null;
};
checks.migrations = row.id === expected ? 'ready' : 'pending';
if (row.id !== expected) ready = false;
} catch {
checks.migrations = 'unavailable';
ready = false;
}
checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured';
if (checks.gatewayAuth !== 'ready') ready = false;
try {
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
} catch {
checks.keychain = 'unavailable';
}
if (checks.keychain !== 'ready') ready = false;
return { ready, checks };
};
}
+71
View File
@@ -0,0 +1,71 @@
import { realpathSync } from 'node:fs';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export const GATEWAY_AUTH_HEADER = 'x-multi-simadmin-gateway-token' as const;
export interface RuntimeEnvironment {
readonly MULTI_SIMADMIN_DATA_ROOT?: string;
readonly MULTI_SIMADMIN_DATABASE_PATH?: string;
readonly MULTI_SIMADMIN_GATEWAY_TOKEN?: string;
readonly API_HOST?: string;
readonly API_PORT?: string;
}
export interface RuntimeConfig {
readonly api: { readonly host: '127.0.0.1'; readonly port: 8790 };
readonly dataRoot: string;
readonly databasePath: string;
readonly gatewayToken: string;
}
function within(root: string, candidate: string): boolean {
const child = relative(root, candidate);
return child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child);
}
function canonicalExistingAncestor(path: string): string {
let existing = path;
const suffix: string[] = [];
for (;;) {
try {
return resolve(realpathSync(existing), ...suffix);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
const parent = dirname(existing);
if (parent === existing) throw error;
suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
existing = parent;
}
}
}
export function createRuntimeConfig(environment: RuntimeEnvironment): RuntimeConfig {
if (environment.API_HOST !== undefined && environment.API_HOST !== '127.0.0.1')
throw new Error('API host is fixed at 127.0.0.1');
if (environment.API_PORT !== undefined && environment.API_PORT !== '8790')
throw new Error('API port is fixed at 8790');
const rootInput = environment.MULTI_SIMADMIN_DATA_ROOT;
const databaseInput = environment.MULTI_SIMADMIN_DATABASE_PATH;
if (!rootInput || !isAbsolute(rootInput))
throw new Error('An explicit absolute local app data root is required');
if (!databaseInput || !isAbsolute(databaseInput))
throw new Error('An explicit absolute SQLite database path is required');
const dataRoot = resolve(rootInput);
const databasePath = resolve(databaseInput);
if (!within(dataRoot, databasePath) || databasePath === dataRoot)
throw new Error('Database path must be within the local app data root');
const canonicalRoot = canonicalExistingAncestor(dataRoot);
const canonicalDatabase = canonicalExistingAncestor(databasePath);
if (!within(canonicalRoot, canonicalDatabase) || canonicalDatabase === canonicalRoot)
throw new Error(
'Database path must remain within the local app data root after resolving symlinks',
);
const gatewayToken = environment.MULTI_SIMADMIN_GATEWAY_TOKEN;
if (
!gatewayToken ||
Buffer.byteLength(gatewayToken) < 32 ||
Buffer.byteLength(gatewayToken) > 512
)
throw new Error('Gateway token must contain at least 32 bytes');
return Object.freeze({
api: Object.freeze({ host: '127.0.0.1' as const, port: 8790 as const }),
dataRoot,
databasePath,
gatewayToken,
});
}
+191
View File
@@ -0,0 +1,191 @@
import { EventEmitter } from 'node:events';
import { lstat, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRuntimeConfig, GATEWAY_AUTH_HEADER } from './runtime-config.js';
import { buildApp } from './app.js';
import { createCanaryGateway, type CanaryGateway } from './canary-gateway.js';
import { createProductionReadiness } from './production-readiness.js';
import { defaultKeychainMetadataCheck } from './production-readiness.js';
import { readCanaryRuntimeOptions } from './canary-runtime.js';
import { installBoundedShutdown } from './shutdown.js';
import { secureTokenEqual } from './app.js';
import { openDatabase } from './infrastructure/database/database.js';
import { migrateDatabase } from './infrastructure/database/migrations.js';
const cleanup: Array<() => Promise<void>> = [];
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()));
});
const token = 'runtime-gateway-token-with-at-least-32-bytes';
describe('production runtime foundation', () => {
it('creates one deeply immutable fixed-listener config and validates before side effects', () => {
const root = resolve('/tmp/multi-simadmin-data');
const config = createRuntimeConfig({
MULTI_SIMADMIN_DATA_ROOT: root,
MULTI_SIMADMIN_DATABASE_PATH: join(root, 'db.sqlite'),
MULTI_SIMADMIN_GATEWAY_TOKEN: token,
API_HOST: '127.0.0.1',
API_PORT: '8790',
});
expect(config).toEqual({
api: { host: '127.0.0.1', port: 8790 },
dataRoot: root,
databasePath: join(root, 'db.sqlite'),
gatewayToken: token,
});
expect(Object.isFrozen(config)).toBe(true);
expect(Object.isFrozen(config.api)).toBe(true);
expect(() =>
createRuntimeConfig({
MULTI_SIMADMIN_DATA_ROOT: root,
MULTI_SIMADMIN_DATABASE_PATH: '/tmp/escape.sqlite',
MULTI_SIMADMIN_GATEWAY_TOKEN: token,
}),
).toThrow(/data root/i);
expect(() =>
createRuntimeConfig({
MULTI_SIMADMIN_DATA_ROOT: root,
MULTI_SIMADMIN_DATABASE_PATH: join(root, 'db.sqlite'),
MULTI_SIMADMIN_GATEWAY_TOKEN: 'weak',
}),
).toThrow(/token/i);
});
it('rejects a database path whose existing symlink ancestor escapes before creating a DB', async () => {
const directory = await mkdtemp(join(tmpdir(), 'runtime-containment-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
const root = join(directory, 'data');
const outside = join(directory, 'outside');
await mkdir(root);
await mkdir(outside);
await symlink(outside, join(root, 'escape'));
const databasePath = join(root, 'escape', 'db.sqlite');
expect(() =>
createRuntimeConfig({
MULTI_SIMADMIN_DATA_ROOT: root,
MULTI_SIMADMIN_DATABASE_PATH: databasePath,
MULTI_SIMADMIN_GATEWAY_TOKEN: token,
}),
).toThrow(/data root|symlink/i);
await expect(lstat(join(outside, 'db.sqlite'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('reads and validates the canary gateway token from its environment', () => {
expect(
readCanaryRuntimeOptions({
MULTI_SIMADMIN_GATEWAY_TOKEN: token,
CANARY_DIST_DIR: '/tmp/dist',
CANARY_PORT: '8789',
CANARY_UPSTREAM_PORT: '8790',
}),
).toMatchObject({ gatewayToken: token, port: 8789, upstreamPort: 8790 });
expect(() => readCanaryRuntimeOptions({ CANARY_DIST_DIR: '/tmp/dist' })).toThrow(/token/i);
expect(() =>
readCanaryRuntimeOptions({
CANARY_DIST_DIR: '/tmp/dist',
MULTI_SIMADMIN_GATEWAY_TOKEN: 'weak',
}),
).toThrow(/token/i);
});
it('always compares fixed-size token digests with timingSafeEqual', () => {
const compare = vi.fn(() => false);
expect(secureTokenEqual('x', token, compare)).toBe(false);
expect(compare).toHaveBeenCalledOnce();
const [actual, expected] = compare.mock.calls[0] as unknown as [Buffer, Buffer];
expect(actual).toHaveLength(32);
expect(expected).toHaveLength(32);
});
it('forces pluggable termination when graceful shutdown exceeds its deadline', async () => {
vi.useFakeTimers();
const target = new EventEmitter();
const terminate = vi.fn();
const stop = installBoundedShutdown(
target,
{ close: () => new Promise(() => undefined) },
{ timeoutMs: 25, terminate },
);
const stopping = stop();
await vi.advanceTimersByTimeAsync(25);
await stopping;
expect(terminate).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it('default Keychain readiness checks only Darwin security executable metadata', async () => {
const stat = vi.fn(async () => ({ isFile: () => true, mode: 0o100755 }));
await expect(defaultKeychainMetadataCheck('darwin', stat)).resolves.toBe(true);
expect(stat).toHaveBeenCalledExactlyOnceWith('/usr/bin/security');
stat.mockClear();
await expect(defaultKeychainMetadataCheck('linux', stat)).resolves.toBe(false);
expect(stat).not.toHaveBeenCalled();
});
it('authenticates internal requests without reflecting credentials', async () => {
const app = buildApp({ gatewayToken: token });
cleanup.push(() => app.close());
expect((await app.inject({ url: '/healthz' })).statusCode).toBe(401);
expect(
(
await app.inject({
url: '/healthz',
headers: { [GATEWAY_AUTH_HEADER]: 'wrong-but-same-length-xxxxxxxxxxxxxxxxx' },
})
).statusCode,
).toBe(401);
const ok = await app.inject({ url: '/healthz', headers: { [GATEWAY_AUTH_HEADER]: token } });
expect(ok.statusCode).toBe(200);
expect(ok.payload).not.toContain(token);
});
it('readiness checks a live migrated DB and auth configuration without reading secrets', async () => {
const directory = await mkdtemp(join(tmpdir(), 'runtime-ready-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
const db = openDatabase(join(directory, 'db.sqlite'));
migrateDatabase(db);
const keychainMetadataCheck = vi.fn(async () => true);
const readiness = createProductionReadiness({ db, gatewayToken: token, keychainMetadataCheck });
expect(await readiness()).toEqual({
ready: true,
checks: { database: 'ready', migrations: 'ready', gatewayAuth: 'ready', keychain: 'ready' },
});
expect(keychainMetadataCheck).toHaveBeenCalledOnce();
db.close();
expect((await readiness()).ready).toBe(false);
});
it('gateway replaces forged auth and only falls back for extensionless routes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'runtime-gateway-'));
await mkdir(join(directory, 'assets'));
await writeFile(join(directory, 'index.html'), '<main>SPA</main>');
cleanup.push(() => rm(directory, { recursive: true, force: true }));
let seen: string | string[] | undefined;
const upstream = createServer((request, response) => {
seen = request.headers[GATEWAY_AUTH_HEADER];
response.end('ok');
});
await new Promise<void>((done) => upstream.listen(0, '127.0.0.1', done));
cleanup.push(() => new Promise<void>((done) => upstream.close(() => done())));
const address = upstream.address();
if (!address || typeof address === 'string') throw new Error('address');
const gateway: CanaryGateway = createCanaryGateway({
distDir: directory,
port: 0,
upstreamPort: address.port,
gatewayToken: token,
});
await gateway.start();
cleanup.push(() => gateway.stop());
await fetch(`${gateway.origin}/healthz`, { headers: { [GATEWAY_AUTH_HEADER]: 'forged' } });
expect(seen).toBe(token);
expect((await fetch(`${gateway.origin}/assets/missing.js`)).status).toBe(404);
expect((await fetch(`${gateway.origin}/missing.css`)).status).toBe(404);
expect((await fetch(`${gateway.origin}/instances/one`)).status).toBe(200);
});
});
+37
View File
@@ -0,0 +1,37 @@
export interface ClosableRuntime {
close(): Promise<void>;
}
export interface SignalTarget {
once(event: 'SIGINT' | 'SIGTERM', listener: () => void): unknown;
removeListener(event: 'SIGINT' | 'SIGTERM', listener: () => void): unknown;
}
export function installBoundedShutdown(
target: SignalTarget,
runtime: ClosableRuntime,
options: { readonly timeoutMs?: number; readonly terminate?: () => void } = {},
): () => Promise<void> {
let shutdown: Promise<void> | undefined;
const timeoutMs = options.timeoutMs ?? 5_000;
const stop = (): Promise<void> => {
if (shutdown) return shutdown;
let timeout: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<void>((resolve) => {
timeout = setTimeout(() => {
options.terminate?.();
resolve();
}, timeoutMs);
});
shutdown = Promise.race([runtime.close(), deadline]).finally(() => {
if (timeout !== undefined) clearTimeout(timeout);
target.removeListener('SIGINT', listener);
target.removeListener('SIGTERM', listener);
});
return shutdown;
};
const listener = (): void => {
void stop();
};
target.once('SIGINT', listener);
target.once('SIGTERM', listener);
return stop;
}
+11 -2
View File
@@ -1,9 +1,11 @@
import { createListenOptions, type ListenEnvironment } from './app.js';
import { buildProductionControlPlane } from './production-control-plane.js';
import { createRuntimeConfig, type RuntimeEnvironment } from './runtime-config.js';
export interface StartApiOptions {
readonly environment?: ListenEnvironment;
readonly databasePath?: string;
readonly runtimeEnvironment?: RuntimeEnvironment;
readonly app?: {
listen(options: { readonly host: string; readonly port: number }): Promise<string>;
close(): Promise<void>;
@@ -12,9 +14,16 @@ export interface StartApiOptions {
}
export async function startApi(options: StartApiOptions = {}): Promise<void> {
const listenOptions = createListenOptions(options.environment ?? {});
const runtime = options.runtimeEnvironment
? createRuntimeConfig(options.runtimeEnvironment)
: undefined;
const listenOptions = runtime?.api ?? createListenOptions(options.environment ?? {});
const app =
options.app ?? buildProductionControlPlane({ databasePath: options.databasePath ?? '' });
options.app ??
buildProductionControlPlane({
databasePath: runtime?.databasePath ?? options.databasePath ?? '',
...(runtime ? { gatewayToken: runtime.gatewayToken } : {}),
});
try {
await app.listen(listenOptions);
} catch (listenError) {
+1 -1
View File
@@ -5,5 +5,5 @@
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/canary-cli.ts"]
"exclude": ["src/canary-cli.ts", "src/production-cli.ts"]
}