Files
PersonalBlog/apps/server/src/index.ts
T
implementer 0ce790fca3 feat: missing required setting fails startup with a field-specific error (E00-S04-T02)
- packages/config: add src/startup.ts exposing assertValidConfig (builds on
  the T01 TypeBox/Ajv schema) and the field-specific startup errors
  (MissingRequiredSettingError names the missing field; ConfigStartupError
  names each violating field); re-export from the package boundary
- apps/server: validate the startup configuration (including the required
  EPPP_SESSION_SECRET) before the server binds, so a missing required
  setting crashes the process at startup naming the field; depends on
  @personal-blog/config
- compose.yaml: provide EPPP_SESSION_SECRET for the app service (dev-only
  >= 32 char default; override via .env / shell)
- Dockerfile: ship the compiled packages/config in the image (build source +
  runtime dist), matching the server's new workspace dependency
- pnpm-lock.yaml: apps/server importer gains @personal-blog/config
2026-08-30 03:00:40 +00:00

161 lines
6.9 KiB
TypeScript

/**
* @personal-blog/server — EPPP public server application.
*
* [E00-S02-T03] minimal serving process: a small HTTP server built on Node's
* `node:http` that answers the application health endpoint. `GET /health`
* reports the application readiness — HTTP 200 with `{"status":"ok"}` — so
* the Compose stack's `app` service stays up and the health endpoint
* succeeds once the stack is running.
*
* [E00-S03-T06] readiness gate: the app does **not** report ready before
* migrations complete. When a `DATABASE_URL` is configured, the server runs
* the startup migrations (the `MigrationRunner` from
* `@personal-blog/database-postgres` over the migration ledger, E00-S03-T03)
* before it reports ready: `GET /health` answers HTTP 503 with
* `{"status":"not ready"}` while the migration run is in flight, and flips to
* HTTP 200 `{"status":"ok"}` only after the run finishes. When no
* `DATABASE_URL` is configured (e.g. the local non-container developer path,
* E00-S01-T06) there are no migrations to run, so the app reports ready
* immediately.
*
* [E00-S04-T02] field-specific startup error: the required settings are
* validated before the server binds, so a deployment missing a required
* setting (the admin-session secret `EPPP_SESSION_SECRET` — the schema's
* required field, Security-and-Operations §32/§26) fails fast at startup
* with an error naming the missing field instead of booting with an invalid
* configuration. The parsed config keeps the committed defaults for
* `host`/`port`/`databaseUrl` (the `process.env` adapter that centralizes
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
* `MissingRequiredSettingError` naming the missing field.
*
* The Fastify 5 application shell (and the real HTTP API) lands in a later
* story; this bootstrap keeps the application health-checkable until then.
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { assertValidConfig } from '@personal-blog/config';
import { Pool } from '@personal-blog/database-postgres';
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
import type { Migration } from '@personal-blog/database-postgres';
/** Port the server listens on; `PORT` overrides the container default (3000). */
const PORT = resolvePort(process.env.PORT);
// [E00-S04-T02] field-specific startup error: validate the startup
// configuration before anything else, so a missing required setting (e.g.
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
// the missing field — never boots with an invalid configuration.
assertValidConfig({
host: '0.0.0.0',
port: PORT,
databaseUrl: process.env.DATABASE_URL,
sessionSecret: process.env.EPPP_SESSION_SECRET,
});
/** Health payload — reported once the startup migration run completes. */
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
/** Payload while the startup migration run is still in flight — the app is up but NOT ready. */
const NOT_READY_PAYLOAD = JSON.stringify({ status: 'not ready' });
/** Payload for any route that is not the health endpoint. */
const NOT_FOUND_PAYLOAD = JSON.stringify({ error: 'not found' });
/**
* The migrations the server applies at startup, oldest first. Empty until the
* first schema migration lands (E00-S03 is the migration-runner foundation;
* real schema migrations arrive with the domain stories). The runner still
* ensures the migration ledger (E00-S03-T03) exists and reads the applied
* versions, so even an empty run is a real, observable migration step that
* readiness waits for.
*/
const MIGRATIONS: readonly Migration[] = [];
/**
* Readiness state — false until the startup migration run completes. The app
* is "up" (the HTTP server is listening) but reports not-ready (E00-S03-T06)
* while migrations are pending.
*/
let migrationsComplete = false;
/**
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
* out-of-range override falls back to the default so a bad `PORT` value cannot
* crash the process at startup.
*/
function resolvePort(raw: string | undefined): number {
const port = Number(raw ?? 3000);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000;
}
/** Writes a JSON response with an explicit content-length. */
function sendJson(res: ServerResponse, statusCode: number, body: string): void {
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
/**
* Routes one request. The application only serves the health endpoint at this
* stage; anything else is a 404 so misconfiguration is loud. The health route
* is the readiness probe (E00-S03-T06): it answers 200 only after the startup
* migration run completes, and 503 while the run is still pending.
*/
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
if (req.method === 'GET' && (req.url ?? '/') === '/health') {
if (migrationsComplete) {
sendJson(res, 200, HEALTH_PAYLOAD);
} else {
sendJson(res, 503, NOT_READY_PAYLOAD);
}
return;
}
sendJson(res, 404, NOT_FOUND_PAYLOAD);
}
const server = createServer(handleRequest);
const databaseUrl = process.env.DATABASE_URL;
if (databaseUrl === undefined) {
// No DATABASE_URL configured (e.g. local non-container dev): there are no
// migrations to run, so the app reports ready from the start.
migrationsComplete = true;
console.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run');
} else {
// E00-S03-T06: run the startup migrations; readiness follows completion.
const pool = new Pool({ connectionString: databaseUrl });
const runner = new MigrationRunner(pool, MIGRATIONS, new MigrationLedger(pool));
runner
.run()
.then((result) => {
migrationsComplete = true;
console.log(
`[migrate] startup migration run complete (applied ${result.applied.length}, skipped ${result.skipped.length}); reporting ready`,
);
})
.catch((error) => {
// Failure diagnostics are E00-S03-T05 (out of scope for T06): the
// runner already throws a serializable MigrationFailedError. The app
// logs the failure and stays not-ready, so a deployment with failed
// migrations is surfaced by the readiness probe instead of
// crash-looping.
console.error('[migrate] startup migration run failed; app stays not-ready:', String(error));
});
}
server.listen(PORT, () => {
console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
});
// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the
// server and exit cleanly instead of being killed mid-request.
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, () => {
server.close(() => process.exit(0));
});
}