fix(runtime): make production commands executable

This commit is contained in:
chick
2026-07-18 15:12:48 +08:00
parent ae27b522ab
commit f89f393485
7 changed files with 205 additions and 25 deletions
+5 -4
View File
@@ -7,16 +7,17 @@
"scripts": {
"test": "vitest run --root ../.. apps/api/src",
"typecheck": "tsc -p tsconfig.json",
"canary": "node --experimental-strip-types src/canary-cli.ts",
"start:production": "node --experimental-strip-types src/production-cli.ts",
"migration": "node --experimental-strip-types src/application/legacy-import/migration-command.ts"
"canary": "tsx src/canary-cli.ts",
"start:production": "tsx src/production-cli.ts",
"migration": "tsx src/application/legacy-import/migration-command.ts"
},
"dependencies": {
"@multi-simadmin/contracts": "workspace:*",
"@multi-simadmin/operation-registry": "workspace:*",
"better-sqlite3": "12.11.1",
"drizzle-orm": "0.45.2",
"fastify": "5.10.0"
"fastify": "5.10.0",
"tsx": "4.22.4"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
@@ -44,7 +44,7 @@ export interface LegacyImportResult {
export type LegacyImportTransactionHook = (result: LegacyImportResult) => void;
interface NormalizedInstance extends Omit<LegacyImportInstancePreview, 'status'> {}
type NormalizedInstance = Omit<LegacyImportInstancePreview, 'status'>;
interface SourceRead {
readonly bytes: Buffer;
+2 -2
View File
@@ -1,5 +1,5 @@
import { createCanaryGateway } from './canary-gateway.ts';
import { readCanaryRuntimeOptions } from './canary-runtime.ts';
import { createCanaryGateway } from './canary-gateway.js';
import { readCanaryRuntimeOptions } from './canary-runtime.js';
const gateway = createCanaryGateway(readCanaryRuntimeOptions(process.env));
+164
View File
@@ -0,0 +1,164 @@
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
const repositoryRoot = resolve(import.meta.dirname, '../../..');
const gatewayToken = 'synthetic-runtime-test-token-at-least-32-bytes';
const cleanup: Array<() => Promise<void>> = [];
const execFileAsync = promisify(execFile);
interface RunningCommand {
readonly child: ChildProcessWithoutNullStreams;
output(): string;
}
function startPackageCommand(
script: 'canary' | 'start:production',
environment: NodeJS.ProcessEnv,
) {
const child = spawn('corepack', ['pnpm', '--filter', '@multi-simadmin/api', 'run', script], {
cwd: repositoryRoot,
env: { ...process.env, ...environment, FORCE_COLOR: '0' },
detached: true,
stdio: 'pipe',
});
let output = '';
child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString()));
const command: RunningCommand = { child, output: () => output };
cleanup.push(() => stopCommand(command));
return command;
}
async function stopCommand(command: RunningCommand): Promise<void> {
if (command.child.exitCode !== null || command.child.signalCode !== null) return;
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit) =>
command.child.once('exit', (code, signal) => resolveExit({ code, signal })),
);
if (command.child.pid === undefined) throw new Error('Runtime command has no pid');
process.kill(-command.child.pid, 'SIGTERM');
const result = await Promise.race([
exit,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Runtime did not stop after SIGTERM:\n${command.output()}`)),
5_000,
),
),
]);
// pnpm's wrapper reports 143 when its child handles the group SIGTERM cleanly.
expect(result.code === 0 || result.code === 143 || result.signal === 'SIGTERM').toBe(true);
}
async function waitForOutput(command: RunningCommand, text: string): Promise<void> {
const deadline = Date.now() + 10_000;
while (!command.output().includes(text)) {
if (command.child.exitCode !== null || command.child.signalCode !== null) {
throw new Error(`Runtime exited before becoming ready:\n${command.output()}`);
}
if (Date.now() >= deadline)
throw new Error(`Runtime readiness timed out:\n${command.output()}`);
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
}
}
async function listenOn(port: number): Promise<ReturnType<typeof createServer>> {
const server = createServer();
await new Promise<void>((resolveListen, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', resolveListen);
});
return server;
}
async function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
await new Promise<void>((resolveClose, reject) =>
server.close((error) => (error ? reject(error) : resolveClose())),
);
}
async function expectPortFree(port: number): Promise<void> {
const server = await listenOn(port);
await closeServer(server);
}
async function listenerPids(port: number): Promise<readonly string[]> {
try {
const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
return stdout.trim().split(/\s+/u).filter(Boolean).sort();
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === 1 || code === '1') return [];
throw error;
}
}
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()));
});
describe.sequential('executable package runtimes', () => {
it('starts the exact production command on 8790 and shuts down on SIGTERM', async () => {
await expectPortFree(8790);
const legacyListeners = await listenerPids(8788);
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-production-cli-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
const dataRoot = join(directory, 'data');
await mkdir(dataRoot);
const command = startPackageCommand('start:production', {
MULTI_SIMADMIN_DATA_ROOT: dataRoot,
MULTI_SIMADMIN_DATABASE_PATH: join(dataRoot, 'synthetic.sqlite'),
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
API_HOST: '127.0.0.1',
API_PORT: '8790',
});
await waitForOutput(command, 'API listening at http://127.0.0.1:8790');
const response = await fetch('http://127.0.0.1:8790/healthz', {
headers: { [GATEWAY_AUTH_HEADER]: gatewayToken },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ status: 'ok' });
expect(await listenerPids(8788)).toEqual(legacyListeners);
expect(command.output()).not.toContain(':8788');
expect(command.output()).not.toContain(gatewayToken);
await stopCommand(command);
await expectPortFree(8790);
expect(command.output()).not.toContain(gatewayToken);
}, 20_000);
it('starts the exact canary command on 8789 with synthetic dist and shuts down on SIGTERM', async () => {
const port = 8789;
await expectPortFree(port);
const legacyListeners = await listenerPids(8788);
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-canary-cli-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
await writeFile(join(directory, 'index.html'), '<main>synthetic canary</main>');
const command = startPackageCommand('canary', {
CANARY_DIST_DIR: directory,
CANARY_PORT: String(port),
CANARY_UPSTREAM_PORT: '8790',
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
});
await waitForOutput(command, `Canary gateway listening at http://127.0.0.1:${port}`);
const response = await fetch(`http://127.0.0.1:${port}/`);
expect(response.status).toBe(200);
expect(await response.text()).toBe('<main>synthetic canary</main>');
expect(await listenerPids(8788)).toEqual(legacyListeners);
expect(command.output()).not.toContain(':8788');
expect(command.output()).not.toContain(gatewayToken);
await stopCommand(command);
await expectPortFree(port);
expect(command.output()).not.toContain(gatewayToken);
}, 20_000);
});
+4 -4
View File
@@ -1,6 +1,6 @@
import { buildProductionControlPlane } from './production-control-plane.ts';
import { createRuntimeConfig } from './runtime-config.ts';
import { installBoundedShutdown } from './shutdown.ts';
import { buildProductionControlPlane } from './production-control-plane.js';
import { createRuntimeConfig } from './runtime-config.js';
import { installBoundedShutdown } from './shutdown.js';
try {
const config = createRuntimeConfig(process.env);
@@ -8,7 +8,7 @@ try {
databasePath: config.databasePath,
gatewayToken: config.gatewayToken,
});
await app.listen(config.api);
await app.listen({ host: config.api.host, port: config.api.port });
installBoundedShutdown(process, app, {
terminate: () => process.exit(1),
});
+1 -2
View File
@@ -4,6 +4,5 @@
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/canary-cli.ts", "src/production-cli.ts"]
"include": ["src/**/*.ts"]
}
+28 -12
View File
@@ -32,7 +32,7 @@ importers:
version: 8.53.0(eslint@9.39.2)(typescript@5.9.3)
vitest:
specifier: 4.1.0
version: 4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3))
version: 4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4))
apps/api:
dependencies:
@@ -51,6 +51,9 @@ importers:
fastify:
specifier: 5.10.0
version: 5.10.0
tsx:
specifier: 4.22.4
version: 4.22.4
devDependencies:
'@types/better-sqlite3':
specifier: 7.6.13
@@ -85,13 +88,13 @@ importers:
version: 19.2.3(@types/react@19.2.7)
'@vitejs/plugin-react':
specifier: 5.1.1
version: 5.1.1(vite@7.3.5(@types/node@24.13.3))
version: 5.1.1(vite@7.3.5(@types/node@24.13.3)(tsx@4.22.4))
jsdom:
specifier: 27.2.0
version: 27.2.0
vite:
specifier: 7.3.5
version: 7.3.5(@types/node@24.13.3)
version: 7.3.5(@types/node@24.13.3)(tsx@4.22.4)
packages/contracts: {}
@@ -1974,6 +1977,11 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
tsx@4.22.4:
resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==}
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
@@ -2907,7 +2915,7 @@ snapshots:
'@typescript-eslint/types': 8.53.0
eslint-visitor-keys: 4.2.1
'@vitejs/plugin-react@5.1.1(vite@7.3.5(@types/node@24.13.3))':
'@vitejs/plugin-react@5.1.1(vite@7.3.5(@types/node@24.13.3)(tsx@4.22.4))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -2915,7 +2923,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.47
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 7.3.5(@types/node@24.13.3)
vite: 7.3.5(@types/node@24.13.3)(tsx@4.22.4)
transitivePeerDependencies:
- supports-color
@@ -2928,13 +2936,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.0(vite@7.3.6(@types/node@24.13.3))':
'@vitest/mocker@4.1.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4))':
dependencies:
'@vitest/spy': 4.1.0
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.6(@types/node@24.13.3)
vite: 7.3.6(@types/node@24.13.3)(tsx@4.22.4)
'@vitest/pretty-format@4.1.0':
dependencies:
@@ -3924,6 +3932,12 @@ snapshots:
dependencies:
typescript: 5.9.3
tsx@4.22.4:
dependencies:
esbuild: 0.28.1
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
@@ -3962,7 +3976,7 @@ snapshots:
util-deprecate@1.0.2: {}
vite@7.3.5(@types/node@24.13.3):
vite@7.3.5(@types/node@24.13.3)(tsx@4.22.4):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.5)
@@ -3973,8 +3987,9 @@ snapshots:
optionalDependencies:
'@types/node': 24.13.3
fsevents: 2.3.3
tsx: 4.22.4
vite@7.3.6(@types/node@24.13.3):
vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4):
dependencies:
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.5)
@@ -3985,11 +4000,12 @@ snapshots:
optionalDependencies:
'@types/node': 24.13.3
fsevents: 2.3.3
tsx: 4.22.4
vitest@4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3)):
vitest@4.1.0(@types/node@24.13.3)(jsdom@28.1.0)(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4)):
dependencies:
'@vitest/expect': 4.1.0
'@vitest/mocker': 4.1.0(vite@7.3.6(@types/node@24.13.3))
'@vitest/mocker': 4.1.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.22.4))
'@vitest/pretty-format': 4.1.0
'@vitest/runner': 4.1.0
'@vitest/snapshot': 4.1.0
@@ -4006,7 +4022,7 @@ snapshots:
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 7.3.6(@types/node@24.13.3)
vite: 7.3.6(@types/node@24.13.3)(tsx@4.22.4)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3