mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
248 lines
11 KiB
TypeScript
248 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 { getAppUrl, getMcpSafeUrl, readEnv } from './app-config';
|
|
import { resolveDataPaths } from './app-config/data-paths';
|
|
// 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 { resolveDbPath } from './db/db-path';
|
|
import { flushLogFileSync, logError } from './nest/audit/audit-log.logger';
|
|
import type { DatabaseLifecycle } from './nest/database/database-lifecycle.service';
|
|
import { ReadinessService } from './nest/health/readiness.service';
|
|
import { createFatalHandler } from './shutdown';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
|
|
// 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'));
|