Skip to content

feat: add API skeleton and its Docker image - #11

Merged
devinder-dev merged 3 commits into
mainfrom
feat/api-skeleton
Oct 29, 2026
Merged

devinder-dev merged 3 commits into
mainfrom
feat/api-skeleton

Conversation

@devinder-dev

@devinder-dev devinder-dev commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

The first code of the Relay API, step 1.3 of the walking skeleton: a Fastify app on Bun 1.4.4 with health routes, checked configuration, one error handler and graceful shutdown. It also adds the API's Docker image (step 1.4). Nothing is deployed yet; CI and deployment come in steps 1.5–1.8.

What's in api/

File Job
src/server.ts Loads the config, listens on 0.0.0.0:PORT, and shuts down gracefully on SIGTERM/SIGINT (app.close(): new requests get 503, running ones finish, exit 0)
src/app.ts buildApp(): 256 KiB body limit, a UUID request id per request (X-Request-Id header + request_id in every JSON log line), health routes not logged, error handlers
src/config.ts PORT, GIT_SHA, LOG_LEVEL checked with Zod at startup. A bad value stops the app with a clear message
src/routes/health.ts /health/live, /health/ready, /version (docs/api.md)
src/errors.ts AppError + notFound() / conflict(), plain TypeScript with no Fastify import
src/error-handler.ts The one place errors become answers, in the shape from docs/api.md: Zod → 422 validation_failed with details, AppError → its status, Fastify 4xx → 400/413/415, anything else → logged + 500 internal_error with no details

Pinned exactly: Bun 1.4.4 types, Fastify 5.12.7, Zod 4.6.7, TypeScript 6.0.3. Follows decisions 0001 and 0003.

How it was checked

  • bun test: 15 tests (health routes, request id, config defaults and rejections including the "3000abc" / "80.5" regression, every error-handler branch, and that a crash leaks no details).
  • bun run typecheck: clean.
  • bun audit: no known vulnerabilities in 53 packages.
  • Shutdown on Bun 1.4.4: SIGTERM and SIGINT exit 0 in about 20 ms; an idle keep-alive connection is closed by the server, so there's no hang.
  • Load on Bun 1.4.4 (the check from decision 0001): about 1.35 million requests in 10 s with 50 parallel clients, 0 errors, p99 under 1 ms, memory 54 → 93 MB. These are local numbers, with no database or network.

Docker image (api/Dockerfile)

  • Base oven/bun:1.4.4-slim (Debian/glibc). Not alpine, because Bun's musl build is the less-tested one; not distroless yet, because a shell helps debugging.
  • Two stages: dependencies are installed with --frozen-lockfile --production in the first, so dev tools and Bun's download cache stay out of the final image.
  • Runs as the non-root user bun (uid 1000). GIT_SHA is passed at build time, so /version reports the image's own commit.
  • Exec-form CMD ["bun", "src/server.ts"], so Bun is PID 1 and receives SIGTERM itself.
  • .dockerignore keeps node_modules, tests and .env files out.

Checked: the image runs as uid 1000, /version shows the build arg, there are no test files in the image, and docker stop takes 0.12 s with exit code 0 and the "shutting down" log line.

How to run it

cd api
bun install
GIT_SHA=local bun start     # then: curl localhost:8080/health/live
bun test && bun run typecheck

# or as a container:
docker build -t relay-api:local --build-arg GIT_SHA=local .
docker run --rm -p 8080:8080 relay-api:local

@AbdikhaniAbdirashid AbdikhaniAbdirashid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran it locally rather than just reading it. Everything you claim holds up.

bun test 15/15, bun run typecheck clean. Probed the running server: /health/live and /version answer, an unknown route returns the contract's error shape, X-Request-Id is on the response and the same id is in the body, broken JSON gives 400 bad_request, a 300 KiB body gives 413 payload_too_large, SIGTERM exits 0 in 58 ms, and the health routes produce zero log lines.

The buildApp() / server.ts split is the part I'll remember — being able to test routes with inject() and no port is why 15 tests run in under 300 ms.

One thing to settle

/health/ready always answers 200. docs/api.md says it should be "200, or 503 while shutting down or when the database is unreachable". There's no flag or onClose hook, so the 503 can't happen.

Nothing breaks today since it isn't deployed, and the preStop sleep in CONTEXT.md covers the same window anyway. But the code and the contract say different things, and I'd rather we pick one now than have someone rely on the wrong one later. Either the route learns to answer 503 while closing, or the contract drops that clause.

Smaller

bun.lock won't parse on my machine — "Unknown lockfile version" on Bun 1.3.7. Tests still ran, so it's on me to upgrade to 1.4.4, but worth knowing that the pin isn't just advisory.

A question

/health/ready is where the database check lands in phase 2. If the database is briefly unreachable, every pod goes unready at once and the whole API disappears rather than degrading. Is that the intent, or should readiness tolerate a short outage?

Approving — the one point above isn't blocking.

@devinder-dev devinder-dev changed the title feat: add API skeleton with health routes, config and error handling feat: add API skeleton and its Docker image Oct 26, 2026
@devinder-dev

