@hardlydifficult/daemon
v1.0.71
Published
A utility library for building long-running Node.js processes with graceful shutdown and continuous background task execution.
Readme
@hardlydifficult/daemon
A utility library for building long-running Node.js processes with graceful shutdown and continuous background task execution.
Installation
npm install @hardlydifficult/daemonQuick Start
import { runContinuousLoop, createTeardown } from "@hardlydifficult/daemon";
// Setup graceful shutdown
const teardown = createTeardown();
teardown.add(() => console.log("Shutting down..."));
teardown.trapSignals();
// Run a background task every 5 seconds
await runContinuousLoop({
intervalSeconds: 5,
runCycle: async (isShutdownRequested) => {
console.log("Running cycle...");
if (isShutdownRequested()) return { stop: true };
return { nextDelayMs: "immediate" };
},
onShutdown: async () => {
await teardown.run();
}
});Graceful Shutdown with createTeardown
Manages resource cleanup with LIFO execution order, signal trapping for SIGINT/SIGTERM, and idempotent teardown behavior.
Core API
createTeardown(): Teardown
Creates a teardown manager for registering cleanup functions.
const teardown = createTeardown();
// Add cleanup functions
teardown.add(() => server.close());
teardown.add(() => db.close());
// Or use async functions
teardown.add(async () => {
await flushPendingWrites();
});Teardown.add(fn): () => void
Registers a cleanup function. Returns an unregister function for removing it.
const unregister = teardown.add(() => cleanup());
// Later, remove it
unregister();Teardown.run(): Promise<void>
Runs all cleanup functions in LIFO order. Safe to call multiple times—subsequent calls are no-ops.
await teardown.run(); // Runs last-in-first-outTeardown.trapSignals(): () => void
Wires SIGINT/SIGTERM to automatically call run() then process.exit(0).
const untrap = teardown.trapSignals();
// Later, restore default behavior
untrap();Behavior Notes
- Errors in teardown functions are caught and logged, allowing remaining functions to complete.
- Signal handlers are added only once per process and cleaned up automatically when
untrap()is called.
Example: Basic Teardown
import { createTeardown } from "@hardlydifficult/daemon";
const teardown = createTeardown();
teardown.add(() => console.log("Closing database"));
teardown.add(() => console.log("Stopping server"));
teardown.trapSignals();
await teardown.run();
// Output:
// Stopping server
// Closing databaseUnregistering Teardown Functions
Teardown functions can be unregistered before run() is called.
const teardown = createTeardown();
const unregister = teardown.add(() => cleanupA());
teardown.add(() => cleanupB());
unregister(); // Removes cleanupA from the teardown list
await teardown.run(); // Only cleanupB runsContinuous Loop with runContinuousLoop
Runs a recurring task in a loop with built-in signal handling, dynamic delays, and configurable error policies.
Core Options
| Option | Type | Description |
|--------|------|-------------|
| intervalSeconds | number | Default delay between cycles in seconds |
| runCycle | (isShutdownRequested: () => boolean) => Promise<RunCycleResult> | Main function executed per cycle |
| getNextDelayMs? | (result, context) => ContinuousLoopDelay \| undefined | Optional custom delay resolver |
| onCycleError? | ContinuousLoopErrorHandler | Custom error handling strategy |
| onShutdown? | () => void \| Promise<void> | Cleanup hook called on shutdown |
| logger? | ContinuousLoopLogger | Optional logger (defaults to console) |
Return Values from runCycle
You can control loop behavior by returning one of:
- Delay value:
number(ms) or"immediate"to skip delay - Control object:
{ stop?: boolean; nextDelayMs?: Delay }
await runContinuousLoop({
intervalSeconds: 10,
runCycle: async () => {
// Skip delay after this cycle
return "immediate";
}
});Example: Backoff Loop
import { runContinuousLoop } from "@hardlydifficult/daemon";
type Result = { backoffMs: number } | { success: true };
await runContinuousLoop({
intervalSeconds: 60,
runCycle: async () => {
const success = await attemptTask();
return success
? { success: true }
: { backoffMs: Math.min(60_000, Math.random() * 5_000) };
},
getNextDelayMs: (result) =>
"backoffMs" in result ? result.backoffMs : undefined,
onShutdown: () => console.log("Stopping loop"),
});Error Handling
By default, cycle errors are logged to console and the loop continues. Custom error handling:
await runContinuousLoop({
intervalSeconds: 5,
runCycle: async () => { throw new Error("fail"); },
onCycleError: async (error, context) => {
console.error(`Cycle ${context.cycleNumber} failed: ${error.message}`);
return "stop"; // or "continue"
}
});Dynamic Delays
Use getNextDelayMs to derive delays from domain results:
type CycleResult = { delaySeconds: number } | { stop: true };
await runContinuousLoop({
intervalSeconds: 60, // default fallback
async runCycle() {
const result = await fetchStatus();
if (result.stopped) return { stop: true };
return { delaySeconds: result.delay };
},
getNextDelayMs(result) {
return "delaySeconds" in result ? result.delaySeconds * 1000 : undefined;
}
});Shutdown Signals
The loop responds to SIGINT and SIGTERM by stopping after the current cycle completes. Use isShutdownRequested() inside runCycle to abort long-running work:
await runContinuousLoop({
intervalSeconds: 1,
runCycle: async (isShutdownRequested) => {
while (!isShutdownRequested()) {
await processChunk();
}
return { stop: true };
}
});