Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
**/*.test.ts
.env*
24 changes: 24 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# The Relay API image. Bun runs our TypeScript directly, so there's no build step.

# Stage 1: install production dependencies only (no TypeScript, no @types).
FROM oven/bun:1.4.4-slim AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

# Stage 2: the image that runs. Bun's download cache from stage 1 stays behind.
FROM oven/bun:1.4.4-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src

# The pipeline passes the commit; /version reports it, so we can see what's running.
ARG GIT_SHA=dev
ENV GIT_SHA=$GIT_SHA

USER bun
EXPOSE 8080

# Exec form: Bun itself is the main process, so it receives SIGTERM directly.
CMD ["bun", "src/server.ts"]
125 changes: 125 additions & 0 deletions api/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "relay-api",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/server.ts",
"start": "bun src/server.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"fastify": "5.12.7",
"zod": "4.6.7"
},
"devDependencies": {
"@types/bun": "1.4.4",
"typescript": "6.0.3"
}
}
32 changes: 32 additions & 0 deletions api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { afterAll, describe, expect, test } from "bun:test";
import { buildApp } from "./app.ts";
import { loadConfig } from "./config.ts";

// A config made from a fixed object, so the test never depends on this machine's environment.
const app = buildApp(loadConfig({ GIT_SHA: "abc123" }), { logger: false });
afterAll(() => app.close());

describe("health routes", () => {
test("GET /health/live answers ok", async () => {
const res = await app.inject({ method: "GET", url: "/health/live" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" });
});

test("GET /health/ready answers ok", async () => {
const res = await app.inject({ method: "GET", url: "/health/ready" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" });
});

test("GET /version shows the commit that is running", async () => {
const res = await app.inject({ method: "GET", url: "/version" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ sha: "abc123" });
});

test("every response carries an x-request-id", async () => {
const res = await app.inject({ method: "GET", url: "/health/live" });
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
});
36 changes: 36 additions & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Builds the Fastify app: settings, hooks and routes, wired together.
// It never listens on a port itself. server.ts does that, so tests can call
// buildApp() and send fake requests with app.inject().
import Fastify, { LogController, type FastifyInstance } from "fastify";
import type { Config } from "./config.ts";
import { errorHandler, notFoundHandler } from "./error-handler.ts";
import { healthRoutes } from "./routes/health.ts";

export function buildApp(config: Config, options: { logger?: boolean } = {}): FastifyInstance {
const app = Fastify({
// JSON logs (pino). Tests pass logger: false to keep their output clean.
logger: options.logger === false ? false : { level: config.LOG_LEVEL },
bodyLimit: 256 * 1024, // 256 KiB, bigger bodies get 413
genReqId: () => crypto.randomUUID(), // a unique id for every request
logController: new LogController({
requestIdLogLabel: "request_id", // the name of that id in every log line
// Kubernetes calls the health routes every few seconds on every pod.
// Logging them would bury the lines that matter.
disableRequestLogging: (request) => request.url.startsWith("/health/"),
}),
});

// Every response tells the client its request id: the same id as in our logs,
// so "it failed" plus the id is enough to find the exact log lines.
app.addHook("onRequest", async (request, reply) => {
reply.header("x-request-id", request.id);
});

// Set before the routes are registered, so every route uses them.
app.setErrorHandler(errorHandler);
app.setNotFoundHandler(notFoundHandler);

app.register(healthRoutes, { config });

return app;
}
23 changes: 23 additions & 0 deletions api/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { loadConfig } from "./config.ts";

describe("loadConfig", () => {
test("uses safe defaults when nothing is set", () => {
expect(loadConfig({})).toEqual({ PORT: 8080, GIT_SHA: "dev", LOG_LEVEL: "info" });
});

test("turns PORT from text into a number", () => {
expect(loadConfig({ PORT: "3000" }).PORT).toBe(3000);
});

test("refuses a PORT that is not a valid port", () => {
expect(() => loadConfig({ PORT: "banana" })).toThrow(/PORT/);
expect(() => loadConfig({ PORT: "70000" })).toThrow(/PORT/);
expect(() => loadConfig({ PORT: "3000abc" })).toThrow(/PORT/);
expect(() => loadConfig({ PORT: "80.5" })).toThrow(/PORT/);
});

test("refuses an unknown LOG_LEVEL", () => {
expect(() => loadConfig({ LOG_LEVEL: "loud" })).toThrow(/LOG_LEVEL/);
});
});
35 changes: 35 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Reads the environment variables once at startup and checks them with Zod.
// A missing or wrong value stops the app right away with a clear message,
// instead of failing later in the middle of a request.
import { z } from "zod";



// The settings the API needs. Env vars are text, so PORT is converted and checked.
const Env = z.object({

PORT: z
.string()
.transform((s) => Number(s))
.refine((n) => Number.isInteger(n), { message: "must be a whole number" })
.refine((n) => n >= 1 && n <= 65535, { message: "must be a whole number between 1 and 65535" })
.default(8080),

GIT_SHA: z
.string().default("dev"),

LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),


});

export type Config = z.infer<typeof Env>;

export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const result = Env.safeParse(env);
if (!result.success) {
throw new Error(`Invalid configuration:\n${z.prettifyError(result.error)}`);
}
return result.data;
}
Loading