mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
5cb87a4df5
Three small gaps in how the process behaves at its edges: - An unhandled rejection or uncaught exception crashed the process with the stack on stderr only. index.ts now logs it to trek.log and runs the same orderly shutdown a SIGTERM gets, exiting with 1; a second fatal error during that shutdown exits at once. - /api/health/ready kept answering 200 while a SIGTERM was draining the process, so an orchestrator sent traffic for another probe period. A shutdown now marks the new ReadinessService first (and Nest's own shutdown hook marks it too), and the probe answers 503 from then on. - Node closes an idle keep-alive connection after 5 seconds while reverse proxies keep upstream connections for about a minute, which shows up as intermittent 502s. The server now keeps them for 65 seconds, configurable through HTTP_KEEP_ALIVE_TIMEOUT_MS (owned by the httpConfig token), with the headers timeout one second above.
238 lines
11 KiB
TypeScript
238 lines
11 KiB
TypeScript
import 'reflect-metadata';
|
|
import 'dotenv/config';
|
|
// Fail-fast env validation — must stay directly after dotenv so a malformed
|
|
// variable aborts before any other module runs its import-time side effects
|
|
// (config.ts key resolution, ...).
|
|
import './app-config/boot-validate';
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
// bootstrap is required inside bootstrap() below, not imported here. Importing
|
|
// it no longer opens the database (the ORM's first connect inside buildApp()
|
|
// does, through DatabaseLifecycle), but a first-start restore (#1089) has to
|
|
// put the backup's database in place before anything near it loads, and
|
|
// keeping the require after the restore keeps that true by construction.
|
|
import type * as Bootstrap from './bootstrap';
|
|
import type { DatabaseLifecycle } from './nest/database/database-lifecycle.service';
|
|
|
|
import { getAppUrl, getMcpSafeUrl, readEnv } from './app-config';
|
|
import { resolveDataPaths } from './app-config/data-paths';
|
|
import { resolveDbPath } from './db/db-path';
|
|
import { flushLogFileSync, logError } from './nest/audit/audit-log.logger';
|
|
import { ReadinessService } from './nest/health/readiness.service';
|
|
import { createFatalHandler } from './shutdown';
|
|
|
|
// data/tmp is the driver-agnostic global scratch dir (restore-upload spool,
|
|
// mirror stream staging) and stays boot-created here. Driver-owned roots — the
|
|
// uploads/ category tree and data/backups — are ensured by LocalDriver.init on
|
|
// every storage-registry load (boot and reload(), storage-registry.service.ts),
|
|
// which keeps the #1762 EACCES guarantee: a non-writable bind mount still
|
|
// fails loudly at startup, inside app.init(), instead of as a stray 500 on
|
|
// first upload. The Dockerfile `mkdir -p` list is pinned to the registry's
|
|
// category prefixes by tests/unit/uploads-dirs.test.ts.
|
|
const { tmpDir } = resolveDataPaths();
|
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
|
|
const PORT = readEnv().app.port;
|
|
const HOST = readEnv().app.host;
|
|
const APP_VERSION: string = readEnv().app.appVersion || (require('../package.json') as { version: string }).version;
|
|
|
|
const onListen = () => {
|
|
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./nest/audit/audit-log.logger');
|
|
const env = readEnv();
|
|
const LOG_LVL = (env.app.logLevel || 'info').toLowerCase();
|
|
const tz = env.app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
const origins = env.http.allowedOriginsRaw || '(same-origin)';
|
|
const appUrl = getAppUrl();
|
|
const resolvedAppUrl = getMcpSafeUrl();
|
|
const banner = [
|
|
'──────────────────────────────────────',
|
|
' TREK API started',
|
|
` Version ${APP_VERSION}`,
|
|
...(HOST ? [` Host: ${HOST}`] : []),
|
|
` Container Port: ${PORT}`,
|
|
` App URL: ${appUrl}`,
|
|
` Environment: ${env.app.nodeEnv?.toLowerCase() || 'development'}`,
|
|
` Timezone: ${tz}`,
|
|
` Origins: ${origins}`,
|
|
` Log level: ${LOG_LVL}`,
|
|
` Log file: /app/data/logs/trek.log`,
|
|
` PID: ${process.pid}`,
|
|
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
|
'──────────────────────────────────────',
|
|
];
|
|
banner.forEach(l => console.log(l));
|
|
sLogInfo('NestJS serving all routes (Express decommissioned)');
|
|
if (env.app.appUrl) {
|
|
let parsedAppUrl: URL | null = null;
|
|
try { parsedAppUrl = new URL(env.app.appUrl); } catch { /* invalid */ }
|
|
|
|
if (!parsedAppUrl) {
|
|
sLogWarn(`APP_URL: "${env.app.appUrl}" is not a valid URL — it will be ignored.`);
|
|
}
|
|
|
|
const mcpSafe = parsedAppUrl !== null && (
|
|
parsedAppUrl.protocol === 'https:' ||
|
|
parsedAppUrl.hostname === 'localhost' ||
|
|
parsedAppUrl.hostname === '127.0.0.1'
|
|
);
|
|
if (!mcpSafe) {
|
|
sLogWarn(`APP_URL: not MCP-safe (requires https:// or http://localhost) — MCP will use ${resolvedAppUrl}.`);
|
|
}
|
|
}
|
|
if (env.demo.enabled) sLogInfo('Demo mode: ENABLED');
|
|
if (env.demo.enabled && env.app.isProduction) {
|
|
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
|
}
|
|
// Nothing else to boot by hand: the crons are domain providers on the
|
|
// scheduling registrar, and /ws is the Nest gateway buildApp already bound.
|
|
};
|
|
|
|
let server: http.Server;
|
|
let nestApp: INestApplication;
|
|
let database: DatabaseLifecycle | undefined;
|
|
|
|
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
|
|
async function bootstrap(): Promise<void> {
|
|
// The whole surface runs on the single NestJS app now (Express decommissioned):
|
|
// global pipeline + /uploads + every /api domain + the platform/transport routes
|
|
// (/mcp, /.well-known, OAuth SDK, SPA catch-all). buildApp() owns the composition
|
|
// order; it is shared with the integration-test harness so they can't drift.
|
|
const restore = await restoreBeforeTheDatabaseOpens();
|
|
const { buildApp, getHttpServer, DatabaseLifecycle: Lifecycle } = require('./bootstrap') as typeof Bootstrap;
|
|
nestApp = await buildApp();
|
|
database = nestApp.get(Lifecycle);
|
|
if (restore.restored) await finishFirstBootRestore(nestApp, restore);
|
|
// The server buildApp created and bound /ws to. Creating a second one here
|
|
// would serve the REST API fine and leave the gateway attached to a socket
|
|
// nobody listens on.
|
|
server = getHttpServer();
|
|
|
|
// A bind failure has to be fatal and loud, and it needs saying explicitly.
|
|
// listen() reports failure by event, not by rejecting, so the catch around
|
|
// bootstrap() never sees it. And since buildApp attaches the ws server to this
|
|
// http server, ws has already registered its own `error` listener on it, which
|
|
// is enough for Node to stop throwing on an unhandled one. Without this the
|
|
// process would survive EADDRINUSE, never run onListen, and serve nothing
|
|
// while looking healthy.
|
|
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
const where = HOST ? `${HOST}:${PORT}` : `:${PORT}`;
|
|
console.error(`Fatal: cannot listen on ${where} — ${err.code ?? ''} ${err.message}`);
|
|
process.exit(1);
|
|
});
|
|
|
|
if (HOST) server.listen(PORT, HOST, onListen);
|
|
else server.listen(PORT, onListen);
|
|
}
|
|
|
|
/**
|
|
* RESTORE_FROM_BACKUP on a first start (#1089), before anything opens the
|
|
* database. See nest/backup/boot-restore.ts for when it runs and why it fails
|
|
* the start rather than coming up empty.
|
|
*/
|
|
async function restoreBeforeTheDatabaseOpens() {
|
|
const { restoreOnFirstBoot } = require('./nest/backup/boot-restore') as typeof import('./nest/backup/boot-restore');
|
|
const env = readEnv();
|
|
return restoreOnFirstBoot({
|
|
archive: env.backup.restoreFromBackup,
|
|
dbFile: resolveDbPath(),
|
|
dataDir: resolveDataPaths(env).dataDir,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The uploads go in once storage is up, through the same driver every upload
|
|
* uses, so an instance on S3 gets them in S3. A failure here leaves the restored
|
|
* database running and says where the files are, rather than stopping a start
|
|
* whose data is already in place.
|
|
*/
|
|
async function finishFirstBootRestore(
|
|
app: INestApplication,
|
|
restore: { uploads: string | null; staging: string },
|
|
): Promise<void> {
|
|
const { StorageService } = require('./nest/storage/storage.service') as typeof import('./nest/storage/storage.service');
|
|
const { rehydrateUploads } = require('./nest/backup/backup.impl') as typeof import('./nest/backup/backup.impl');
|
|
try {
|
|
if (restore.uploads) await rehydrateUploads(app.get(StorageService), restore.uploads);
|
|
fs.rmSync(restore.staging, { recursive: true, force: true });
|
|
console.log('[restore] Backup restored. Sign in with an account from the backup.');
|
|
} catch (err) {
|
|
console.error(`[restore] The database was restored, but the uploads could not be copied (${err instanceof Error ? err.message : String(err)}). They are still in ${restore.staging}.`);
|
|
}
|
|
}
|
|
|
|
bootstrap().catch((err) => {
|
|
console.error('Fatal: failed to bootstrap server', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
//
|
|
// The sequence itself lives in ./shutdown so it can be tested without a process
|
|
// to exit; this is only the wiring. See that file for why the order matters —
|
|
// in short, #2193: nothing here could ever release a WebSocket, so `docker
|
|
// stop` always ended in SIGKILL and exit 137.
|
|
let shuttingDown = false;
|
|
// Raised by a fatal error even when a signal's shutdown is already running, so
|
|
// the process still leaves with a failure code the orchestrator can see.
|
|
let exitCode = 0;
|
|
function shutdown(signal: string, code = 0): void {
|
|
exitCode = Math.max(exitCode, code);
|
|
// A second signal (the SIGINT that follows a Ctrl-C, or an impatient
|
|
// orchestrator sending SIGTERM twice) must not start a second teardown on
|
|
// top of the first one.
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
const { logInfo: sLogInfo, logError: sLogError } = require('./nest/audit/audit-log.logger');
|
|
const { closeMcpSessions } = require('./mcp');
|
|
const { getServer } = require('./nest/realtime/ws-state');
|
|
const { runShutdown } = require('./shutdown');
|
|
runShutdown(signal, {
|
|
// Both are still undefined if the signal beat bootstrap(); the sequence
|
|
// handles that and still exits rather than hanging or throwing.
|
|
server,
|
|
// nestApp.close() stops every cron via the scheduling registrar's shutdown
|
|
// hook, and tears the plugin supervisor's forked children down.
|
|
closeNestApp: async () => { await nestApp?.close(); },
|
|
getWsClients: () => getServer()?.clients ?? null,
|
|
closeMcpSessions,
|
|
// Through the lifecycle provider once the app is up; before that (a signal
|
|
// that beat bootstrap()) the module function is all there is.
|
|
closeDb: () => {
|
|
if (database) database.close();
|
|
else (require('./db/database') as typeof import('./db/database')).closeDb();
|
|
},
|
|
logInfo: sLogInfo,
|
|
logError: sLogError,
|
|
// Readiness answers 503 from here on; strict: false because the provider
|
|
// lives in HealthModule, not in the root module.
|
|
markDraining: () => nestApp?.get(ReadinessService, { strict: false }).markDraining(),
|
|
exitCode,
|
|
exit: (code: number) => process.exit(Math.max(code, exitCode)),
|
|
}).catch((err: unknown) => {
|
|
// Fire-and-forget would make this an unhandled rejection, which on Node 22
|
|
// is a crash — a worse ending than the one we are here to fix.
|
|
sLogError(`Shutdown failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
// trek.log is written in batches off the event loop. Whatever is still queued
|
|
// when the process ends, by any of the exits above or below, goes out
|
|
// synchronously here: an 'exit' listener cannot wait for a promise.
|
|
process.on('exit', () => flushLogFileSync());
|
|
|
|
// Last-resort handlers. Node would crash on either anyway, printing the stack
|
|
// to stderr only; this way it lands in trek.log first, and the process goes
|
|
// through the same orderly shutdown as on SIGTERM before it exits with 1.
|
|
const onFatal = createFatalHandler({
|
|
logError,
|
|
shutdown: (reason, code) => shutdown(reason, code),
|
|
exit: (code) => process.exit(code),
|
|
});
|
|
process.on('unhandledRejection', (reason) => onFatal('Unhandled promise rejection', reason));
|
|
process.on('uncaughtException', (error) => onFatal('Uncaught exception', error));
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|