@devvir/service-kit

A lightweight toolkit for Node.js microservices. Health checks, graceful shutdown, and provider connections — in one place.

Most Node.js services need the same boilerplate: start a health endpoint, connect to a database or queue, handle SIGTERM, and disconnect cleanly on shutdown. Service-kit handles all of that so you can focus on what the service actually does.

There are two objects you interact with:

Install

pnpm add @devvir/service-kit
# or
yarn add @devvir/service-kit
# or
npm install @devvir/service-kit

Your first service

The minimal case — just import and run:

import SK from '@devvir/service-kit';

SK.run(async (service) => {
  service.logger.info('Running!');
});

Out of the box you get a health check on port 3000 and graceful shutdown on SIGTERM/SIGINT. No configuration required.

To declare a name and pass in some config:

import SK from '@devvir/service-kit';

SK.declare({
  name:   'api',
  config: { port: parseInt(process.env.PORT || '3001') },
});

SK.run(async (service) => {
  const port = service.config('port') as number;
  startServer(port);
});

.declare(), .bind(), and .use() mutate the instance and return it for chaining. You can chain calls or split them across lines:

SK
  .declare({ name: 'worker', config: { ... } })
  .bind({ onShutdown: cleanup });

SK.run(async (service) => { ... });

.run() is always the final step. It starts the service, connects any declared providers, registers signal handlers, and calls your function. In a typical project you would separate the declaration from the run call — .declare() and .bind() in one file, .run() in the entry point.

Events

Service is an EventEmitter. Everything the standard Node.js EventEmitter API offers — on, once, off, emit, removeAllListeners, and so on — works on a service instance directly.

One difference from a plain EventEmitter: all listeners receive the service instance as their first argument, prepended before any arguments passed to emit:

service.emit('ready', port);
// listener receives: (service, port)

service.on('ready', (service, port) => {
  service.logger.info({ port }, 'ready');
});

You can fire and listen for any event — system events emitted by plugins ('shutdown', 'healthCheck', 'providerConnected'), or your own:

service.emit('message');
service.emit('batchComplete', count);

Listeners can be registered with service.on() anywhere you have access to the service, or declared statically via SK.bind(). Bindings use the convention on + event name in PascalCase:

SK.bind({
  onShutdown:        (service, signal) => { ... },
  onMessage:         (service) => { ... },
  onBatchComplete:   (service, count) => { ... },
});

A binding named onFoo registers a listener for the 'foo' event. Bindings are equivalent to calling service.on('foo', handler) — they are just a convenient declarative form.

Plugins

Service-kit is built around plugins. Five plugins are active by default — you get all of them without any configuration:

PluginWhat it provides
HealthHTTP health check server on port 3000. Customize the port, response, or logic via onHealthCheck.
ShutdownListens for SIGTERM and SIGINT. Fires onShutdown handlers, then disconnects providers. Adds service.shutdown() for programmatic shutdown.
StateA mutable key/value store on the service. Useful for counters, flags, and values shared between handlers. Adds service.state(), service.setState(), service.increment(), and service.decrement().
ProvidersManages external connections (databases, queues, caches). Adds service.providers.connect().
NetHTTP and WebSocket servers and outbound clients. Adds service.servers and service.clients.

Each of these is covered in detail in the sections below. You can also write your own plugins — see Custom plugins.

Health checks

A health check HTTP server starts automatically on port 3000. By default it responds to any request with {"status":"ok"} and a 200 status. After each request, the plugin updates state.healthy to true if the response status was 2xx, or false otherwise — readable via service.isHealthy().

$ curl http://localhost:3000/health
{"status":"ok"}

To change the port or disable the health check entirely:

SK.declare({ healthcheck: { port: 8080 } });  // custom port
SK.declare({ healthcheck: false });            // disabled

Custom health logic

Register an onHealthCheck handler to control the response. The handler receives the service and the raw ServerResponse — write to it directly:

SK.bind({
  onHealthCheck: (service, res) => {
    const healthy = service.isHealthy();
    res.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ healthy, uptime: process.uptime() }));
  },
});

SK.run(async (service) => { ... });

Orchestrator integration

# Docker Compose
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
  interval: 30s
  timeout: 5s
  retries: 3

# Kubernetes
livenessProbe:
  httpGet:
    path: /health
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 30

Graceful shutdown

The shutdown plugin listens for SIGTERM and SIGINT. When either signal is received, it awaits all onShutdown handlers (async handlers are fully awaited), and disconnects all providers. Then it awaits all onShutdownComplete handlers, and finally exits.

Register cleanup logic with .bind():

SK.bind({
  onShutdown: async (service, signal) => {
    service.logger.info(`Shutting down (${signal})...`);
    await flushPendingWrites();
  },
});

SK.run(async (service) => { ... });

You can also register handlers inside .run() via service.on() — useful when you need access to objects that are only available at runtime:

SK.run(async (service) => {
  const server = app.listen(3001);

  service.on('shutdown', () => {
    server.close();
  });
});

