Skip to content
Build a serviceDocs 1.3.x

Server Lifecycle ​

Connectum servers follow a deterministic state machine. Understanding the lifecycle helps you hook into the right moment for health checks, telemetry, background workers, and resource cleanup.

Server States ​

StateWhat happens
createdcreateServer() returns. No port is bound yet.
startingserver.start() called -- the server binds the port and initializes protocols.
runningThe server is accepting requests.
stoppingserver.stop() called (or a signal received with autoShutdown). Connections are being drained.
stoppedStartup failed or shutdown finished. With forceCloseOnTimeout: false, existing connections may remain open; a failed hook may leave application cleanup incomplete.

The transitions are one-directional. A stopped server cannot be restarted -- create a new one instead.

Which calls work in which state ​

Callcreatedstartingrunningstoppingstopped
start()Starts the serverRejectsRejectsRejectsRejects
stop()Rejects; the server stays createdWaits for the start to settle, then stops the server; resolves if the start failsStarts the shutdownReturns the promise of the shutdown in progress and settles with its outcomeResolves at once; runs no hooks and emits no events
onShutdown()Registers the hookRegisters the hookRegisters the hookRegisters the hook (see the note below)Throws
addService(), addInterceptor(), addProtocol()Add, unless the routes are already builtThrowThrowThrowThrow

A rejected start() or stop() carries the message Cannot start server: current state is "<state>", expected "created" or, for a server that was never started, Cannot stop server: current state is "created", expected "running". The add* methods throw Cannot add <service|interceptor|protocol>: server is already <state>. Add ... before calling start().; onShutdown() in the stopped state throws Cannot add shutdown hook: server is already stopped.

  • Routes lock. The add* methods also throw in the created state once the routes have been built. start() builds them, and so does the first use of the in-process client (server.localClient()), even on a server that is still in the created state.
  • A failed start() ends in stopped. The server cannot be started again.
  • A hook registered while stopping. onShutdown() accepts it without an error, but the shutdown collects its hooks once, after the transport has closed. A hook registered after that moment is not guaranteed to run.
  • Signal handlers appear with starting. With autoShutdown: true the framework installs its signal handlers when start() begins. When stop() begins it replaces them with handlers that end the process on a repeated signal (see Repeated signals) until stop() completes, and when the start fails it removes them. In created the framework has no handler for the signal. A signal that arrives while the server is starting stops it as soon as the start has finished; see Stop and signals during startup.

Lifecycle Events ​

Register listeners with server.on(event, handler):

typescript
server.on('start', () => {
  console.log('Server is starting...');
});

server.on('ready', () => {
  console.log(`Listening on port ${server.address?.port}`);
  healthcheckManager.update(ServingStatus.SERVING);
});

server.on('stopping', () => {
  console.log('Shutdown initiated');
  healthcheckManager.update(ServingStatus.NOT_SERVING);
});

server.on('stop', () => {
  console.log('Server stopped cleanly');
});

server.on('error', (err) => {
  console.error('Server error:', err);
});

Event Reference ​

EventPayloadTypical use
start--Log startup, initialize external connections
ready--Update health status to SERVING, start background workers
stopping--Update health status to NOT_SERVING, stop accepting new work
stop--Final log, exit the process if needed
errorErrorLog the error, alert monitoring

Event Ordering ​

The error events described below are emitted only when an error listener is registered.

Events always fire in the transition order shown in the state diagram: start, ready, stopping, then stop.

error may fire at any point. If startup fails before the server is listening, or a start listener throws, the sequence is start → error. If a ready listener throws, ready has already been emitted, so the sequence is start → ready → error. If the shutdown itself fails (closing the transport or a shutdown hook), it is stopping → error → stop: a failed shutdown still ends with stop, and stop() rejects with the original error, or with an AggregateError when several hooks failed, or when the transport close and a hook both failed (see Graceful Shutdown). A stopping or stop listener that throws is not a failed shutdown: it is reported through error and stop() still resolves.

Listener Exceptions ​

What happens when a listener throws depends on the phase:

EventA throwing listener
start, readyIs a startup failure. start() rejects with that exception, error is emitted, the port, the autoShutdown signal handlers and the event bus are released, and the server ends up stopped.
stopping, stopIs isolated. The remaining listeners of the event still run, the shutdown always reaches stopped, and the exception is reported through error. stop() does not reject because of it.

Register an error listener before start() to observe startup and shutdown failures. Without one, exceptions from stopping or stop listeners are written to the server logger's error method (console.error unless you pass logger), while failures of start() or stop() are carried by their rejected promises.

For signal-triggered shutdown (autoShutdown), a failure is reported once through error only when a listener is registered. The signal handler catches the rejection, so it does not become an unhandled rejection; without a listener, a shutdown-hook failure can go unreported.

Only synchronous exceptions are covered. An async listener that rejects is not awaited by the emitter; handle its errors inside the listener.

Server Log Messages ​

Besides emitting events, the server writes a few messages about its own lifecycle. By default they go to console. Pass logger to createServer() to send them to your own logger instead:

