Skip to content

@tetsujs/lifecycle

@tetsujs/lifecycle stops a server without cutting the requests it is still serving, then closes what the server was using. It works with any Bun.serve server, not only a Tetsu application. The Health checks and shutdown guide puts it in context.

Terminal window
bun add @tetsujs/lifecycle
import { onShutdownSignals } from "@tetsujs/lifecycle";
const
const server: Bun.Server<unknown>
server
= Bun.serve({ ...app });
onShutdownSignals(
const server: Bun.Server<unknown>
server
, {
close?: readonly Closer[] | undefined

Resources to release, in order, after the server has stopped.

After, never before: a request still in flight may reach for the pool that closing it early would have taken away.

close
: [() =>
const pool: {
end(): Promise<void>;
}
pool
.end()] });

On SIGTERM or SIGINT this:

  1. aborts the stopping signal;
  2. keeps serving for preStopDelayMs (0 by default; see Behind a load balancer);
  3. aborts the draining signal, stops accepting connections and waits up to graceMs for the requests in flight;
  4. cuts whatever is left, waiting up to forceMs;
  5. runs the close functions in order;
  6. exits with 0 if all went cleanly, or 1 if connections had to be cut or a closer or a server’s stop threw.

Closers run after the server has stopped, so a request in flight never loses the pool it is using. A closer that throws is reported, and the rest still run.

A second signal skips the pre-stop delay and the grace period, but still cuts what is left and runs the closers. A third ends the process at once, even with exit: false: it is the way out of a shutdown that hangs, such as a closer that never returns.

To run the sequence without signal handling, call shutdown(). It never rejects: failures are collected into the result.

import { shutdown } from "@tetsujs/lifecycle";
const {
const forced: boolean

Whether the grace period ran out and connections had to be cut — on any of the servers, when there are several.

forced
,
const failures: readonly unknown[]

Whatever a server's stop and the closers threw, in the order they threw it.

failures
} = await shutdown(
const server: Bun.Server<unknown>
server
, {
close?: readonly Closer[] | undefined

Resources to release, in order, after the server has stopped.

After, never before: a request still in flight may reach for the pool that closing it early would have taken away.

close
: [() =>
const pool: {
end(): Promise<void>;
}
pool
.end()] });

A process with more than one server, such as a public API and an admin API on their own ports, passes them all at once:

const
const api: Bun.Server<unknown>
api
= Bun.serve({ ...publicApp,
port?: string | number | undefined

The port the server listens on

@default ― process.env.PORT || "3000"

port
: 3000 });
const
const admin: Bun.Server<unknown>
admin
= Bun.serve({ ...adminApp,
port?: string | number | undefined

The port the server listens on

@default ― process.env.PORT || "3000"

port
: 3001 });
onShutdownSignals([
const api: Bun.Server<unknown>
api
,
const admin: Bun.Server<unknown>
admin
], {
close?: readonly Closer[] | undefined

Resources to release, in order, after the server has stopped.

After, never before: a request still in flight may reach for the pool that closing it early would have taken away.

close
: [() =>
const pool: {
end(): Promise<void>;
}
pool
.end()] });

They drain side by side within one graceMs, and the closers run once, after the last server has stopped. Do not call onShutdownSignals once per server: the closers would run twice, and the process would exit when the first server is done.

A balancer keeps sending requests for a few seconds after the signal, and stopping at once cuts exactly those requests. Set preStopDelayMs and fail the readiness check while stopping is aborted, so traffic moves away before the server stops. Use both or neither: a delay without a failing readiness check only postpones the cut. Health checks and shutdown wires the two together.

An event stream, a long poll or a WebSocket never finishes on its own, and holds every stop for the whole of graceMs. End it on draining: sse(), stream() and ws() take it as until, and a long poll passes it to what it waits on. Use draining, not stopping: during the pre-stop delay a client that reconnects at once would land on this server again. See Streams and sockets.

A readiness route fails while stopping is aborted, and while something the instance needs is down. The guide has a health controller to copy.

stopping is a standard AbortSignal, so scheduled work can stop with the server, and a closer can wait for a run in progress before the pool it uses goes away. Background jobs shows both.

Option Default
close [] functions run in order after the server has stopped
preStopDelayMs 0 how long to keep serving after the signal
graceMs 10000 how long in-flight requests get to finish
forceMs 1000 how long to wait for the forced close
signals ["SIGTERM", "SIGINT"] onShutdownSignals only: which signals start it
exit true onShutdownSignals only: whether to end the process when done
reportError console.error onShutdownSignals only: receives each failure as { source: "shutdown", error }, like the reportError of createApp

onShutdownSignals returns { stopping, draining, detach }:

  • stopping aborts as soon as a stop is asked for. A readiness check reads it, and anything that takes an AbortSignal can use it.
  • draining aborts when the server starts to stop: after the pre-stop delay, or together with stopping when there is none.
  • detach removes the signal handlers, for a process that outlives the server, such as a test suite.

shutdown returns { forced, failures }: whether connections had to be cut on any server, and what the servers’ stop and the closers threw, in order.

The package also exports the types ShutdownOptions, SignalOptions, ShutdownHandle, ShutdownResult, ShutdownFailure, Closer, Stoppable (anything with a stop method) and Servers.

  • Why not plain server.stop(). Bun’s stop() waits as long as a client holds a WebSocket open. Every step here runs against a deadline instead.
  • Startup needs nothing from this package. Open pools and run migrations with an await before Bun.serve.