#!/usr/bin/env node import { existsSync, readFileSync, realpathSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseEnv } from 'node:util'; import { assertNodeVersion } from './lib/node-version.mjs'; const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = 3000; const SHUTDOWN_TIMEOUT_MS = 60_000; export function parseStartArguments(args) { let envFile; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; if (argument !== '--env-file') throw new Error(`Unknown option: ${argument}`); const value = args[index + 1]; if (!value || value.startsWith('--')) throw new Error('--env-file requires a file path'); if (envFile) throw new Error('--env-file may only be provided once'); envFile = value; index += 1; } if (!envFile) throw new Error('--env-file is required for production startup'); return { envFile }; } export function applyEnvironmentFile(file, target = process.env) { const resolved = path.resolve(file); let parsed; try { parsed = parseEnv(readFileSync(resolved, 'utf8')); } catch (error) { throw new Error(`Cannot load production environment file ${resolved}: ${error.message}`, { cause: error }); } for (const [key, value] of Object.entries(parsed)) { if (!(key in target)) target[key] = value; } return resolved; } export function resolveServerOptions(environment = process.env) { const rawHost = Object.hasOwn(environment, 'HOST') ? environment.HOST : DEFAULT_HOST; if (typeof rawHost !== 'string' || !rawHost || !/^[A-Za-z0-9._:-]+$/.test(rawHost)) { throw new Error('HOST must be a hostname or IP address without a scheme, path, or whitespace'); } const rawPort = environment.PORT ?? String(DEFAULT_PORT); if (!/^\d+$/.test(rawPort)) throw new Error('PORT must be an integer from 1 through 65535'); const port = Number(rawPort); if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { throw new Error('PORT must be an integer from 1 through 65535'); } return { hostname: rawHost, port }; } export function assertProductionArtifact(file) { if (!existsSync(file)) { throw new Error(`Production Node artifact is missing at ${file}. Run npm run flue:build first.`); } return file; } export async function startNodeApplication({ application, loadApplication, background, serverOptions, }) { const ownership = background.acquireNodeProcessOwnership(); let flueLifecycle; let backgroundAttempted = false; try { // A restart resumes submissions while the application assembles, so the // connector details must be in memory before a render can run. await ownership.ready?.(); const loadedApplication = application ?? await loadApplication(); flueLifecycle = await loadedApplication.startFlueNodeServer(serverOptions); backgroundAttempted = true; await background.startNodeBackground(); } catch (error) { if (backgroundAttempted) await background.stopNodeBackground().catch(() => undefined); if (flueLifecycle) await flueLifecycle.stop().catch(() => undefined); ownership.release(); throw error; } let stopping; return { stop() { if (stopping) return stopping; stopping = (async () => { const failures = []; try { await background.stopNodeBackground(); } catch (error) { failures.push(error); } try { await flueLifecycle.stop(); } catch (error) { failures.push(error); } try { ownership.release(); } catch (error) { failures.push(error); } if (failures.length === 1) throw failures[0]; if (failures.length > 1) throw new AggregateError(failures, 'Node shutdown failed'); })(); return stopping; }, }; } export async function startProductionNode({ args = process.argv.slice(2) } = {}) { assertNodeVersion(); const { envFile } = parseStartArguments(args); applyEnvironmentFile(envFile); const options = resolveServerOptions(); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const appArtifact = assertProductionArtifact(path.join(root, 'dist', 'app.mjs')); const backgroundArtifact = assertProductionArtifact(path.join(root, 'dist', 'node-background.mjs')); const background = await import(pathToFileURL(backgroundArtifact).href); if (typeof background.acquireNodeProcessOwnership !== 'function' || typeof background.startNodeBackground !== 'function' || typeof background.stopNodeBackground !== 'function') { throw new Error(`Production Node artifact at ${backgroundArtifact} is incomplete. Rebuild it with npm run flue:build.`); } const lifecycle = await startNodeApplication({ loadApplication: async () => { const application = await import(pathToFileURL(appArtifact).href); if (typeof application.startFlueNodeServer !== 'function') { throw new Error(`Production Node artifact at ${appArtifact} does not export startFlueNodeServer. Rebuild it with npm run flue:build.`); } return application; }, background, serverOptions: options, }); // The managed tunnel must wait for this child's successful bind, not merely // an HTTP response that another process on the requested port could serve. if (process.send) process.send({ type: 'chickpea-ready' }); let shutdown; const stop = (exitCode) => { if (shutdown) return shutdown; shutdown = (async () => { let finalExitCode = exitCode; const timeout = setTimeout(() => { console.error('[chickpea] Shutdown timed out, exiting.'); process.exit(exitCode); }, SHUTDOWN_TIMEOUT_MS); timeout.unref(); try { await lifecycle.stop(); } catch { console.error('[chickpea] Graceful shutdown failed.'); finalExitCode = 1; } finally { clearTimeout(timeout); } process.exit(finalExitCode); })(); return shutdown; }; process.once('SIGINT', () => { void stop(130); }); process.once('SIGTERM', () => { void stop(143); }); process.once('disconnect', () => { void stop(0); }); return lifecycle; } export function isMainModule(argvEntry = process.argv[1], moduleUrl = import.meta.url) { if (!argvEntry) return false; try { return realpathSync(argvEntry) === realpathSync(fileURLToPath(moduleUrl)); } catch { return false; } } const isMain = isMainModule(); if (isMain) { startProductionNode().catch((error) => { console.error(`[chickpea] ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; }); }