npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 only

Use

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.