To trigger shutdown programmatically (for example, after a fatal error your service can't recover from):

service.shutdown();          // signal: 'MANUAL'
service.shutdown('TIMEOUT'); // custom signal string

Match your orchestrator's grace period to your cleanup time:

# Docker Compose
stop_grace_period: 30s

# Kubernetes
terminationGracePeriodSeconds: 30

State

The state plugin provides a mutable key/value store on the service. It is useful for runtime values that need to be shared between your service function and event handlers — counters, flags, timestamps, and similar.

Declare initial state in the spec:

SK.declare({
  state: { requestCount: 0, ready: false },
});

Then read and write from anywhere that receives the service object:

// Read
service.state()             // → full state object (shallow copy)
service.state('requestCount') // → single value

// Write
service.setState('ready', true);
service.increment('requestCount'); // +1; initializes to 1 if missing
service.decrement('requestCount'); // -1; initializes to -1 if missing

Example — tracking message throughput and using it in a health check:

SK
  .declare({ state: { messages: 0, lastMessageAt: null } })
  .bind({
    onMessage: (service) => {
      service.increment('messages');
      service.setState('lastMessageAt', Date.now());
    },
    onHealthCheck: (service, res) => {
      const last    = service.state('lastMessageAt') as number | null;
      const healthy = last !== null && (Date.now() - last < 30_000);
      res.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ healthy, messages: service.state('messages') }));
    },
  });

SK.run(async (service) => {
  // ... consume messages, call service.emit('message') for each
});

Note: increment and decrement require the key to hold a number (or be unset). Calling them on a non-numeric key throws.

Servers & clients

The Net plugin does for network endpoints what the providers plugin does for databases: you declare a server or client in the spec, and the kit hands back a ready-made handle — routing, retries, reconnection, timeouts, and clean logs already wired.

It adds two APIs to the service:

The smallest case — one HTTP server:

import SK, { type ExpressServerHandle } from '@devvir/service-kit';

SK.declare({ servers: { type: 'express' } });

SK.run(async (service) => {
  const api = service.servers.get() as ExpressServerHandle;

  api.addRoute('get', '/hello', (_req, res) => res.json({ hi: true }));

  await api.start();
});

That is already a production HTTP server — JSON body parsing, request logging, a /ping route, a complete error handler, and graceful shutdown are all on. You added one route and called start().

The spec model

Every server and client is a spec object — { name, type, ...config }. type picks the kind; name defaults to the type. Pass one object, or an array:

SK.declare({
  servers: { name: 'api', type: 'express', port: 8000 },

  clients: [
    { name: 'vault', type: 'fetch', url: 'http://vault' },
    { name: 'feed',  type: 'ws',    url: 'wss://stream.example.com' },
  ],
});
APItypeWhat it is
serversexpressHTTP server — routes, middleware, error handling.
serverswsWebSocket server — connections, commands, broadcast.
clientsfetchHTTP client — retries, timeouts, status policy.
clientswsWebSocket client — reconnection, heartbeat.

