feat: initial backend service template
Some checks failed
/ checks (push) Successful in 35s
/ deploy (push) Failing after 2s

This commit is contained in:
Noah Holt 2026-11-05 01:43:18 -06:00
commit 2828a630a6
28 changed files with 6437 additions and 0 deletions

69
src/app.test.ts Normal file
View file

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import request from "supertest";
import pino from "pino";
import { createApp } from "./app.js";
import { parseConfig } from "./lib/config.js";
import { createDb } from "./lib/db.js";
const logger = pino({ level: "silent" });
const base = { SERVICE_NAME: "test-svc", GIT_SHA: "abc123" };
describe("config", () => {
it("applies defaults", () => {
const c = parseConfig(base);
expect(c.PORT).toBe(3000);
expect(c.LOG_LEVEL).toBe("info");
expect(c.DATABASE_URL).toBeUndefined();
});
it("rejects missing SERVICE_NAME with a clear message", () => {
expect(() => parseConfig({})).toThrow(/SERVICE_NAME/);
});
it("rejects an invalid LOG_LEVEL", () => {
expect(() => parseConfig({ ...base, LOG_LEVEL: "loud" })).toThrow(
/LOG_LEVEL/,
);
});
});
describe("routes", () => {
const config = parseConfig(base);
const app = createApp({ config, logger });
it("GET /healthz -> 200 ok", async () => {
const res = await request(app).get("/healthz");
expect(res.status).toBe(200);
expect(res.text).toBe("ok");
});
it("GET /readyz with no backends -> 200", async () => {
const res = await request(app).get("/readyz");
expect(res.status).toBe(200);
expect(res.body.status).toBe("ready");
});
it("GET /readyz with unreachable DATABASE_URL -> 503 with reason", async () => {
const db = createDb("postgres://u:p@127.0.0.1:1/none");
const res = await request(createApp({ config, logger, db })).get("/readyz");
await db.close();
expect(res.status).toBe(503);
expect(res.body.status).toBe("not-ready");
expect(res.body.postgres).toMatch(/^unavailable/);
});
it("GET / has service shape", async () => {
const res = await request(app).get("/");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
service: "test-svc",
version: "abc123",
});
expect(typeof res.body.pod).toBe("string");
});
it("echoes or generates X-Request-Id", async () => {
const a = await request(app).get("/").set("X-Request-Id", "rid-1");
expect(a.headers["x-request-id"]).toBe("rid-1");
const b = await request(app).get("/");
expect(b.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
});

56
src/app.ts Normal file
View file

@ -0,0 +1,56 @@
import express, { type ErrorRequestHandler } from "express";
import { randomUUID } from "node:crypto";
import { pinoHttp } from "pino-http";
import type { Logger } from "pino";
import type { Config } from "./lib/config.js";
import type { Db } from "./lib/db.js";
import type { Cache } from "./lib/redis.js";
import { healthRouter } from "./routes/health.js";
import { rootRouter } from "./routes/root.js";
export interface Deps {
config: Config;
logger: Logger;
db?: Db;
cache?: Cache;
}
export function createApp({ config, logger, db, cache }: Deps) {
const app = express();
app.disable("x-powered-by");
app.use(
pinoHttp({
logger,
genReqId: (req, res) => {
const incoming = req.headers["x-request-id"];
const id =
(Array.isArray(incoming) ? incoming[0] : incoming) ?? randomUUID();
res.setHeader("X-Request-Id", id);
return id;
},
// Keep the logging contract flat and small: req.id/method/url,
// res.statusCode, responseTime.
serializers: {
req: (req) => ({ id: req.id, method: req.method, url: req.url }),
res: (res) => ({ statusCode: res.statusCode }),
},
autoLogging: {
ignore: (req) => req.url === "/healthz" || req.url === "/readyz",
},
}),
);
app.use(healthRouter({ db, cache }));
app.use(rootRouter({ config, db, cache }));
// Express identifies error handlers by arity; keep all four args.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const onError: ErrorRequestHandler = (err, req, res, _next) => {
req.log.error({ err }, "unhandled error");
res.status(500).json({ error: "internal error" });
};
app.use(onError);
return app;
}

28
src/bin/server.ts Normal file
View file