Copy link
Copy Markdown
Owner Author

Thanks for running it rather than just reading it. That's exactly the kind of review that catches things.

/health/ready always answers 200. [...] There's no flag or onClose hook, so the 503 can't happen.

It does happen, through Fastify rather than our code: after app.close() (in server.ts), Fastify answers every new request with 503 (return503OnClosing, on by default), the readiness probe included. On Bun the server even stops listening within milliseconds, and a refused connection also counts as "not ready" for the probe. You're right that nothing in our code shows it, though. So I'm adding a comment in health.ts (small commit on this PR), and #10 now has the same note in docs/api.md, so code and contract say the same thing.

bun.lock won't parse [...] on Bun 1.3.7

Good to know, and it proves the pin is real. In the CI step (1.5) I'll add a .bun-version file that CI and both our laptops read, so there's one place that says 1.4.4.

If the database is briefly unreachable, every pod goes unready at once [...] should readiness tolerate a short outage?

Good question. My suggestion: readiness does check the database (a pod that can't reach it shouldn't get traffic, for example right after startup), but with periodSeconds: 5 and failureThreshold: 3, so a blip shorter than about 15 s doesn't take pods out. During those seconds requests fail one by one with our 503 error shape. A longer outage takes all pods out of rotation, which is honest, since there's nothing they could serve without the database. Let's settle the exact numbers in step 2.3, when the check exists.

One more thing: I pushed a second commit after your approval, api/Dockerfile + api/.dockerignore (ee6b257), plus the comment above. Could you look at those before I merge? I've also turned on "require approval of the most recent push" in the ruleset, so from now on a push after an approval always needs a fresh approval.

@AbdikhaniAbdirashid AbdikhaniAbdirashid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving: my first review was before the Dockerfile landed, so this covers that and the 503 note.

Built and ran the image rather than reading it. 204 MB, runs as uid 1000 (bun), /health/live, /health/ready and /version all answer, and --build-arg GIT_SHA=abc1234 comes back out of /version, so the pipeline's tag will be visible on the running pod. An unknown route returns the contract's error shape. No TypeScript and no *.test.ts in the image - --production plus the .dockerignore do what they say. docker stop exits in 121 ms.

Two things I'd point at for anyone reading this later:

No build step. Bun runs the TypeScript directly, so the image is install + copy src. Mine needs npm run build first because a browser can't run TSX. Good illustration of what the runtime choice actually bought.

Exec-form CMD. CMD ["bun", "src/server.ts"] makes Bun PID 1, so SIGTERM reaches it. Shell form would put /bin/sh in front and swallow the signal, and the graceful shutdown you measured would quietly stop working in the container while still passing on the host. That one is easy to get wrong and hard to notice.

On the 503: the comment answers my question completely - it comes from Fastify after app.close(), not from route code. That was my misreading, and it's now written down in both the code and the contract.

One small thing, not blocking: oven/bun:1.4.4-slim is Debian-based, which is where most of the 204 MB is. If the image size ever matters, oven/bun:1.4.4-alpine is a fraction of it - though musl vs glibc is a real difference and probably not worth the risk this close to the defense.

@devinder-dev
devinder-dev merged commit aef5921 into main Oct 29, 2026
@devinder-dev
devinder-dev deleted the feat/api-skeleton branch October 29, 2026 09:19
devinder-dev added a commit that referenced this pull request Oct 29, 2026
Step 1.5: the API pipeline in GitHub Actions
(`.github/workflows/api.yml`).

## What it does
- **Every pull request:** `api-test` installs with the frozen lockfile,
then runs `typecheck` and `bun test`. `api-image` then checks that the
Docker image builds for `linux/amd64`. Nothing is pushed.
- **Every merge to `main` that touches `api/`:** the same tests, then
the image is built and **pushed to GHCR** as
`ghcr.io/devinder-dev/relay-api:<commit>`. Deployment comes in step 1.8.

## Choices
- **No path filter on pull requests:** `api-test` will be a required
check, and a required check that never runs (for example on a
dashboard-only PR) would block the merge forever. The API tests take
about 30 seconds.
- **Bun 1.4.4 from `api/.bun-version`:** one file that CI reads and that
documents the version for our laptops.
- **Actions pinned to full commit SHAs**, with the version as a comment,
so a moved or hijacked tag can't run new code. `docker/login-action` is
v4.6.0, because v4.7.0 was only 3 days old.
- **Least privilege:** `contents: read` by default. Only `api-image`
gets `packages: write`, and it only logs in and pushes on `main`.
- **Images are built on GitHub's amd64 runners**, which match the VPS.
Images built on a Mac would be arm64.
- `concurrency` cancels outdated PR runs. The `gha` cache reuses
unchanged image layers.

## After the first push to `main`
1. Make the GHCR package `relay-api` public, so k3s can pull it without
credentials.
2. Add `api-test` as a required check in the `protect-main` ruleset.

Rebased onto `main` after #11 was merged, so it now contains only the
workflow and `api/.bun-version`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants