From f89f3934854961e34534649f585418e6ba926196 Mon Sep 17 00:00:00 2001 From: chick Date: Sat, 18 Jul 2026 15:12:48 +0800 Subject: [PATCH] fix(runtime): make production commands executable --- apps/api/package.json | 9 +- .../legacy-import/legacy-import.ts | 2 +- apps/api/src/canary-cli.ts | 4 +- apps/api/src/cli-runtime.test.ts | 164 ++++++++++++++++++ apps/api/src/production-cli.ts | 8 +- apps/api/tsconfig.json | 3 +- pnpm-lock.yaml | 40 +++-- 7 files changed, 205 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/cli-runtime.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index 77d383a..ee253ee 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/application/legacy-import/legacy-import.ts b/apps/api/src/application/legacy-import/legacy-import.ts index 5e3fa92..c72c7e3 100644 --- a/apps/api/src/application/legacy-import/legacy-import.ts +++ b/apps/api/src/application/legacy-import/legacy-import.ts @@ -44,7 +44,7 @@ export interface LegacyImportResult { export type LegacyImportTransactionHook = (result: LegacyImportResult) => void; -interface NormalizedInstance extends Omit {} +type NormalizedInstance = Omit; interface SourceRead { readonly bytes: Buffer; diff --git a/apps/api/src/canary-cli.ts b/apps/api/src/canary-cli.ts index da14d9d..4bfca13 100644 --- a/apps/api/src/canary-cli.ts +++ b/apps/api/src/canary-cli.ts @@ -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)); diff --git a/apps/api/src/cli-runtime.test.ts b/apps/api/src/cli-runtime.test.ts new file mode 100644 index 0000000..f958749 --- /dev/null +++ b/apps/api/src/cli-runtime.test.ts @@ -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> = []; +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 { + 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((_, 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 { + 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> { + const server = createServer(); + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', resolveListen); + }); + return server; +} + +async function closeServer(server: ReturnType): Promise { + await new Promise((resolveClose, reject) => + server.close((error) => (error ? reject(error) : resolveClose())), + ); +} + +async function expectPortFree(port: number): Promise { + const server = await listenOn(port); + await closeServer(server); +} + +async function listenerPids(port: number): Promise { + 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'), '
synthetic canary
'); + + 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('
synthetic canary
'); + 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); +}); diff --git a/apps/api/src/production-cli.ts b/apps/api/src/production-cli.ts index fdeaf51..d1d9325 100644 --- a/apps/api/src/production-cli.ts +++ b/apps/api/src/production-cli.ts @@ -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), }); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index dfcafb5..3dff0dc 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -4,6 +4,5 @@ "rootDir": "src", "types": ["node"] }, - "include": ["src/**/*.ts"], - "exclude": ["src/canary-cli.ts", "src/production-cli.ts"] + "include": ["src/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f550ec..a7c5f69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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