diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 3b2c97c..5b9498f 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -2,13 +2,14 @@ # @personal-blog/server — EPPP public server application image. # -# [E00-S02-T01] baseline: builds the workspace server package with the pinned -# toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the compiled -# entrypoint. The server is still a bootstrap placeholder (its module loads and -# exits cleanly); the Fastify 5 application shell that turns it into a serving -# process lands in a later story, and the health gate (T02), health endpoint -# (T03), volume persistence (T04), non-root/read-only hardening and multi-arch -# targets are later E00-S02 tasks — all out of scope here. +# [E00-S02-T01/T02/T03] baseline: builds the workspace server package with the +# pinned toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the +# compiled entrypoint. Since T03 the entrypoint is a minimal Node `node:http` +# server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port +# 3000, so the app container stays up and the health endpoint succeeds. The +# Fastify 5 application shell (and the real HTTP API) lands in a later story; +# volume persistence (T04), non-root/read-only hardening (T05) and multi-arch +# targets remain later E00-S02 tasks — all out of scope here. # # Image base: node:24.19.0-bookworm-slim (glibc Debian) per Technology-Stack # §5.4 — argon2 is a native dependency and musl/Alpine causes native-module diff --git a/apps/server/package.json b/apps/server/package.json index 4044b07..62d5b7e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,12 +3,15 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP public server application. Bootstrap placeholder — the Fastify 5 application shell lands in a later story.", + "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node dist/index.js" }, + "devDependencies": { + "@types/node": "24.13.3" + }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index bd4187d..aad34d0 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,8 +1,69 @@ /** * @personal-blog/server — EPPP public server application. * - * Bootstrap placeholder so apps/ is a real workspace package that compiles - * under tsconfig.base.json. The Fastify 5 application shell (and its real - * exports) lands in a later story. + * [E00-S02-T03] minimal serving process: a small HTTP server built on Node's + * `node:http` (no runtime dependencies yet) that answers the application + * health endpoint. `GET /health` reports a healthy application — HTTP 200 with + * `{"status":"ok"}` — so the Compose stack's `app` service stays up and the + * health endpoint succeeds once the stack is running. + * + * The Fastify 5 application shell (and the real HTTP API) lands in a later + * story; this bootstrap keeps the application health-checkable until then. */ -export {}; + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; + +/** Port the server listens on; `PORT` overrides the container default (3000). */ +const PORT = resolvePort(process.env.PORT); + +/** Health payload — reports a healthy application. */ +const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' }); + +/** Payload for any route that is not the health endpoint. */ +const NOT_FOUND_PAYLOAD = JSON.stringify({ error: 'not found' }); + +/** + * 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. + */ +function handleRequest(req: IncomingMessage, res: ServerResponse): void { + if (req.method === 'GET' && (req.url ?? '/') === '/health') { + sendJson(res, 200, HEALTH_PAYLOAD); + return; + } + sendJson(res, 404, NOT_FOUND_PAYLOAD); +} + +const server = createServer(handleRequest); + +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)); + }); +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 5285d28..bb07ae1 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["node"] }, "include": ["src"] } diff --git a/compose.yaml b/compose.yaml index bad9a35..3def6eb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,4 @@ -# EPPP Docker Compose baseline — [E00-S02-T01/T02] +# EPPP Docker Compose baseline — [E00-S02-T01/T02/T03] # # `docker compose up -d` starts both the database (PostgreSQL) and the # application (@personal-blog/server). Rollback: `docker compose down`. @@ -8,8 +8,11 @@ # `condition: service_healthy`, so the application does not start until the # database is accepting connections. # -# Explicitly out of scope for T01/T02 (land in later E00-S02 tasks): -# - application health endpoint (T03) +# Application health endpoint (T03): the app serves `GET /health` (HTTP 200 + +# `{"status":"ok"}`) on port 3000, so the app container stays up and the +# health endpoint succeeds once the stack is running. +# +# Explicitly out of scope for T01/T02/T03 (land in later E00-S02 tasks): # - DB volume persistence (T04) # # All values have defaults so `docker compose up -d` works from a clean clone diff --git a/docs/development/non-container.md b/docs/development/non-container.md index ef9ca38..57254b7 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -15,7 +15,7 @@ The workspace is a pnpm monorepo with three package groups: | Group | Path | Purpose | | --- | --- | --- | -| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Bootstrap placeholder — the Fastify 5 application shell lands in a later story. | +| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | @@ -86,15 +86,16 @@ pnpm --filter @personal-blog/server start ``` This runs the `start` script of `apps/server` (`node dist/index.js`), i.e. the -compiled application entrypoint. Two things to know at bootstrap: +compiled application entrypoint. Two things to know: 1. The command **must follow `pnpm build`** — the `start` script executes the compiled artifact in `dist/`, it does not compile first. -2. `apps/server` is currently a **bootstrap placeholder**: its entrypoint is an - empty module, so starting it loads the module and exits cleanly (exit 0) - without opening an HTTP port yet. The real Fastify 5 application shell — - which turns this into a serving process — lands in a later story; the - `start` command shape above stays the same once it does. +2. Since [E00-S02-T03], `apps/server` serves the **application health + endpoint**: starting it opens an HTTP server on port 3000 answering + `GET /health` with HTTP 200 and `{"status":"ok"}`, so the process stays up. + The real Fastify 5 application shell — which turns this into the full + serving API — lands in a later story; the `start` command shape stays the + same once it does. To run the compiled output of any other workspace package directly: @@ -121,7 +122,7 @@ pnpm install --frozen-lockfile # exit 0, lockfile untouched pnpm build # 3/3 packages emit dist/, exit 0 pnpm typecheck # 3/3 packages pass --noEmit, exit 0 pnpm test # 10/10 pass, exit 0 -pnpm --filter @personal-blog/server start # loads compiled server entrypoint, exit 0 +pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up ``` ## Troubleshooting @@ -131,7 +132,7 @@ pnpm --filter @personal-blog/server start # loads compiled server entrypoint, | `pnpm: command not found` | Corepack shims not activated — run `corepack enable`, or prefix commands with `corepack pnpm ...`. | | `ERR_PNPM_OUTDATED_LOCKFILE` | `pnpm-lock.yaml` is out of date with the manifests. Run `pnpm install` (unfrozen) and commit the lockfile update. | | `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). | -| `start` exits immediately with no output | Expected at bootstrap — the server entrypoint is a placeholder module; the Fastify 5 shell is a later story (see [Run](#run)). | +| `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). | | `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). | ## Out of scope diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b797de..007743e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,11 @@ importers: specifier: 6.0.3 version: 6.0.3 - apps/server: {} + apps/server: + devDependencies: + '@types/node': + specifier: 24.13.3 + version: 24.13.3 extensions/example: {} @@ -20,11 +24,23 @@ importers: packages: + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + snapshots: + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + typescript@6.0.3: {} + + undici-types@7.18.2: {}