feat: app health endpoint succeeds (E00-S02-T03)

This commit is contained in:
implementer
2026-08-29 00:21:34 +00:00
parent 9937268d64
commit 9c049ce6b5
7 changed files with 112 additions and 26 deletions
+8 -7
View File
@@ -2,13 +2,14 @@
# @personal-blog/server — EPPP public server application image. # @personal-blog/server — EPPP public server application image.
# #
# [E00-S02-T01] baseline: builds the workspace server package with the pinned # [E00-S02-T01/T02/T03] baseline: builds the workspace server package with the
# toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the compiled # pinned toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the
# entrypoint. The server is still a bootstrap placeholder (its module loads and # compiled entrypoint. Since T03 the entrypoint is a minimal Node `node:http`
# exits cleanly); the Fastify 5 application shell that turns it into a serving # server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port
# process lands in a later story, and the health gate (T02), health endpoint # 3000, so the app container stays up and the health endpoint succeeds. The
# (T03), volume persistence (T04), non-root/read-only hardening and multi-arch # Fastify 5 application shell (and the real HTTP API) lands in a later story;
# targets are later E00-S02 tasks — all out of scope here. # 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 # 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 # §5.4 — argon2 is a native dependency and musl/Alpine causes native-module
+4 -1
View File
@@ -3,12 +3,15 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "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": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/index.js" "start": "node dist/index.js"
}, },
"devDependencies": {
"@types/node": "24.13.3"
},
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
+65 -4
View File
@@ -1,8 +1,69 @@
/** /**
* @personal-blog/server — EPPP public server application. * @personal-blog/server — EPPP public server application.
* *
* Bootstrap placeholder so apps/ is a real workspace package that compiles * [E00-S02-T03] minimal serving process: a small HTTP server built on Node's
* under tsconfig.base.json. The Fastify 5 application shell (and its real * `node:http` (no runtime dependencies yet) that answers the application
* exports) lands in a later story. * 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));
});
}
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"outDir": "dist" "outDir": "dist",
"types": ["node"]
}, },
"include": ["src"] "include": ["src"]
} }
+6 -3
View File
@@ -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 # `docker compose up -d` starts both the database (PostgreSQL) and the
# application (@personal-blog/server). Rollback: `docker compose down`. # application (@personal-blog/server). Rollback: `docker compose down`.
@@ -8,8 +8,11 @@
# `condition: service_healthy`, so the application does not start until the # `condition: service_healthy`, so the application does not start until the
# database is accepting connections. # database is accepting connections.
# #
# Explicitly out of scope for T01/T02 (land in later E00-S02 tasks): # Application health endpoint (T03): the app serves `GET /health` (HTTP 200 +
# - application health endpoint (T03) # `{"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) # - DB volume persistence (T04)
# #
# All values have defaults so `docker compose up -d` works from a clean clone # All values have defaults so `docker compose up -d` works from a clean clone
+10 -9
View File
@@ -15,7 +15,7 @@ The workspace is a pnpm monorepo with three package groups:
| Group | Path | Purpose | | 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. | | `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. | | `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 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 1. The command **must follow `pnpm build`** — the `start` script executes the
compiled artifact in `dist/`, it does not compile first. compiled artifact in `dist/`, it does not compile first.
2. `apps/server` is currently a **bootstrap placeholder**: its entrypoint is an 2. Since [E00-S02-T03], `apps/server` serves the **application health
empty module, so starting it loads the module and exits cleanly (exit 0) endpoint**: starting it opens an HTTP server on port 3000 answering
without opening an HTTP port yet. The real Fastify 5 application shell — `GET /health` with HTTP 200 and `{"status":"ok"}`, so the process stays up.
which turns this into a serving process — lands in a later story; the The real Fastify 5 application shell — which turns this into the full
`start` command shape above stays the same once it does. 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: 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 build # 3/3 packages emit dist/, exit 0
pnpm typecheck # 3/3 packages pass --noEmit, exit 0 pnpm typecheck # 3/3 packages pass --noEmit, exit 0
pnpm test # 10/10 pass, 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 ## 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 ...`. | | `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_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). | | `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). | | `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). |
## Out of scope ## Out of scope
+17 -1
View File
@@ -12,7 +12,11 @@ importers:
specifier: 6.0.3 specifier: 6.0.3
version: 6.0.3 version: 6.0.3
apps/server: {} apps/server:
devDependencies:
'@types/node':
specifier: 24.13.3
version: 24.13.3
extensions/example: {} extensions/example: {}
@@ -20,11 +24,23 @@ importers:
packages: packages:
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
typescript@6.0.3: typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
snapshots: snapshots:
'@types/node@24.13.3':
dependencies:
undici-types: 7.18.2
typescript@6.0.3: {} typescript@6.0.3: {}
undici-types@7.18.2: {}