@waukeshamakerspace/service-kit
v0.3.0
Published
The house Fastify service shell for Waukesha Makerspace: the {success,data}/{success,error} envelope, Swagger wiring, and a health endpoint that actually checks the database
Maintainers
Readme
@waukeshamakerspace/service-kit
The house Fastify shell: one call for the response envelope, Swagger, cookies, and a health endpoint that actually checks the database.
Install
npm install @waukeshamakerspace/service-kit fastify
npm install --save-dev pino-pretty # optional, dev logging onlyUse
import { createApiServer } from '@waukeshamakerspace/service-kit';
import { getDb, ping } from '@myapp/database';
export async function buildApp() {
const app = await createApiServer({
service: 'myapp',
version: '1.0.0',
description: 'What this service is for. API consumers read this first.',
health: { ping },
});
await app.register(thingRoutes);
return app;
}That gives you /api/health, Swagger UI at /swagger, cookie parsing (which
auth-kit's BFF needs), and the envelope on every failure path.
Logging
Pass logger to override it, or logger: false to silence it in tests.
Otherwise the default is JSON at LOG_LEVEL (default info), switching to
pino-pretty when NODE_ENV is not production.
pino-pretty is an optional peer dependency. If it is not installed the
default falls back to JSON instead of failing to build the server, so a service
that never installs it still starts.
Shell options
Everything the Fastify shells used to pass by hand, in one place. The OpenAPI and Swagger UI pieces are typed by the plugin they are forwarded to.
| Option | What it does |
|---|---|
| title, description, version | The OpenAPI info block. version also lands in the health body. |
| servers | The OpenAPI servers block, verbatim. Local + production is the house pattern; defaults to [{ url: '/' }]. |
| tags | OpenAPI tags ({ name, description? }), the groups Swagger UI files operations under. |
| securitySchemes | OpenAPI components.securitySchemes. Replaces the default block (sessionCookie on <service>_session, bearer serviceKey) wholesale, for a service whose machine callers send X-API-Key. |
| swaggerPath | Swagger UI mount point, default /swagger. |
| swaggerUi | Forwarded verbatim to @fastify/swagger-ui: uiConfig, staticCSP, baseDir, logo. |
| fastify | Any other Fastify constructor option (bodyLimit, ignoreTrailingSlash, trustProxy, ...), merged beneath logger. |
| health | { ping, path?, version? }. See Health. |
const app = await createApiServer({
service: 'atlas',
version: '1.0.0',
servers: [
{ url: 'http://localhost:3506', description: 'Local development' },
{ url: 'https://atlas.waukeshamakers.com', description: 'Production' },
],
tags: [{ name: 'Resources', description: 'Tools, consumables, and their state' }],
securitySchemes: {
sessionCookie: { type: 'apiKey', in: 'cookie', name: 'atlas_session' },
serviceKey: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
},
swaggerUi: { uiConfig: { docExpansion: 'list', deepLinking: true }, staticCSP: true },
health: { ping },
});The envelope
From shared/conventions.md. Handlers return data or throw:
import { ApiError, ok, paginate } from '@waukeshamakerspace/service-kit';
app.get('/api/things/:id', async (request) => {
const thing = await findThing(request.params.id);
if (!thing) throw ApiError.notFound('No such thing');
return ok(thing);
});
app.get('/api/things', async (request) => {
const { items, total } = await listThings(request.query);
return ok(paginate(items, total, limit, offset));
});paginate() is the flat { items, total, limit, offset, hasMore } shape.
paginateNested(items, { limit, offset, count?, total }) is the nested one
Gatehouse and Journeyman already emit, { items, pagination: { limit, offset,
count, total } } (count defaults to items.length), so a service adopts the
shell without changing what its clients read off a list. Both are house
shapes; a new route picks one.
registerErrorEnvelope catches thrown ApiErrors, Fastify's schema-validation
rejections, unmatched routes, and unhandled bugs. Client errors keep their
message; anything 500 or above is logged with its route and answered with a
flat Internal server error, so a stack trace or a DSN never reaches a client.
Constructors: badRequest, unauthorized, forbidden, notFound, conflict,
or new ApiError(status, message).
Health
The two shapes in the wild disagreed on the thing that matters. Journeyman pinged the database and answered 503 when it was unreachable; Roster returned a static 200 that stayed green while the service was entirely unable to serve a request. A health check that cannot fail is not a health check, so the pinging version is the default here.
{
"success": true,
"data": {
"service": "myapp",
"status": "ok",
"database": "ok",
"version": "1.0.0",
"timestamp": "2026-07-27T18:00:00.000Z"
}
}database is "none" for a service without one, and the endpoint answers 503
with {"success": false, "error": "Database unreachable"} when ping throws.
Pair it with db-kit's ping and it exercises a real SELECT 1.
This body, and the 503 on an unreachable database, is the house health
contract: every service on the kit answers it, and there is deliberately no
option to reshape it. A runbook or client that expects a different body
changes to read this one. path may differ (/health where a CloudFront or
SAM route already names it); the body does not.
Omit the health option entirely to skip the route.
What changed during extraction
The shell came from three services that had drifted apart. Atlas's errors.ts
is the direct ancestor of the envelope; Journeyman's app.ts and health route
supplied the rest.
| Concern | Atlas | Journeyman | Roster | Here |
|---|---|---|---|---|
| Thrown ApiError status | honoured | no ApiError; every non-validation error became 500 | n/a | honoured |
| Unmatched route | enveloped 404 | Fastify's default {message,error,statusCode} | n/a | enveloped 404 |
| Schema-validation failure | via statusCode | explicit 400 branch | n/a | explicit branch, keeps Fastify's status |
| 5xx logging | console.error | request.log.error(error) | n/a | request.log.error({err, method, url}) |
| ApiError constructors | bare | absent | n/a | plus badRequest/unauthorized/forbidden/notFound/conflict |
| Pagination | hand-rolled per route | hand-rolled per route | hand-rolled per route | paginate() |
| Health path | /health | /api/health | mounted at / | /api/health, path overrides |
| Health envelope | bare body, unenveloped | enveloped | enveloped | enveloped |
| Database ping | yes, 503 on failure | yes, 503 on failure | none, static 200 | optional ping, 503 when it throws |
| Version in body | API_VERSION const | hardcoded '0.1.0' | absent | required option |
| service in body | absent | absent | present | present |
| timestamp in body | present | absent | present | present |
Three of those are behaviour changes worth knowing before you adopt this in a running service:
Journeyman's thrown errors all became 500. Its handler branched on
error.validation and sent 500 for everything else, so a handler that threw a
404 or a 403 answered 500 with "Internal server error". Under service-kit an
ApiError keeps its status and its message. Any handler that leaned on the old
flattening (throwing to mean "500") still gets one, because a non-ApiError
throw is still a 500.
Unmatched routes change shape in Journeyman. It registered no
setNotFoundHandler, so a 404 came back as Fastify's default
{"message": "...", "error": "Not Found", "statusCode": 404}. It is now
{"success": false, "error": "Not found"}. A client parsing message off a 404
needs updating.
Atlas's health body was never enveloped and lived at /health, not
/api/health. It returned {status, db, version, timestamp} with db as a
boolean. The replacement is enveloped like every other route, database is the
string "ok" or "none", and the path moved. An unreachable database answers
503 with the failure envelope rather than a 200 carrying a falsy flag, so a
client that read db === false off a 200 needs updating. Pass path:
'/health' to keep the old URL while a client catches up.
Roster's static health check was dropped rather than kept as an option: a
liveness endpoint that cannot fail reports nothing. A service with no database
omits ping and reports "database": "none", which is honest about what was
and was not checked.