typescript
const server = createServer({
  services: [greeterService],
  port: 5000,
  logger: {
    info: (message) => appLog.write('info', message),
    warn: (message, ...details) => appLog.write('warn', message, details),
    error: (message, ...details) => appLog.write('error', message, details),
  },
});

logger is an object with info, warn and error functions, typed as ServerLogger. Each method receives a complete message string first. When the message reports a failure, the error object follows it. The global console already fits this shape, so leaving the option out keeps the output as it was.

LevelMessageWhen
infoServer listening <host>:<port>The transport is bound. The wildcard IPv6 address is shown as localhost.
infoReceived <signal>, initiating graceful shutdown...A signal arrived while autoShutdown is on.
warnShutdown timeout (<n>ms) exceededClosing the transport did not finish within shutdown.timeout.
warnA transport warningBidirectional streaming over plain HTTP/1.1 with transportValidation: "warn", or TLS that can negotiate HTTP/1.1; see Transport matrix.
warnShutdown hook "<name>" depends on "<missing>", ...A hook depends on a name no hook registered; see Graceful Shutdown.
errorError during graceful close:Closing the transport failed. The error object follows.
errorUnhandled error in a server lifecycle listener:A stopping or stop listener threw and nobody listens to error. The error object follows.
errorAn 'error' listener threw:A listener of the error event itself threw. The error object follows.

Three rules apply to the option:

  • It is checked when the server is created. createServer() throws a TypeError for a value that is not an object, and for an object whose info, warn or error is not a function. The message names the method, for example createServer: logger.warn must be a function, got undefined.
  • A failing logger cannot stop the server. If a method throws, or returns a promise that rejects, the failure is dropped. It does not fail start() and does not skip a shutdown hook, because the server has nowhere else to report a message the logger could not take.
  • It covers only the messages above. Request logging is a separate concern, handled by the logger interceptor.

A logger whose methods take their arguments in another order, for example an object first and the message second, needs a small wrapper that maps (message, ...details) onto its own signature.

The shutdownSignal ​

Every server exposes an AbortSignal via server.shutdownSignal. The signal is aborted right after the stopping event has been emitted: inside a stopping listener it is not yet aborted, so react to it with signal.addEventListener('abort', …) rather than by reading signal.aborted there. Use it to propagate cancellation to streaming RPCs, background workers, and long-running operations. A server stream whose handler ignores the signal is cut by the framework and ends with unavailable, not with a normal end of stream (see Server streams).

typescript
server.on('ready', () => {
  startBackgroundWorker(server.shutdownSignal);
});

function startBackgroundWorker(signal: AbortSignal) {
  const interval = setInterval(() => {
    if (signal.aborted) {
      clearInterval(interval);
      return;
    }
    // Periodic work
  }, 5_000);
}

With Fetch or Timers ​

Node.js built-in APIs accept AbortSignal natively:

typescript
import { setTimeout } from 'node:timers/promises';

// Cancels if the server shuts down before the delay completes
await setTimeout(10_000, undefined, { signal: server.shutdownSignal });
typescript
// Abort an outgoing HTTP request when the server shuts down
const res = await fetch('https://api.example.com/data', {
  signal: server.shutdownSignal,
});

Integrating with Health Checks ​

The @connectum/healthcheck package exposes a healthcheckManager singleton. Update it in lifecycle events so that load balancers and Kubernetes know when the service is ready:

typescript
import { Healthcheck, healthcheckManager, ServingStatus } from '@connectum/healthcheck';

const server = createServer({
  services: [routes],
  protocols: [Healthcheck({ httpEnabled: true })],
  shutdown: { autoShutdown: true },
});

server.on('ready', () => {
  healthcheckManager.update(ServingStatus.SERVING);
});

server.on('stopping', () => {
  healthcheckManager.update(ServingStatus.NOT_SERVING);
});

server.on('error', (err) => {
  console.error('Server error:', err);
});

await server.start();

The stopping handler sets the reported status to NOT_SERVING. Probes observe the change on their next request; endpoint withdrawal also depends on the orchestrator. For the shutdown order and available drain time, see Graceful Shutdown.

Complete Example ​

Use the service definition from Quickstart and the startBackgroundWorker() implementation above.

typescript
import { createServer } from '@connectum/core';
import { Healthcheck, healthcheckManager, ServingStatus } from '@connectum/healthcheck';
import { Reflection } from '@connectum/reflection';
import { createDefaultInterceptors } from '@connectum/interceptors';
import { greeterService } from './services/greeterService.ts';

const server = createServer({
  services: [greeterService],
  port: 5000,
  protocols: [Healthcheck({ httpEnabled: true }), Reflection()],
  interceptors: createDefaultInterceptors(),
  shutdown: { autoShutdown: true, timeout: 25_000 },
});

server.on('start', () => console.log('Starting...'));
server.on('ready', () => {
  console.log(`Ready on port ${server.address?.port}`);
  healthcheckManager.update(ServingStatus.SERVING);
  startBackgroundWorker(server.shutdownSignal);
});
server.on('stopping', () => {
  healthcheckManager.update(ServingStatus.NOT_SERVING);
});
server.on('stop', () => console.log('Stopped'));
server.on('error', (err) => console.error('Error:', err));

await server.start();