For a client, type can be omitted — it is inferred from the URL scheme (http(s)://fetch, ws(s)://ws). An explicit type always wins, and is required when the url is a factory function.

Reaching a handle

Inside .run(), reach a declared server or client through the matching API:

service.servers.get('api')      // a declared server, by name
service.servers.get()           // the sole server — throws if there are several
service.clients.get('vault')    // a declared client
service.clients.has('vault')    // → boolean
service.clients.all()           // → every live client handle

You can also create one at runtime with no spec entry — service.servers.create('express'), service.clients.create({ type: 'fetch', url }). Spec-declared and runtime-created handles are identical; the spec is simply where stable config belongs.

Bringing things up. A server or stateful client is registered immediately, but binds no port and dials no connection until you call start(). Teardown is automatic — every handle is stopped on service shutdown.

Templates & pools

A spec entry marked template: true is registered but not started — it records a reusable shape. Mint instances from it with clone(), supplying whatever the template left out:

SK.declare({
  clients: { name: 'bitmex', template: true, type: 'ws', heartbeat: 30_000 },
});

SK.run(async (service) => {
  // one connection per account, all sharing the template's config
  const open = (acct) =>
    service.clients
      .clone('bitmex', { name: `bitmex:${acct.id}`, url: () => sign(acct) })
      .start();
});

This keeps the static config of a pooled client in one place, alongside every other spec entry, instead of scattered through the service as inline objects.

Express server

The express server kind is an HTTP server with the recurring boilerplate already wired. Declare it, add routes, start it:

import SK, { type ExpressServerHandle } from '@devvir/service-kit';

SK.declare({ servers: { type: 'express' } });

SK.run(async (service) => {
  const api = service.servers.get() as ExpressServerHandle;

  api.addRoute('get', '/users/:id', (req, res) => {
    res.json({ id: req.params.id });
  });

  await api.start();
});

Routes can be registered the moment the handle exists; start() binds the port and begins accepting. The handle wraps a real Express app — api.app is always there for anything the handle does not surface.

On by default

Every express server starts with these already wired:

Routes and middleware

Routes and middleware register on the handle, and they are cumulative — many modules can contribute to one server:

// a single route
api.addRoute('post', '/orders', createOrder);

// an Express Router, an (app) => void builder, or an array of either
api.addRoutes(buildOrderRoutes());
api.addRoutes([adminRouter, metricsRouter]);

// plain middleware, and a router mounted under a prefix
api.use(cors);
api.mount('/api/v1', v1Router);

Building routes in a function that returns a Router is the recommended shape: it is trivially testable on a bare Express app, with no service involved.

The error handler

One complete error handler is appended automatically. It keys off generic signals — never a specific validation library:

ConditionResponse
Error carries a status / statusCodethat status
Malformed JSON body400
Body over the size limit413
Client aborted the request499
Anything else500, logged

A route signals a specific status by attaching one to the error it throws — and a validation error reaches 400 the same way, by carrying status: 400:

api.addRoute('get', '/orders/:id', (req, res) => {
  const order = lookup(req.params.id);

  if (! order) throw Object.assign(new Error('not found'), { status: 404 });

  res.json(order);
});

Options

OptionDefaultDescription
portNET_DEFAULT_PORT, else 80Port to bind.
jsontruetrue, { limit: '32mb' } to size it, or false to disable.
rawfalseParse the body as raw bytes — for a proxy that forwards bodies untouched.
basePath''Prefix prepended to every route.
pingabletrueMount GET /ping.
rateLimitnullPer-IP request-rate limiting — see below.
tls{ cert, key } upgrades the listener to HTTPS.
requestTimeout
headersTimeout
Node defaultsThe http.Server deadlines, in ms. 0 disables — e.g. for an endpoint that receives large streamed uploads.

Default port

A server's port resolves in precedence order: an explicit port in the spec → the NET_DEFAULT_PORT environment variable → 80. With 80, container-internal URLs need no :port. A host that disallows low ports sets NET_DEFAULT_PORT once in its environment, and every server without an explicit port follows — no code change.

Rate limiting

rateLimit caps requests per client IP. Two forms:

rateLimit: '60/m'                               // 60 per minute — fixed window
rateLimit: { max: 60, unit: 'm', rolling: true }  // rolling (sliding) window

The string form is a fixed window — the count resets on each boundary. The object form with rolling: true is a sliding trailing window, with no burst at the boundary. Over-limit requests receive 429.

Lifecycle

await api.start();   // bind the port, begin accepting
await api.stop();    // graceful drain; the handle is reusable afterwards
api.isUp();          // → boolean
api.status;          // 'down' | 'starting' | 'up' | 'closing'

Declared servers stop automatically on service shutdown, so an explicit stop() is only for restarts or tests. configure(partialSpec) changes options on a stopped server — it throws if the server is up.

WebSocket server

The ws server kind is a managed WebSocket server. It owns the connection registry, a heartbeat with dead-connection sweep, JSON-frame parsing, and per-client error isolation — you supply what connections and messages mean, through hooks.

import SK, { type WsServerHandle } from '@devvir/service-kit';

SK.declare({ servers: { type: 'ws' } });

SK.run(async (service) => {
  const ws = service.servers.get() as WsServerHandle;

  ws.onConnect((client) => client.socket.send('welcome'));
  ws.onMessage((client, data) => service.logger.info(data.toString()));

  await ws.start();
});

Connections

Each connection is a WsServerClient{ id, socket, data }. id is a generated UUID, socket is the raw WebSocket, and data is a free-form per-client object you own — store identity, subscriptions, anything:

ws.onConnect((client, req) => {
  const url = new URL(req.url ?? '/', 'http://x');

  client.data.apiKey = url.searchParams.get('api-key') ?? undefined;
});

ws.onDisconnect((client) => cleanup(client.id));

Commands

For a JSON protocol with an op field, register command handlers by op name — the server parses each frame and dispatches:

ws.addCommand('subscribe',   (client, msg) => subscribe(client, msg.args));
ws.addCommand('unsubscribe', (client, msg) => unsubscribe(client, msg.args));

// or many at once
ws.addCommands({ subscribe, unsubscribe });

A frame that is not JSON, or carries no matching command, falls through to the onMessage hooks instead — use those for raw frames or non-op protocols.

Broadcast and built-ins

ws.broadcast(JSON.stringify({ type: 'tick', price }));  // to every open client

for (const client of ws.clients()) notify(client);    // iterate the registry

On by default: a heartbeat (pingable; interval heartbeat ms, default 30s) that pings every client and terminates any that miss a pong — dead connections are swept without the application noticing; a pingpong text shortcut; and per-client error isolation, so one handler throwing never disturbs another connection. pingable: false turns the heartbeat off.

Lifecycle mirrors the express server — start(), stop(), status, and automatic close-all on shutdown.

Fetch client

The fetch client is a configured HTTP client — a base URL, retries, timeouts, a status policy, and clean transition logging layered over Node's fetch.

import SK, { type FetchClientHandle } from '@devvir/service-kit';

SK.declare({
  clients: { name: 'vault', type: 'fetch', url: 'http://vault' },
});

SK.run(async (service) => {
  const vault = service.clients.get('vault') as FetchClientHandle;

  const files = await vault.get('/files');       // → parsed JSON
  await vault.post('/files/today/rows', rows);   // JSON body, serialised
});

A fetch client is stateless — there is no start(). Declare it, take the handle, call it.

JSON helpers and raw requests

get, post, put, patch, and delete are JSON sugar — they resolve to parsed JSON, serialise an object body, and set the content type. For full control, request() carries the exact fetch signature and returns a real Response:

const res = await vault.request('/files/today', { method: 'PUT', body: stream, duplex: 'half' });

if (! res.ok) throw new Error(`HTTP ${res.status}`);

A relative path is resolved against the configured url; an absolute URL overrides it.

Status policy

What happens to a response is declarative:

const file = await vault.get('/files/x', { passThrough: [404] });  // 404 → null, not a throw

Retry and timeouts

Retries are on by default — capped exponential backoff, retrying both retryOn statuses and network errors. By default it retries forever; that is safe precisely because it is never silent (see transition logging). Cap it with attempts:

clients: {
  name:    'vault',
  type:    'fetch',
  url:     'http://vault',
  retry:   { attempts: 5 },     // cap at 5 — omit to retry forever
  retryOn: [429, 503],          // statuses that retry
  timeout: 30_000,              // per-request deadline (ms)
}

A 429 is handled first-class: the client honours Retry-After and x-ratelimit-reset, and otherwise backs off at the cap. The per-request timeout aborts a stuck request — but is skipped automatically for a streaming body (a ReadableStream upload of unbounded duration), so a healthy large upload is never cut off. retry, retryOn, passThrough, and timeout can each be overridden per request.

Transition logging

This is what makes "retry forever" safe. The client tracks whether each target is reachable, and logs only the transitions — never a line per attempt:

An hour-long outage is three log lines and a heartbeat, not thousands.

Auth

Well-known schemes are pure spec — no callback:

auth: { bearer: process.env.TOKEN }
auth: { basic:  { user, pass } }
auth: { apiKey: { header: 'X-API-Key', value: key } }

Anything bespoke — HMAC request signing, expiring credentials — uses the sign hook: a function handed { method, url, body } that returns the headers for that one request.

clients: {
  name: 'exchange',
  type: 'fetch',
  url:  'https://api.exchange.com',
  sign: (req) => signRequest(req, secret),
}

WebSocket client

The ws client is a managed WebSocket connection. It owns the lifecycle — single-flight reconnection with capped backoff, a heartbeat that detects dead connections, transition logging, and messages that survive reconnects — so the caller never hand-rolls a reconnect loop.

import SK, { type WsClientHandle } from '@devvir/service-kit';

SK.declare({
  clients: { name: 'feed', type: 'ws', url: 'wss://stream.example.com' },
});

SK.run(async (service) => {
  const feed = service.clients.get('feed') as WsClientHandle;

  feed.onMessage((data) => handle(JSON.parse(data.toString())));

  await feed.start();   // dials; resolves on first open
});

Reconnection

If the connection drops, the client reconnects on its own — no caller code. The loop is single-flight: the error and close events from one failure, and any repeats during an outage, collapse into exactly one pending attempt. Attempts never pile up. Backoff is capped exponential, reset on a successful open; a 429 on the upgrade jumps straight to the cap. Reachability transitions are logged the way the fetch client logs them — one line down, one line up.

Sending, and surviving reconnects

send() writes to the current socket. A message that must hold for the life of the connection — a subscription, an auth frame — should instead be registered with sendOnOpen(), which (re)sends it on every open, reconnects included:

feed.send(JSON.stringify({ op: 'ping' }));     // one-off

const sub = feed.sendOnOpen(JSON.stringify({ op: 'subscribe', args: ['trades'] }));
// re-sent automatically on every reconnect — sub.stop() to cancel

For anything more involved, on('open', …) is the durable hook — it fires on every (re)connection.

Events

on(event, handler) takes the lifecycle events and any native ws event. Listeners are durable — re-applied to each replacement socket, so reconnection stays transparent:

EventWhen
openEach (re)connection opens.
closeThe socket closes.
reconnectingA reconnect is scheduled — the delay (ms) is the argument.
message, ping, pong, errorThe native ws events.

Heartbeat and credentials

A heartbeat (heartbeat ms, default 30s; 0 disables) pings the server and terminates the connection when a pong is missed — a dead connection becomes a fast reconnect instead of a silent stall.

The url may be a factory() => string | Promise<string> — re-invoked on every connect. This is how freshly-signed, expiring credentials reach each new connection:

clients: {
  name: 'private-feed',
  type: 'ws',                          // required — a factory url has no scheme to sniff
  url:  () => signWsUrl(credentials),
}

Lifecycle: start() dials and resolves on the first open; stop() closes and stops reconnecting (the handle stays reusable); declared clients stop automatically on shutdown.

Connecting providers

Providers are external connections — databases, queues, caches. Declare them in the spec under the providers key, then connect to them by name inside .run().

SK.declare({
  providers: {
    mongodb:  { url: process.env.MONGODB_URL },
    rabbitmq: { url: process.env.RABBITMQ_URL },
  },
});

SK.run(async (service) => {
  const [db, broker] = await service.providers.connect(['mongodb', 'rabbitmq']);
});

service.providers.connect(name) establishes the connection and returns the client. Pass an array to connect multiple providers in parallel.

The provider key is also the provider type by default ('mongodb', 'rabbitmq', 'redis'). To use a custom name, add a provider field:

providers: {
  primary: { provider: 'mongodb', url: process.env.MONGODB_PRIMARY_URL },
  replica: { provider: 'mongodb', url: process.env.MONGODB_REPLICA_URL },
}

Retry

Connection failures are retried automatically. The default is a linear backoff. Override per provider:

providers: {
  mongodb: {
    url:   process.env.MONGODB_URL,
    retry: { strategy: 'exponential', delay: 1000, maxDelay: 30_000, attempts: 10 },
  },
}

Disconnect events

Use onProviderConnected and onProviderDisconnected to react to connection state changes:

SK.bind({
  onProviderConnected:    (service, type, name) => service.logger.info(`${name} connected`),
  onProviderDisconnected: (service, type, name) => service.logger.warn(`${name} disconnected`),
});

MongoDB

The MongoDB provider returns a MongoClient instance from the official driver.

import type { MongoClient } from 'mongodb';

SK.declare({
  providers: {
    mongodb: { url: process.env.MONGODB_URL },
  },
});

SK.run(async (service) => {
  const client = await service.providers.connect('mongodb') as MongoClient;
  const db     = client.db('myapp');
  const user   = await db.collection('users').findOne({ email: 'x@example.com' });
});

RabbitMQ

The RabbitMQ provider can return either a raw amqplib connection or a higher-level Broker instance. Set useBroker: true to get the Broker:

import type { Broker } from '@devvir/rabbitmq';

SK.declare({
  providers: {
    rabbitmq: { url: process.env.RABBITMQ_URL, useBroker: true },
  },
});

SK.run(async (service) => {
  const broker = await service.providers.connect('rabbitmq') as Broker;
  const queue  = broker.getQueue('jobs')!;

  await queue.consume(async (message, delivery) => {
    await processJob(message);
    delivery.ack();
  });
});

Topology declaration

Pass a topology spec to declare exchanges and queues on connect, declaratively:

providers: {
  rabbitmq: {
    url:      process.env.RABBITMQ_URL,
    useBroker: true,
    topology: {
      exchanges: {
        events: {
          type:   'topic',
          queues: { 'events.processor': { routingKey: '#' } },
        },
      },
    },
  },
}

Redis

The Redis provider returns an ioredis client.

import type { Redis } from 'ioredis';

SK.declare({
  providers: {
    redis: { url: process.env.REDIS_URL },
  },
});

SK.run(async (service) => {
  const redis = await service.providers.connect('redis') as Redis;
  await redis.set('key', 'value', 'EX', 3600);
});

SK.create()

In a monorepo or multi-service project, you often want shared configuration — the same health check logic, the same shutdown handler, the same logging. SK.create() lets you define that once and export a pre-configured instance for your services to build on.

// packages/shared/sk.ts
import SK from '@devvir/service-kit';

export default SK.create({
  spec: {
    healthcheck: { port: 3002 },
  },
  bindings: {
    onShutdown: (service, signal) => {
      service.logger.info(`Shutdown signal: ${signal}`);
    },
  },
});
// services/my-service/service.ts
import SK from '@shared/sk';

export default SK
  .declare({
    name: 'my-service',
    providers: { mongodb: { url: process.env.MONGODB_URL } }
  })
  .bind({ onShutdown: cleanup });
// services/my-service/index.ts
import SK from './service';

SK.run(async (service) => {
  const db = await service.providers.connect('mongodb');
  // ...
});

Each service gets its own instance from SK.create(), so .declare() and .bind() calls in one service don't affect others.

SK.create() accepts a config object with three optional keys: spec, bindings, and plugins.

Service registry

The registry is a process-wide store of named service instances. SK.run() registers the service automatically when the spec has a name. Any module in the same process can then look it up — no prop drilling, no manual wiring.

import { registry, SK_CONFIG, SK_STATE, SK_PROVIDERS } from '@devvir/service-kit';

// Get the service itself
const svc = registry.get('worker');

// Or ask for a specific member directly
const config    = registry.get('worker', SK_CONFIG);    // → service.config()
const state     = registry.get('worker', SK_STATE);     // → service.state()
const providers = registry.get('worker', SK_PROVIDERS); // → service.providers

SK_CONFIG, SK_STATE, and SK_PROVIDERS are Symbols — not strings — so callers must import them from service-kit rather than conjuring their own key.

Avoiding prop drilling

Without the registry, a module deep in your call tree must receive the service (or its providers) as an argument from the top:

// Without registry — provider travels as a parameter through every layer
async function startConsumer(service: Service) {
  const broker = await service.providers.connect('rabbitmq');
  await consume(broker);
}

async function consume(broker: Broker) {
  const queue = broker.getQueue('jobs')!;
  await queue.consume(async (msg, delivery) => {
    await handleJob(msg, broker); // propagates further
    delivery.ack();
  });
}
// With registry — any module reaches the provider directly
import { registry, SK_PROVIDERS } from '@devvir/service-kit';
import type { Broker } from '@devvir/rabbitmq';

async function startConsumer() {
  const providers = registry.get('worker', SK_PROVIDERS);
  const broker    = providers.get('rabbitmq') as Broker;

  const queue = broker.getQueue('jobs')!;
  await queue.consume(async (msg, delivery) => {
    await handleJob(msg);
    delivery.ack();
  });
}

Misconfiguration guard

Registering two services with the same name in one process is always a misconfiguration. The registry throws immediately rather than silently overwriting the first service:

// Both specs say name: 'processor' → error on second SK.run()
// [registry] Service "processor" is already registered —
// duplicate name in one process is a misconfiguration

Testing

In tests, register a mock service and call registry.clear() between test cases so each test starts with a clean slate:

import { registry, SK_CONFIG } from '@devvir/service-kit';
import Service from '@devvir/service-kit/core/service';

beforeEach(() => registry.clear());

it('reads config from registry', () => {
  const svc = new Service({
    spec:     { name: 'teller', config: { port: 4000 } },
    bindings: {},
    plugins:  [],
  });

  registry.add(svc);

  const cfg = registry.get('teller', SK_CONFIG);
  expect(cfg.port).toBe(4000);
});

Production code that calls registry.get('teller', SK_PROVIDERS) will receive the mock's providers transparently — no injection, no argument threading.

Custom plugins

Plugins are how service-kit is extended. The built-in health check, state, shutdown, and provider features are all plugins — there is no separate internal API.

A plugin is a plain object with a name, an optional init function that runs when the service starts, and an optional extends function that adds methods to the service object:

import SK, { type Plugin } from '@devvir/service-kit';

const metricsPlugin: Plugin = {
  name: 'Metrics',

  extends(config) {
    const counters: Record<string, number> = {};

    return {
      count: (key: string) => { counters[key] = (counters[key] || 0) + 1; },
      metrics: () => ({ ...counters }),
    };
  },
};

export default SK.use(metricsPlugin);

After this, service.count() and service.metrics() are available in your service function and all handlers.

Use init when you need to hook into the service lifecycle — register event listeners, start background processes, or do setup that depends on the assembled service:

{
  name: 'RequestTracker',

  init(service) {
    service.on('shutdown', () => {
      service.logger.info({ requests: service.state('requests') }, 'Final request count');
    });
  },
}

ServiceKit (SK)

The default export is a singleton ServiceKit instance. .declare(), .bind(), and .use() mutate the instance and return it for chaining. Only .create() returns a new separate instance.

SK.declare(spec: Spec): this

Merges the spec into the instance. Multiple calls accumulate — later values override earlier ones for the same key.

SK.bind(bindings: Bindings): this

Adds the bindings to the instance. Multiple calls accumulate — handlers are never replaced, only appended.

SK.use(plugin: Plugin | Plugin[]): this

Registers the plugin(s) on the instance.

SK.create(config: { spec?, bindings?, plugins? }): ServiceKit

Creates and returns a new ServiceKit instance with a preset configuration. Useful for common presets in a monorepo, or otherwise shared SK instances that best suit your needs. See SK.create().

SK.run(fn: (service: Service) => void | Promise<void>): void

Starts the service. Initializes plugins, registers bindings, calls your function. Errors in fn trigger shutdown and exit with code 1.

SK.defaults: { spec?, bindings?, plugins? }

Getter and setter for the global defaults applied to every SK run. Mutates the singleton. Use SK.create() instead when you don't want global side effects.

Service

The runtime object passed to your service function and all event handlers.

Config

service.config(): Record<string, unknown>
service.config(key: string): unknown

Returns the full config object or a single value by key. Config is set via declare({ config: {...} }) and is read-only at runtime.

State

service.state(): Record<string, unknown>
service.state(key: string): unknown
service.setState(key: string, value: unknown): void
service.increment(key: string): void
service.decrement(key: string): void

Mutable runtime state. increment and decrement require the key to be numeric (or absent). See State.

Providers

service.providers.connect(name: string): Promise<unknown>
service.providers.connect(names: string[]): Promise<unknown[]>

Establishes a connection to a declared provider and returns the client. Pass an array to connect multiple providers concurrently.

Shutdown

service.shutdown(signal?: string): Promise<void>

Triggers the shutdown sequence programmatically. Awaits all onShutdown handlers, disconnects providers, awaits all onShutdownComplete handlers, then exits. Default signal: 'MANUAL'.

Health

service.isHealthy(): boolean

Returns the current healthy/unhealthy status. The Health plugin updates state.healthy automatically after each health check request — true if the response status was 2xx, false otherwise.

Events

service.on(event: string, handler: Function): this
service.once(event: string, handler: Function): this
service.off(event: string, handler: Function): this
service.emit(event: string, ...args: unknown[]): boolean
service.hasListeners(event: string): boolean

Standard EventEmitter interface. All handlers receive service as their first argument automatically — you don't need to pass it yourself. Use service.emit() to fire any event — system, plugin, or custom. Listeners can be registered via bindings, service.on(), or inside plugins.

Providers

service.providers.connect(name: string): Promise<unknown>
service.providers.connect(names: string[]): Promise<unknown[]>

Lazily connects a configured provider and returns the connection. Call inside .run() when the connection is needed. Returns the underlying client (e.g. MongoClient, Broker).

Servers & clients

service.servers: ServersAPI
service.clients: ClientsAPI

The Net plugin's APIs for declared HTTP/WS servers and outbound clients. See Servers & Clients.

Logging

service.logger: pino.Logger

Structured logger scoped to the service. Use it anywhere you have access to the service.

Introspection

service.spec(): Spec
service.bindings(): Bindings
service.plugins(): string[]
service.config(key?: string): unknown
service.state(key?: string): unknown

Read-only views into the service's current configuration and runtime state. config() returns the full config object or a single key. state() returns the full state snapshot or a single key.

Spec

FieldTypeDefaultDescription
namestring'service'Service name. Used in log output.
configRecord<string, unknown>{}App config. Access via service.config().
stateRecord<string, unknown>{}Initial mutable state. Access via service.state().
healthcheckboolean | number | { port?, message? }truetrue: port 3000. false: disabled. Number: port. Object: full config.
providersRecord<string, ProviderSpec>{}Provider configurations. See Providers.
serversServerSpec | ServerSpec[]HTTP/WS servers. See Servers & clients.
clientsClientSpec | ClientSpec[]HTTP/WS outbound clients. See Servers & clients.

ProviderSpec

FieldDescription
urlConnection URL (required).
providerProvider type. Defaults to the spec key name ('mongodb', 'rabbitmq', 'redis').
retryRetry config: { strategy: 'linear' | 'exponential', delay, maxDelay?, attempts? }.

Provider-specific fields (e.g., useBroker, topology for RabbitMQ) are also accepted — the provider handler reads them from the spec object.

Servers & Clients

The Net plugin adds service.servers and service.clients. Both expose the same collection API; the handles differ by kind. See Servers & clients for the guide.

service.servers / service.clients

get(name?: string): Handle
create(spec?: Spec | string): Handle
clone(name: string, overrides?: Partial<Spec>): Handle
all(): Handle[]
has(name: string): boolean

get() with no argument returns the sole instance, and throws if there are several. create() builds a runtime instance from a spec object, a bare type string, or kind defaults. clone() instantiates from a template: true entry. service.servers.create() takes an optional second routes argument, applied via addRoutes.

ExpressServerHandle

addRoute(method, path, ...handlers): this
addRoutes(routes): this
use(...middleware): this
mount(basePath, router): this
setBase(path): this
start(): Promise<this>   stop(): Promise<this>
dispose(): Promise<void>
configure(partialSpec): this
isUp(): boolean   isDown(): boolean
status: 'down' | 'starting' | 'up' | 'closing'
app: express.Application   server: http.Server | null

Routes and middleware are cumulative. configure() throws while the server is up. app and server are escape hatches.

WsServerHandle

addCommand(op, handler): this   addCommands(commands): this
onConnect(handler): this   onDisconnect(handler): this
onMessage(handler): this
broadcast(data): void
clients(): Iterable<WsServerClient>
start(): Promise<this>   stop(): Promise<this>
dispose(): Promise<void>
configure(partialSpec): this
isUp(): boolean   isDown(): boolean
status: 'down' | 'starting' | 'up' | 'closing'
server: WebSocketServer | null

A WsServerClient is { id: string, socket: WebSocket, data: Record<string, unknown> }.

FetchClientHandle

get<T>(path, opts?): Promise<T | null>
post / put / patch / delete<T>(path, body?, opts?): Promise<T | null>
request(input, init?): Promise<Response>
configure(partialSpec): this
dispose(): void

The JSON helpers resolve to parsed JSON, or to null for a passThrough status. request() returns the raw Response; its init extends RequestInit with retry, retryOn, passThrough, and timeout. configure() is always allowed — the client is stateless.

WsClientHandle

start(): Promise<this>   stop(): this
dispose(): void
send(data): void
sendOnOpen(message | message[]): { stop(): void }
onMessage(handler): this
on(event, handler): this   off(event, handler): this
configure(partialSpec): this
isUp(): boolean   isDown(): boolean
status: 'down' | 'connecting' | 'open' | 'reconnecting'
socket: WebSocket | null

configure() throws while the client is up. on() listeners are durable across reconnects.

Bindings

Bindings are event handlers registered via .bind(). Keys follow the on<EventName> pattern. All handlers receive service as their first argument, followed by event-specific data.

KeyAdditional argumentsWhen
onShutdownsignal: stringSIGTERM, SIGINT, or service.shutdown().
onShutdownCompletesignal: stringAfter providers disconnect and health server closes.
onHealthCheckres: ServerResponseEach inbound health check request. Write the response to res.
onProviderConnectedtype: ProviderType, name: stringAfter service.providers.connect() succeeds.
onProviderDisconnectedtype: ProviderType, name: stringAfter a provider connection drops.

Custom events are also supported. If your binding key is onMessage, you fire it with service.emit('message'):

SK.bind({
  onMessage: (service) => {
    service.increment('messageCount');
  },
});

// Later, inside run():
service.emit('message');

Events

Event stringBinding keyWhen
'shutdown'onShutdownSIGTERM, SIGINT, or service.shutdown().
'shutdownComplete'onShutdownCompleteAfter shutdown sequence completes.
'healthCheck'onHealthCheckEach GET /health request.
'providerConnected'onProviderConnectedAfter connect() resolves.
'providerDisconnected'onProviderDisconnectedAfter a provider drops.
customon<Name>service.emit('name') — any string that follows the onEventName'eventName' convention.

Example: message consumer

A RabbitMQ worker that processes jobs and drains gracefully on shutdown.

import SK from '@devvir/service-kit';
import type { Broker } from '@devvir/rabbitmq';

let active = 0;
let accepting = true;

const worker = SK
  .declare({
    name:      'job-worker',
    state:     { processed: 0 },
    providers: { rabbitmq: { url: process.env.RABBITMQ_URL, useBroker: true } },
  })
  .bind({
    onShutdown: async (service, signal) => {
      accepting = false;
      service.logger.info(`Draining ${active} in-flight jobs...`);
      const deadline = Date.now() + 30_000;
      while (active > 0 && Date.now() < deadline) {
        await new Promise((r) => setTimeout(r, 200));
      }
    },
  });

worker.run(async (service) => {
  const broker = await service.providers.connect('rabbitmq') as Broker;
  const queue  = broker.getQueue('jobs')!;

  await queue.consume(async (message, delivery) => {
    if (! accepting) { delivery.nack(); return; }

    active++;
    try {
      await processJob(message);
      service.increment('processed');
      delivery.ack();
    } catch {
      delivery.nack();
    } finally {
      active--;
    }
  }, { prefetch: 10 });
});

Example: API server

An Express API with a MongoDB connection and graceful HTTP server shutdown.

import SK from '@devvir/service-kit';
import type { MongoClient } from 'mongodb';
import express from 'express';

const api = SK
  .declare({
    name:      'user-api',
    config:    { port: parseInt(process.env.PORT || '3001') },
    providers: { mongodb: { url: process.env.MONGODB_URL } },
  });

api.run(async (service) => {
  const client = await service.providers.connect('mongodb') as MongoClient;
  const db     = client.db('myapp');
  const port   = service.config('port') as number;
  const app    = express();

  app.use(express.json());

  app.get('/users/:id', async (req, res) => {
    const user = await db.collection('users').findOne({ _id: req.params.id });
    res.json(user ?? { error: 'not found' });
  });

  const server = app.listen(port, () => service.logger.info(`API on port ${port}`));

  service.on('shutdown', () => {
    server.close();
  });
});

Example: batch job

A one-shot script that connects, does its work, and exits. Disable the health check so Node exits naturally when the service function returns:

import SK from '@devvir/service-kit';
import type { MongoClient } from 'mongodb';

const job = SK.declare({
  name:        'data-import',
  healthcheck: false,
  providers:   { mongodb: { url: process.env.MONGODB_URL } },
});

job.run(async (service) => {
  const client  = await service.providers.connect('mongodb') as MongoClient;
  const db      = client.db('myapp');
  const records = await fetchExternalData();
  await db.collection('imports').insertMany(records);
  service.logger.info(`Imported ${records.length} records`);
  // function returns → provider disconnects → process exits
});

Example: project preset

A shared base instance for a monorepo. Each service adds its own config and providers on top.

// packages/shared/sk.ts
import SK from '@devvir/service-kit';

export default SK.create({
  bindings: {
    onShutdown: (service, signal) => {
      service.logger.info(`[${service.spec().name}] shutdown (${signal})`);
    },
    onHealthCheck: (service, res) => {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
    },
  },
});
// services/order-processor/service.ts
import SK from '@shared/sk';

export default SK.declare({
  name:      'order-processor',
  providers: { rabbitmq: { url: process.env.RABBITMQ_URL, useBroker: true } },
});
// services/order-processor/index.ts
import SK from './service';
import type { Broker } from '@devvir/rabbitmq';

SK.run(async (service) => {
  const broker = await service.providers.connect('rabbitmq') as Broker;
  const queue  = broker.getQueue('orders')!;

  await queue.consume(async (order, delivery) => {
    await processOrder(order);
    delivery.ack();
  });
});