feat: initial backend service template
Some checks failed
/ checks (push) Successful in 35s
/ deploy (push) Failing after 2s
Some checks failed
/ checks (push) Successful in 35s
/ deploy (push) Failing after 2s
This commit is contained in:
commit
2828a630a6
28 changed files with 6437 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.forgejo
|
||||
6
.env.example
Normal file
6
.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
PORT=3000
|
||||
SERVICE_NAME=${REPO_NAME}
|
||||
LOG_LEVEL=info
|
||||
# Optional: set to enable the backing services
|
||||
DATABASE_URL=postgresql://app:app@localhost:5432/app
|
||||
REDIS_URL=redis://localhost:6379
|
||||
6
.forgejo/template
Normal file
6
.forgejo/template
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package.json
|
||||
README.md
|
||||
.env.example
|
||||
.forgejo/workflows/ci.yaml
|
||||
migrations/0001_init.sql
|
||||
package-lock.json
|
||||
29
.forgejo/workflows/ci.yaml
Normal file
29
.forgejo/workflows/ci.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Full pipeline: checks -> build+push image -> bump tag in gitops repo.
|
||||
# The heavy lifting lives in nholtware/workflows composite actions; ArgoCD
|
||||
# takes over from the gitops commit.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: http://192.168.1.230:3000/nholtware/workflows/node-checks@main
|
||||
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@main
|
||||
|
||||
deploy:
|
||||
needs: checks
|
||||
runs-on: host
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: http://192.168.1.230:3000/nholtware/workflows/build-push@main
|
||||
with:
|
||||
app-name: ${REPO_NAME}
|
||||
registry-user: ${{ secrets.REGISTRY_USER }}
|
||||
registry-token: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- uses: http://192.168.1.230:3000/nholtware/workflows/gitops-bump@main
|
||||
with:
|
||||
app-name: ${REPO_NAME}
|
||||
gitops-token: ${{ secrets.GITOPS_TOKEN }}
|
||||
12
.forgejo/workflows/pr.yaml
Normal file
12
.forgejo/workflows/pr.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# PR checks: lint + test only. No build/deploy -- that's ci.yaml's job on push to main.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: http://192.168.1.230:3000/nholtware/workflows/node-checks@main
|
||||
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@main
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
v22
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
10
CLAUDE.md
Normal file
10
CLAUDE.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Service conventions
|
||||
|
||||
- Stack: Express 5, TypeScript ESM (`tsc`), Node 22, `pg` + `node-pg-migrate`, `ioredis`, `pino`/`pino-http`, `zod`, vitest + supertest.
|
||||
- Do NOT rename npm scripts (`lint`, `test`, `format:check`, `build`, `migrate`): the shared CI composite actions call them.
|
||||
- Config: all env parsed once in `src/lib/config.ts` (zod); add new vars there.
|
||||
- Logging contract (log pipeline depends on it): JSON per line on stdout with `level,time,msg,service,version`; requests add `req.id,req.method,req.url,res.statusCode,responseTime`.
|
||||
- Health contract: `/healthz` liveness (always 200), `/readyz` readiness (checks configured Postgres/Redis, else 503 JSON).
|
||||
- Seams: cross-cutting modules in `src/lib/`, endpoints in `src/routes/` wired in `src/app.ts`.
|
||||
- Migrations: `migrations/*.sql`, run in-cluster by an ArgoCD PreSync Job using this same image.
|
||||
- Deploy: push to `main` -> Forgejo CI -> image -> gitops bump -> ArgoCD. Never `kubectl apply` by hand.
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Build stage: full toolchain, compile TS -> dist/
|
||||
FROM node:22 AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
# Runtime stage: production deps only (node-pg-migrate is a prod dep so
|
||||
# `npm run migrate` works inside this image for the PreSync Job).
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ARG GIT_SHA=dev
|
||||
ENV GIT_SHA=${GIT_SHA}
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY migrations ./migrations
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/bin/server.js"]
|
||||
28
README.md
Normal file
28
README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# ${REPO_NAME}
|
||||
|
||||
Express 5 + TypeScript backend generated from `nholtware/service-template-express`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /healthz` liveness, always 200 once listening
|
||||
- `GET /readyz` readiness: Postgres `SELECT 1` and Redis `PING` for whichever of `DATABASE_URL` / `REDIS_URL` are set; 503 + JSON reason otherwise
|
||||
- `GET /` service name, version (git sha), pod, a `service_info` row, Redis hit counter
|
||||
|
||||
## Local dev
|
||||
|
||||
```sh
|
||||
cp .env.example .env # then export the vars, or use your shell's env loader
|
||||
npm ci && npm run dev
|
||||
npm run lint && npm test
|
||||
```
|
||||
|
||||
Env is validated at boot (`src/lib/config.ts`): `PORT`, `SERVICE_NAME` (required), `LOG_LEVEL`, `DATABASE_URL?`, `REDIS_URL?`.
|
||||
Migrations: SQL files in `migrations/`, applied with `npm run migrate`.
|
||||
Logs: one JSON object per line on stdout (`level,time,msg,service,version`, plus `req.*`/`res.statusCode`/`responseTime` per request).
|
||||
|
||||
## Using this template
|
||||
|
||||
1. **Generate** a repo from this template in Forgejo (public). `${REPO_NAME}` is substituted into `package.json`, this README, `.env.example`, the CI workflow, and the first migration.
|
||||
2. **Set 3 Actions secrets** on the new repo: `REGISTRY_USER` (`nholtware`), `REGISTRY_TOKEN` (registry push token), `GITOPS_TOKEN` (ci-bot gitops token).
|
||||
3. **Add gitops entries**: render `templates/backend/` (see its README) into `manifests/<app>/` and add `apps/<app>.yaml` in `nholtware/gitops`.
|
||||
4. **Push** to `main`. Generating a repo emits no push event, so CI only starts on your first push.
|
||||
3
commitlint.config.js
Normal file
3
commitlint.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
};
|
||||
8
eslint.config.js
Normal file
8
eslint.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["dist/**"],
|
||||
},
|
||||
...tseslint.configs.recommended,
|
||||
);
|
||||
10
migrations/0001_init.sql
Normal file
10
migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- Up Migration
|
||||
CREATE TABLE service_info (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO service_info (name) VALUES ('${REPO_NAME}');
|
||||
|
||||
-- Down Migration
|
||||
DROP TABLE service_info;
|
||||
5906
package-lock.json
generated
Normal file
5906
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
package.json
Normal file
44
package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "${REPO_NAME}",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend service generated from nholtware/service-template-express",
|
||||
"type": "module",
|
||||
"main": "dist/app.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/bin/server.js",
|
||||
"dev": "tsx watch src/bin/server.ts",
|
||||
"test": "vitest run",
|
||||
"migrate": "node-pg-migrate up --migrations-dir migrations --migration-file-language sql",
|
||||
"lint": "eslint . && tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"node-pg-migrate": "^8.0.0",
|
||||
"pg": "^8.13.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"eslint": "^9.13.0",
|
||||
"prettier": "^3.3.3",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
69
src/app.test.ts
Normal file
69
src/app.test.ts
Normal 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
56
src/app.ts
Normal 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
28
src/bin/server.ts
Normal 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
25
src/lib/config.ts
Normal 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
24
src/lib/db.ts
Normal 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
14
src/lib/logger.ts
Normal 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
26
src/lib/redis.ts
Normal 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
35
src/routes/health.ts
Normal 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
39
src/routes/root.ts
Normal 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;
|
||||
}
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
vitest.config.ts
Normal file
7
vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue