mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +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.
242 lines
14 KiB
TypeScript
242 lines
14 KiB
TypeScript
import { applyGlobalMiddleware, routingCspOrigins } from './middleware/globalMiddleware';
|
|
import { readEnv } from './app-config';
|
|
import { nestLogLevels } from './app-config/nest-log-levels';
|
|
import { httpConfig } from './nest/app-config';
|
|
import { AppModule } from './nest/app.module';
|
|
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
|
|
import { validateBodyContracts } from './nest/common/validate-body-contracts';
|
|
import { validateManagedRoutes } from './nest/common/validate-managed-routes';
|
|
import { validateRouteGuards } from './nest/common/validate-route-guards';
|
|
import { setupApiDocs } from './nest/platform/api-docs';
|
|
import { MCP_METADATA_MIDDLEWARE } from './nest/platform/mcp-metadata.middleware';
|
|
import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes';
|
|
import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter';
|
|
import { SettingsService } from './nest/settings/settings.service';
|
|
import { StorageService } from './nest/storage/storage.service';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import { withRequestContext } from './nest/database/request-context';
|
|
import { DatabaseLifecycle } from './nest/database/database-lifecycle.service';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
import type { ConfigType } from '@nestjs/config';
|
|
import { NestFactory } from '@nestjs/core';
|
|
import { ExpressAdapter } from '@nestjs/platform-express';
|
|
import { MAX_COLLECTION_FILE_BYTES } from '@trek/shared';
|
|
|
|
import express from 'express';
|
|
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
|
import nodeHttp from 'node:http';
|
|
|
|
/**
|
|
* Builds the unified TREK NestJS application that serves the ENTIRE surface — the
|
|
* former Express app is gone. One builder is shared by the production bootstrap
|
|
* (index.ts) and the integration-test harness so the two can never drift.
|
|
*
|
|
* Composition order is load-bearing. The few remaining pre-`app.init()` pieces
|
|
* are registered on the underlying Express instance first, because Nest's
|
|
* router terminates an unmatched request by throwing NotFoundException — it
|
|
* does NOT fall through to a route registered after init, so a post-init
|
|
* Express route is unreachable. Each pre-init piece matches its own requests
|
|
* and `next()`s everything else through to the Nest controllers:
|
|
*
|
|
* 1. applyGlobalMiddleware — helmet/CSP, CORS, HSTS, forced-HTTPS, request
|
|
* logging + cookie-parser.
|
|
* 2. applyPlatformUploads — the static + guarded /uploads/* routes.
|
|
* 3. MCP_METADATA_MIDDLEWARE — the SDK's OAuth discovery router + addon
|
|
* gate, container-built (DI) but mounted pathless here (see below).
|
|
* 4. applyPlatformStatic — the production built-client static assets (so a
|
|
* real asset request returns the file before the Nest router 404s it).
|
|
* 4b. setupApiDocs — Swagger UI/spec at /api/docs* when TREK_API_DOCS_ENABLED.
|
|
* 4c. the named body-parser wrappers — the global 100kb JSON/urlencoded
|
|
* parsers, exempting /mcp so the MCP SDK reads the raw stream.
|
|
* 5. app.init() — every domain controller, including the transport surface
|
|
* that used to live pre-init: /api/health (FeaturesController), OAuth
|
|
* discovery documents (DiscoveryController), the /oauth/consent COOP
|
|
* override (ConsentCoopMiddleware), /oauth/authorize + /oauth/register
|
|
* (SDK routers via OauthModule.configure) and /mcp (McpTransportModule).
|
|
*
|
|
* The SPA index.html fallback (unmatched GET → index.html in production) is the
|
|
* SpaFallbackFilter (APP_FILTER in AppModule); the global error envelope is the
|
|
* TrekExceptionFilter (also APP_FILTER).
|
|
*/
|
|
let boundHttpServer: nodeHttp.Server | null = null;
|
|
|
|
/**
|
|
* The provider that owns the database connection, for index.ts to close it on
|
|
* shutdown. Re-exported so index.ts reaches it through the one module it loads
|
|
* after a first-boot restore.
|
|
*/
|
|
export { DatabaseLifecycle };
|
|
|
|
/**
|
|
* The http server buildApp created and bound the ws gateway to.
|
|
*
|
|
* index.ts listens on it; the websocket suites connect to it. Anyone creating a
|
|
* second server around the same express instance would get a working REST API
|
|
* and a /ws that no client can reach.
|
|
*/
|
|
export function getHttpServer(): nodeHttp.Server {
|
|
if (!boundHttpServer) throw new Error('buildApp() has not run yet');
|
|
return boundHttpServer;
|
|
}
|
|
|
|
/**
|
|
* Keep-alive behind a reverse proxy. Node's default is to drop an idle
|
|
* keep-alive connection after 5 s, while proxies keep their upstream
|
|
* connections for about a minute: a proxy that reuses a socket Node has just
|
|
* closed answers that request with a 502, intermittently and with nothing in
|
|
* TREK's log. The headers timeout stays above the keep-alive one, so a
|
|
* connection waiting for its next request is never cut by the wrong timer.
|
|
*/
|
|
export function applyProxyTimeouts(server: nodeHttp.Server, keepAliveTimeoutMs: number): void {
|
|
server.keepAliveTimeout = keepAliveTimeoutMs;
|
|
server.headersTimeout = keepAliveTimeoutMs + 1_000;
|
|
}
|
|
|
|
export async function buildApp(): Promise<INestApplication> {
|
|
// rawBody keeps the unparsed request bytes on req.rawBody so a plugin webhook
|
|
// route can verify a provider's HMAC signature over the exact payload (the
|
|
// parsed JSON alone can't be re-serialised byte-for-byte).
|
|
// `logger` is the only place Nest's own logger learns about LOG_LEVEL; without
|
|
// it every boot prints its full route map whatever the operator asked for.
|
|
const app = await NestFactory.create(AppModule, new ExpressAdapter(), {
|
|
rawBody: true,
|
|
logger: nestLogLevels(readEnv().app.logLevel),
|
|
});
|
|
// Schema first, before ANY consumer reads it. The connection lifecycle is the
|
|
// DatabaseLifecycle provider's: it opens the connection (the ORM's first
|
|
// connect usually already has), binds the ORM to later swaps, and runs the
|
|
// legacy baseline, the migrations and the seeders. That is async, so this is
|
|
// the earliest point it can happen. It has to stay above the SettingsService
|
|
// resolution below, which is the boot's first DB read.
|
|
const orm = app.get(MikroORM);
|
|
await app.get(DatabaseLifecycle).open();
|
|
const instance = app.getHttpAdapter().getInstance();
|
|
// The http server is created HERE, not by the caller after buildApp returns,
|
|
// and that ordering is the whole point: Nest binds gateways during app.init(),
|
|
// so the ws adapter has to exist before it and has to already know which
|
|
// server to attach to. Handing it the app instead would bind the ws server to
|
|
// Nest's own internal http server, which this process never listens on: the
|
|
// boot succeeds, the gateway logs as registered, every test passes, and no
|
|
// browser can connect. Callers take the server from getHttpServer() below.
|
|
boundHttpServer = nodeHttp.createServer(instance);
|
|
// ConfigModule.forRoot's load factories already ran inside NestFactory.create,
|
|
// so the boot-stable snapshot is resolvable here, BEFORE app.init(); this is
|
|
// the one bridge that lets the pre-init Express layer consume the validated
|
|
// config instead of reading process.env itself.
|
|
const http = app.get<ConfigType<typeof httpConfig>>(httpConfig.KEY);
|
|
applyProxyTimeouts(boundHttpServer, http.keepAliveTimeoutMs);
|
|
app.useWebSocketAdapter(new TrekWsAdapter(boundHttpServer, orm));
|
|
// Same pre-init bridge: a self-hosted routing engine has to be named in connect-src, or
|
|
// the browser blocks every request to it without an error the app could report. Both
|
|
// engines go through the same door — the second one answers the avoidance questions
|
|
// the first cannot, and is blocked just as silently when the policy leaves it out.
|
|
const settings = app.get(SettingsService, { strict: false });
|
|
// Boot runs outside any HTTP request, so there is no per-request EntityManager
|
|
// yet and `allowGlobalContext` is off in production: every repository-backed
|
|
// read at boot goes through D6's explicit context (see request-context.ts).
|
|
const defaults = await withRequestContext(orm, () => settings?.getAdminUserDefaults());
|
|
const asUrl = (value: unknown) => (typeof value === 'string' ? value : null);
|
|
applyGlobalMiddleware(instance, {
|
|
http,
|
|
extraConnectSrc: routingCspOrigins([asUrl(defaults?.routing_base_url), asUrl(defaults?.valhalla_base_url)]),
|
|
});
|
|
// Same pre-init consumption bridge as httpConfig above: the StorageService
|
|
// instance is resolvable before init, and the handlers only *register* here —
|
|
// per-request resolution runs after app.init() completed the registry load.
|
|
applyPlatformUploads(instance, app.get(StorageService), orm);
|
|
// The SDK discovery router (+ its addon gate). Container-built so its deps
|
|
// are injected (same pre-init consumption bridge as httpConfig above), but
|
|
// applied here as a PATHLESS app.use: the SDK router matches absolute
|
|
// /.well-known/* paths against req.url, and any Nest wildcard forRoutes()
|
|
// mount is an Express pattern mount that strips the matched prefix from
|
|
// req.url before the middleware runs.
|
|
instance.use(app.get<RequestHandler>(MCP_METADATA_MIDDLEWARE));
|
|
applyPlatformStatic(instance);
|
|
// Pin the request-body ceiling explicitly. The Express shell used to set
|
|
// '100kb' and stopped doing it when the Nest instance took over parsing, which
|
|
// left the limit implicit — the same number, but nowhere anybody would find
|
|
// it, and one NestFactory default away from silently becoming something else.
|
|
// The verify hook keeps req.rawBody, which the plugin webhook routes need to
|
|
// check a provider's HMAC over the exact payload (same hook Nest's own
|
|
// useBodyParser would install).
|
|
//
|
|
// /mcp is exempted and stays RAW: the MCP SDK's StreamableHTTPServerTransport
|
|
// reads the request stream itself (its own size handling and JSON-RPC error
|
|
// bodies), and the transport controller passes the still-undefined req.body
|
|
// through as handleRequest's parsedBody argument. No Nest route may ever
|
|
// @Body() on /mcp. The wrapper NAMES are load-bearing:
|
|
// ExpressAdapter.registerParserMiddleware skips its own init-time parsers
|
|
// only when layers named jsonParser/urlencodedParser already sit in the stack.
|
|
const rawBodyKeeper = (req: Request, _res: Response, buffer: Buffer) => {
|
|
if (Buffer.isBuffer(buffer)) (req as Request & { rawBody?: Buffer }).rawBody = buffer;
|
|
};
|
|
/*
|
|
* ── Why one route gets a larger body than the rest ───────────────────
|
|
*
|
|
* A hundred kilobytes is the right ceiling for an API of forms and ids, and
|
|
* it is the wrong one for a photo book. A Studio document is sent WHOLE on
|
|
* every autosave — deliberately, see book-store.schema.ts, because a patch
|
|
* protocol would need an ordering guarantee and a merge rule per field — and
|
|
* the contract already caps it at 150 spreads of 90 elements. A real book of
|
|
* a fortnight's journey is well past a hundred kilobytes before anybody asks
|
|
* for road geometry, and what the ceiling produces is not an error message
|
|
* but a save that quietly fails and an editor that says "not saved" without
|
|
* saying why.
|
|
*
|
|
* So the book route is measured against the size a book can actually be.
|
|
*
|
|
* A list file is the same story at a smaller scale. Its contract allows a
|
|
* megabyte and the import posts it whole, yet a list of a few hundred places
|
|
* with notes is past a hundred kilobytes already, and so is the favourites
|
|
* GPX of anybody who uses OsmAnd (#2301). The three routes that carry one,
|
|
* the import into a new list, the GPX reader and the import into a list that
|
|
* already exists, are measured against that megabyte, doubled for the JSON
|
|
* escaping of a document full of quotes. Everything else keeps the tighter
|
|
* limit.
|
|
*/
|
|
const bookBody = express.json({ limit: '8mb', verify: rawBodyKeeper });
|
|
const listFileBody = express.json({ limit: MAX_COLLECTION_FILE_BYTES * 2, verify: rawBodyKeeper });
|
|
const json = express.json({ limit: '100kb', verify: rawBodyKeeper });
|
|
const urlencoded = express.urlencoded({ limit: '100kb', extended: true, verify: rawBodyKeeper });
|
|
const isMcp = (req: Request) => req.path === '/mcp' || req.path === '/mcp/';
|
|
const isBookWrite = (req: Request) => req.method === 'PUT' && /^\/api\/journeys\/\d+\/book$/.test(req.path);
|
|
const isListFile = (req: Request) =>
|
|
req.method === 'POST' && /^\/api\/addons\/collections\/(import|gpx\/read|\d+\/import)$/.test(req.path);
|
|
|
|
instance.use(function jsonParser(req: Request, res: Response, next: NextFunction) {
|
|
if (isBookWrite(req)) return bookBody(req, res, next);
|
|
if (isListFile(req)) return listFileBody(req, res, next);
|
|
return isMcp(req) ? next() : json(req, res, next);
|
|
});
|
|
instance.use(function urlencodedParser(req: Request, res: Response, next: NextFunction) {
|
|
return isMcp(req) ? next() : urlencoded(req, res, next);
|
|
});
|
|
if (apiDocsEnabled()) setupApiDocs(app);
|
|
// The per-request EntityManager fork every Nest route runs inside (D6). This
|
|
// used to be @mikro-orm/nestjs's own middleware, which Nest 11 mounts as an
|
|
// app.all('/{*all}') route: Express decodes the wildcard param before the
|
|
// handler runs, so a malformed %-escape anywhere (GET /uploads/avatars/%ZZ)
|
|
// died as a 400 "Failed to decode param" instead of falling through to the
|
|
// 404 envelope (UPLOADS-P16). A pathless app.use has no param to decode.
|
|
// Registered last before init, where the module middleware used to sit.
|
|
// AppModule turns the module's own registration off (registerRequestContext).
|
|
instance.use(function mikroOrmRequestContext(_req: Request, _res: Response, next: NextFunction) {
|
|
withRequestContext(orm, next);
|
|
});
|
|
await app.init();
|
|
// Fail closed on unvalidated mutation bodies: every POST/PUT/PATCH @Body()
|
|
// must carry a createZodDto class (validated by the global ZodValidationPipe)
|
|
// or sit on the ratchet-only legacy allow-list — otherwise refuse to boot.
|
|
validateBodyContracts(app);
|
|
// Fail closed on the anonymous surface too: a route that answers without a
|
|
// session must carry @Public() with a reason AND be on the reviewed list.
|
|
// Default-deny protects what exists; this is what keeps the exemptions honest.
|
|
validateRouteGuards(app);
|
|
// And on the other direction of the same question: a route that a centrally
|
|
// administered install withholds from its own admin must say why, and be on a
|
|
// list somebody reviewed. Runs in every e2e harness because they share this
|
|
// builder, so a forgotten marker fails in CI rather than at a customer.
|
|
validateManagedRoutes(app);
|
|
return app;
|
|
}
|