@ -0,0 +1,28 @@
import { createApp } from "../app.js";
import { parseConfig } from "../lib/config.js";
import { createDb } from "../lib/db.js";
import { createLogger } from "../lib/logger.js";
import { createCache } from "../lib/redis.js";
const config = parseConfig(process.env);
const logger = createLogger(config);
const db = config.DATABASE_URL ? createDb(config.DATABASE_URL) : undefined;
const cache = config.REDIS_URL ? createCache(config.REDIS_URL) : undefined;
const server = createApp({ config, logger, db, cache }).listen(
config.PORT,
() => {
logger.info({ port: config.PORT }, "listening");
},
);
function shutdown(signal: string) {
logger.info({ signal }, "shutting down");
server.close(async () => {
await Promise.allSettled([db?.close(), cache?.close()]);
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

25
src/lib/config.ts Normal file
View file

@ -0,0 +1,25 @@
import { z } from "zod";
const schema = z.object({
PORT: z.coerce.number().int().positive().default(3000),
SERVICE_NAME: z.string().min(1, "SERVICE_NAME is required"),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.default("info"),
GIT_SHA: z.string().default("dev"),
DATABASE_URL: z.string().url().optional(),
REDIS_URL: z.string().url().optional(),
});
export type Config = z.infer<typeof schema>;
export function parseConfig(env: NodeJS.ProcessEnv): Config {
const result = schema.safeParse(env);
if (!result.success) {
const problems = result.error.issues
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Invalid environment:\n${problems}`);
}
return result.data;
}

24
src/lib/db.ts Normal file
View file

@ -0,0 +1,24 @@
import pg from "pg";
export interface Db {
query: pg.Pool["query"];
ping(): Promise<void>;
close(): Promise<void>;
}
export function createDb(connectionString: string): Db {
const pool = new pg.Pool({
connectionString,
max: 5,
connectionTimeoutMillis: 2000,
});
// Idle-client errors must not crash the process.
pool.on("error", () => {});
return {
query: pool.query.bind(pool) as pg.Pool["query"],
async ping() {
await pool.query("SELECT 1");
},
close: () => pool.end(),
};
}

14
src/lib/logger.ts Normal file
View file

@ -0,0 +1,14 @@
import pino from "pino";
import type { Config } from "./config.js";
// Logging contract: one JSON object per line on stdout with level, time,
// msg, service, version. Level is emitted as a label, time as ISO-8601.
export function createLogger(config: Config): pino.Logger {
return pino({
level: config.LOG_LEVEL,
base: { service: config.SERVICE_NAME, version: config.GIT_SHA },
messageKey: "msg",
timestamp: pino.stdTimeFunctions.isoTime,
formatters: { level: (label) => ({ level: label }) },
});
}

26
src/lib/redis.ts Normal file
View file

@ -0,0 +1,26 @@
import { Redis } from "ioredis";
export interface Cache {
incr(key: string): Promise<number>;
ping(): Promise<void>;
close(): Promise<void>;
}
export function createCache(url: string): Cache {
const client = new Redis(url, {
maxRetriesPerRequest: 1,
connectTimeout: 2000,
enableOfflineQueue: false,
lazyConnect: false,
});
client.on("error", () => {});
return {
incr: (key) => client.incr(key),
async ping() {
await client.ping();
},
async close() {
client.disconnect();
},
};
}

35
src/routes/health.ts Normal file
View file

@ -0,0 +1,35 @@
import { Router } from "express";
import type { Db } from "../lib/db.js";
import type { Cache } from "../lib/redis.js";
export function healthRouter(deps: { db?: Db; cache?: Cache }): Router {
const router = Router();
// Liveness: 200 whenever the process is listening.
router.get("/healthz", (_req, res) => {
res.type("text/plain").send("ok");
});
// Readiness: every configured backend must answer.
router.get("/readyz", async (_req, res) => {
const body: Record<string, string> = {};
let ready = true;
const check = async (name: string, fn?: () => Promise<void>) => {
if (!fn) return;
try {
await fn();
body[name] = "ok";
} catch (err) {
ready = false;
body[name] = `unavailable: ${(err as Error).message || "error"}`;
}
};
await check("postgres", deps.db && (() => deps.db!.ping()));
await check("redis", deps.cache && (() => deps.cache!.ping()));
res
.status(ready ? 200 : 503)
.json({ status: ready ? "ready" : "not-ready", ...body });
});
return router;
}

39
src/routes/root.ts Normal file
View file

@ -0,0 +1,39 @@
import { Router } from "express";
import { hostname } from "node:os";
import type { Config } from "../lib/config.js";
import type { Db } from "../lib/db.js";
import type { Cache } from "../lib/redis.js";
export function rootRouter(deps: {
config: Config;
db?: Db;
cache?: Cache;
}): Router {
const router = Router();
router.get("/", async (_req, res, next) => {
try {
const body: Record<string, unknown> = {
service: deps.config.SERVICE_NAME,
version: deps.config.GIT_SHA,
pod: hostname(),
};
if (deps.db) {
const r = await deps.db.query(
"SELECT id, name, created_at FROM service_info ORDER BY id LIMIT 1",
);
body.db = r.rows[0] ?? null;
}
if (deps.cache) {
body.cacheHits = await deps.cache.incr(
`${deps.config.SERVICE_NAME}:hits`,
);
}
res.json(body);
} catch (err) {
next(err);
}
});
return router;
}