Skip to content

Repository files navigation

eliware.org

@eliware/signals npm versionlicensebuild status

Graceful shutdown signal handler utility for Node.js


Table of Contents

Features

  • Register handlers for process signals (e.g., SIGTERM, SIGINT, SIGHUP)
  • Register async shutdown hooks to run on signals, explicit shutdown, or beforeExit
  • Customizable logger and process object
  • Idempotent registration with repeat-safe listener cleanup
  • AbortSignal support for lifecycle-managed applications
  • Concurrent shutdown protection and per-hook error isolation
  • Configurable exit code and optional non-exiting mode
  • Simple ESM API
  • TypeScript type definitions included
  • Well-tested with the shared @eliware/test harness

Requirements

  • Node.js 26 or newer (engines.node: >=26)
  • A process-like application lifecycle that can receive shutdown signals

Installation

npm install @eliware/signals

Usage

ESM Example

import log from '@eliware/log';
import registerSignals from '@eliware/signals';

const { shutdown, getShuttingDown, removeHandlers } = registerSignals({
  log,
  exit: false,
});

console.log(`Shutdown handlers ready: ${getShuttingDown()}`);
await shutdown('manual');
removeHandlers();

Shutdown Hooks Example

import log from '@eliware/log';
import registerSignals from '@eliware/signals';

You can call registerSignals multiple times to add async shutdown hooks. All hooks run in registration order when a signal is received, shutdown() is called, or Node emits beforeExit. Repeated registrations must use the same lifecycle options (log, signals, exitCode, exit, and signal); conflicting options throw TypeError.

// Simulate a resource that needs cleanup (e.g., database connection)
const fakeDb = {
  close: async () => {
    return new Promise(resolve => setTimeout(() => {
      log.info('Fake DB connection closed');
      resolve();
    }, 100));
  }
};

// Register signal handlers
registerSignals({ log });

// Add shutdown hook for closing the fake DB connection
registerSignals({
  log,
  shutdownHook: async (signal) => {
    await fakeDb.close();
    log.info(`Cleanup complete on ${signal}`);
  }
});

API

registerSignals(options?)

Registers shutdown handlers for the specified signals and allows registering async shutdown hooks.

Options

  • processObj (default: process): Process-like object to attach handlers to; must provide on, with optional off and exit.
  • log (default: @eliware/log): Logger for output. Must have debug, warn, and error methods; invalid loggers throw TypeError. Custom loggers are responsible for their own error serialization/redaction.
  • signals (default: [ 'SIGTERM', 'SIGINT', 'SIGHUP' ]): Array of signals to listen for.
  • shutdownHook (optional): A sync or async function to run during shutdown. Multiple registrations add hooks in order.
  • exitCode (default: 0): Finite integer exit code used after signal-driven shutdown.
  • exit (default: true): Boolean. Set to false for embedded applications and tests that should not call process.exit.
  • signal (optional): An AbortSignal that removes all registered listeners when aborted.

Returns

An object with:

  • shutdown(signal: string): Promise<void> — Manually trigger shutdown logic.
  • getShuttingDown(): boolean — Returns whether shutdown is in progress.
  • removeHandlers(): void — Detaches registered listeners; safe to call repeatedly.
  • removed: boolean — Indicates whether cleanup has completed.

Invalid options throw TypeError before listeners are registered.

**Shutdown hooks run on signals, explicit shutdown(), or beforeExit. They are intentionally not run from Node’s exit event because asynchronous cleanup cannot complete reliably there.

When an injected processObj does not provide off, removeHandlers() remains safe and marks the registration removed, but cannot detach listeners from that object. The beforeExit listener returns the hook promise for integrations that explicitly await it; Node itself does not await event-listener return values, so asynchronous cleanup must keep its work scheduled before exit.

Operations

Signal handlers are installed for SIGTERM, SIGINT, and SIGHUP by default. Use signals: [] to install only the beforeExit lifecycle hook, or provide a custom signal list. Signal names are deduplicated while preserving order.

The first registration for a process-like object owns its lifecycle options. Later registrations add hooks to that same lifecycle and return the same API. Call removeHandlers() when the registration is no longer needed; it is safe to call repeatedly. An attached AbortSignal performs the same cleanup when it aborts.

When processObj.off is unavailable, cleanup remains safe and marks the registration removed, but cannot detach listeners from that object. Prefer an object implementing on, off, and exit for complete lifecycle control.

TypeScript

Type definitions are included:

import registerSignals, { RegisterSignalsOptions } from '@eliware/signals';

// Optionally provide options
const options: RegisterSignalsOptions = {
  processObj: process, // optional, defaults to process
  log: myLogger,       // optional; must provide debug, warn, and error
  signals: ['SIGTERM', 'SIGINT', 'SIGHUP'], // optional, defaults as shown
  shutdownHook: async (signal) => { /* ... */ } // optional
};

const { shutdown, getShuttingDown, removeHandlers, removed } = registerSignals(options);

// Types:
// interface RegisterSignalsOptions {
//   processObj?: ProcessLike;
//   log?: SignalsLogger;
//   signals?: NodeSignal[];
//   shutdownHook?: (signal: string) => void | Promise<void>;
//   exitCode?: number;
//   exit?: boolean;
//   signal?: AbortSignal;
// }
//
// function registerSignals(options?: RegisterSignalsOptions): SignalsRegistration;

Errors / Troubleshooting

Shutdown hooks run in registration order, and a failing hook is logged without preventing later hooks from running. Use exit: false for embedded applications and tests. Prefer explicit shutdown() when the caller must await cleanup. Always call removeHandlers() when a registration is no longer needed.

Development

npm test
npm run lint
npm run typecheck
npm audit --omit=dev --audit-level=moderate
npm run pack

npm test and npm run lint use the shared @eliware/test harness. The baseline test command reports and fails on any statement, branch, function, or line coverage gap in the in-scope production logic.

Examples are safe to inspect and should be run only in a controlled process when testing signal behavior.

Security

Set LOG_LEVEL=debug to see diagnostic messages from the default logger. Do not log secrets or sensitive shutdown context. Keep cleanup hooks bounded and avoid relying on asynchronous work after the process has entered the exit event.

Support

For help, questions, or to chat with the author and community, visit:

Discordeliware.org

eliware.org on Discord

License

MIT © 2025 Eli Sterling, eliware.org

Links

Releases

Contributors

Languages