@aria-framework/server
v0.14.0
Published
Aria App Framework — server module. Process lifecycle spine: graceful shutdown coordinator, the DB-worker death policy (exitCode + graceful SIGTERM), crash handlers, and an HTTPS-or-HTTP listen helper. Handlers register before the worker spawns; the serve
Readme
@aria-framework/server
Aria App Framework — the process lifecycle spine. Graceful shutdown, the DB-worker death policy, crash handlers, and an HTTPS-or-HTTP listen helper — the small, safety-critical code where a wrong move is a silent outage, now maintained once instead of forked per app.
This is deliberately not a middleware bootstrap (helmet/session/flash/csrf). That's where the apps diverge (CSP allowlists, cookie names, api-before-session ordering); it stays app-side.
createLifecycle
const { createLifecycle, workerDeathPolicy } = require('@aria-framework/server');
// At the TOP of server.js — BEFORE spawning the DB worker, because the death
// policy self-sends SIGTERM and a no-handler window would let a signal death
// read as a clean stop to systemd (no restart).
const lifecycle = createLifecycle({
logger,
teardown: [ // stop app services before draining HTTP
() => require('./lib/backup').stopScheduler(),
() => require('./lib/email-inbound').stop(),
() => require('./lib/notifHub').closeAll(), // end SSE so server.close can drain
],
onClose: async () => { // final release after drain
await dbClient.close();
encryption.close();
}
// hardDeadlineMs: 10000, installCrashHandlers: true
});
async function start() {
await encryption.init(/* ... */);
await dbClient.init(/* ... */); // its onWorkerDeath = workerDeathPolicy({ logger }) — see below
// ... app-specific boot (caches, email, schedulers) ...
const app = createApp(sessionSecret);
await lifecycle.listen(app, {
https: config.httpsEnabled ? { cert: fs.readFileSync(config.httpsCertPath), key: fs.readFileSync(config.httpsKeyPath) } : null,
port: config.port, host: config.host,
onListening: (proto) => logger.info(`running at ${proto}://${config.host}:${config.port}`)
});
}
start().catch((err) => { logger.error(`Startup failed: ${err.message}`); process.exit(1); });createLifecycle registers SIGTERM/SIGINT (and, unless
installCrashHandlers:false, unhandledRejection/uncaughtException → log +
exit 1) immediately, and owns the server reference internally — so the app
never juggles a "server assigned later" variable, and a death during boot
(before listen) still shuts down cleanly. Shutdown runs the teardown hooks
in order (each error-swallowed so one can't block), drains the HTTP server,
runs onClose, then exits with process.exitCode || 0 — honoring a
failure code the death policy pre-set, so a supervised restart happens on a
crash while a plain operator stop stays 0. A hardDeadlineMs timer
force-exits if drain/close hangs.
The drain is bounded (0.1.2), so onClose reliably runs instead of being
skipped by the deadline: idle keep-alive sockets are destroyed immediately
(closeIdleConnections(), else they hold close() open for up to
keepAliveTimeout), and anything still active at half the deadline is
destroyed too (closeAllConnections()). Cutting a straggler request at
hardDeadlineMs/2 is strictly better than force-exiting past your DB close at
hardDeadlineMs. Long-lived streams (SSE/websockets) should still be ended in
teardown — that's a clean shutdown, whereas this is the backstop.
HTTPS here is HTTP/1.1 (Express + Node's native http2 don't mix reliably). For real HTTP/2, front the app with a reverse proxy speaking h2 to the browser and HTTP/1.1 to this process.
workerDeathPolicy
// In your DbClient subclass (see @aria-framework/db-worker):
const { workerDeathPolicy } = require('@aria-framework/server');
new DbClient({ workerPath, logger, onWorkerDeath: workerDeathPolicy({ logger }) });A non-deliberate worker death means every invoke() rejects forever — a silent
outage. This turns it into a supervised restart: set process.exitCode = 1,
then route through the app's graceful shutdown via SIGTERM (so schedulers,
SSE, and the DB client all close cleanly). If no SIGTERM handler is registered
(a boot-window death, or an app that didn't call createLifecycle), it exits
directly so a bare signal death can't read as a clean stop. It never shares an
object with createLifecycle — they communicate through the signal +
process.exitCode — so it can be wired into a DbClient constructed long
before the lifecycle exists.
Windows:
process.kill(self,'SIGTERM')hard-terminates (no handler runs); the exit-code intent still holds. Production is Linux (systemd/pm2), where the graceful route runs. Pair withRestart=on-failure+StartLimitIntervalSec/StartLimitBurstso crash loops surface as a failed unit.
Flushing the logger before exit (0.2.0)
logger.error(...); process.exit(1) is only correct with a synchronous
transport. A buffering one (winston File, pino to a stream) loses the very line
that explains why the process is dying — it fails exactly when you need the log.
Pass a flush hook and every exit this module owns drains first: the crash
handlers, the hard-deadline force-exit, and the normal post-drain release.
const lifecycle = createLifecycle({
logger,
// winston has no universal flush — end the logger and wait for 'finish':
flush: () => new Promise((res) => { logger.on('finish', res); logger.end(); }),
flushMs: 2000, // upper bound; crash state is untrusted, so never unbounded
teardown: [/* ... */],
onClose: async () => { /* ... */ }
});
// Give your OWN fatal paths the same durability — fatal() is the same bounded
// exit, so a boot failure's reason reaches the log file instead of racing it:
start().catch((err) => {
logger.error(`Startup failed: ${err.message}`, { stack: err.stack });
lifecycle.fatal(1); // NOT process.exit(1)
});Omit flush and behavior is identical to 0.1.2. Worst-case stop time becomes
hardDeadlineMs + flushMs — still far inside systemd's 90s TimeoutStopSec.
The middleware baseline (0.3.0)
const { applyTransport, applySession } = require('@aria-framework/server');
applyTransport(app, { trustProxy, csp, bodyLimit, staticDirs, rateLimiter },
{ express, helmet, cookieParser });
app.use('/api/v1', apiRouter); // optional: stateless, session-free
const { csrfUtil } = applySession(app,
{ sessionStore, secret, cookieName, secure, flash, csrf, viewEngine },
{ session, doubleCsrf });TWO PHASES, and that is the design. One consumer mounts a stateless Bearer-JWT
API between them, so it gets no session cookie and the global CSRF guard never
touches it. A single applyBaseline(app) would force that API into the session stack
or make the app opt out of half the baseline. An app with no such API calls the two
back to back.
Dependencies stay at ZERO. Callers pass their own express/helmet/session
instances, so the package can never introduce a second copy of express-session that
silently fails to share state.
flash.skipWhen keeps a background request from eating the message. Flash is
read-and-clear, which is right for a navigation and wrong for anything else. A page that
refetches itself — a live-updating detail view, a poll — sends the session cookie and
renders nothing the user sees; consuming there deletes the message while the navigation
that would have shown it is still in flight, so a confirmation for the user's own action
intermittently never appears. Only the app knows which of its requests are page views, so
it says:
applySession(app, {
…,
flash: { skipWhen: (req) => req.get('X-Requested-With') === 'fetch' }
}, { session });Skipped requests still get a working res.flash and an empty res.locals.flash; the
message stays in the session for the next real page load. flash: false still disables
the feature outright, and flash: {} (or omitting it) behaves exactly as before.
csp MERGES over the defaults, never replaces. A replace-style option is exactly
how a directive goes missing — one consumer was missing objectSrc, baseUri and
frameAncestors for months because each app hand-maintained its own policy. An app
adding one origin keeps every hardening it did not mention. csp: false disables.
csrf.util — bring your own instance. An app whose route modules re-apply the
guard after their own body parser must share ONE doubleCsrf instance with the
global gate. Two built from the same secret and options are interchangeable today
(the tokens are stateless HMACs), but that is a coincidence, not a contract — change
the cookie name in one place and the other silently diverges. Pass csrf: { util }
and the package applies your instance instead of building one; { doubleCsrf } is
then not required in the deps.
csrf.skipWhen(req) must be PATH-SCOPED. One consumer exempts known upload routes
at the global layer and re-applies the guard after multer — hence the returned
csrfUtil. A bare content-type test ("skip multipart") would let an attacker POST
enctype=multipart/form-data to any route and bypass CSRF entirely. Default is
() => false, so doing nothing means global protection.
Order is a security property, not a style choice: helmet before anything can respond, body parsing before CSRF (the token arrives in the body), CSRF after session (it keys on the session identifier). The test suite asserts the order explicitly.
What stays in the app: the rate limiters themselves (limits are policy), how secure
is derived, the session sweep interval, and everything after the baseline.
Changelog
0.3.2 — three fixes from an xhigh review of 0.3.0/0.3.1, all found by reading the module against its own comments rather than by a failing test.
helmetOptionscould silently override the CSP. It is the last argument toObject.assign, so a consumer passinghelmetOptions.contentSecurityPolicywould replace the merged directives wholesale — dropping theobject-src/base-uri/frame-ancestorshardening this module exists to hold, or quietly defeatingcsp: false. Now throws: there is one way to configure CSP, and it iscsp. The guard is narrow — unrelated helmet options still pass through.__Host-prefix and cookie attributes are now cross-validated. The prefix was chosen fromopts.securewhilecsrf.cookieOptionscould be overridden independently. Adomain, a non-/path, orsecure: falsealongside the prefix makes the browser silently refuse to store the cookie, disabling CSRF on every state-changing request with no error raised anywhere in this stack. That contradiction now fails at boot instead of in production.peerDependenciesactually declared. The header comment promised "peer dependencies, not dependencies", but the manifest listed express / helmet / cookie-parser / express-session / csrf-csrf only underdevDependencies— so npm had no way to warn a consumer about an incompatible version, and the sibling convention (db-workerdeclaresbetter-sqlite3as a peer) was not followed. All five are declared optional: the lifecycle half of this package (createLifecycle, its original 0.1.x purpose) needs none of them, so the range exists to catch a version mismatch, not to force an install.
Known and deliberately NOT changed here:
applySessionhardcodessaveUninitialized: falsewhile the CSRF token HMAC binds toreq.session.id. A GET that never touches the session emits no session cookie, so the following POST mints a new id and the form token fails to validate. Both consumers already work around this per-route (req.session.csrfBootstrap = 1before rendering an anonymous form) — which is exactly the repeated hand-wiring this package exists to remove. But fixing it centrally changes session-issuance behaviour and wants its own version.0.3.1 —
csrf.utillets a consumer supply its owndoubleCsrfinstance rather than have one built. Surfaced by the second consumer: Support101 keeps its instance inlib/csrf.jssoroutes/public.jsandroutes/tickets.jscan re-apply the same guard after multer, and a package-built second instance would have been a latent divergence in a security primitive.0.3.0 — adds the Express middleware baseline (
applyTransport/applySession), ~120-140 lines of near-identical wiring per consumer. Additive:createLifecycleandworkerDeathPolicyare unchanged, and the package still has zero runtime dependencies. This was previously deferred because the two apps diverged on CSP, cookie names and api-before-session; all three were resolved rather than worked around — see index.js.0.2.0 — flush before exit. New
flush/flushMsoptions and a returnedfatal(code). All four exits this module owns (two crash handlers, the hard-deadline force-exit, the post-drain release) now drain the logger first, bounded byflushMsso a wedged transport can't stop the exit;fatal()extends the same guarantee to app-side paths likestart().catch. Backward compatible — withoutflush, behavior is byte-identical to 0.1.2. Found because BOTH consumers log to a winston File transport and both hadlog(); process.exit()on their startup-failure path, silently truncating the reason. Apps that setinstallCrashHandlers:falsepurely to get their own flush-then-exit can now drop that override.0.1.2 — the drain is now bounded, so a hung request can no longer cost you
onClose.server.close()waits for existing connections, including ones sitting idle in a keep-alive pool, so a drain could outlivehardDeadlineMsand force-exit before the DB/encryption release ever ran. Shutdown now callscloseIdleConnections()immediately andcloseAllConnections()at half the deadline (both Node >=18.2, feature-detected — older runtimes keep the previous wait-only behavior). Found while adopting 0.1.1 in a second app, whose hand-rolled shutdown had deliberately not gated its resource release on the drain callback for exactly this reason.0.1.1 — review fixes. (1) The force-exit hard-deadline is now armed at the TOP of shutdown (before teardown/onClose run), so a hanging async teardown hook can't prevent the safety net from arming. (2)
listen()builds the server explicitly and attaches anerrorlistener before binding, so a bind failure (EADDRINUSE/EACCES) REJECTS the returned promise instead of crashing the process — callers can catch it. (3) Documented that the crash handlers hard-exit WITHOUT graceful teardown by design (corrupt state → fast restart).0.1.0 — first release. The lifecycle spine extracted from Support101/ Acc101 (identical after both converged on the hardened shutdown):
createLifecycle(shutdown coordinator + listen helper + crash handlers) andworkerDeathPolicy(the DbClient onWorkerDeath hook). The middleware baseline is intentionally out of scope.
Diagnostics (0.9.0)
versionRoute answers "which build is this?" in one string. createDiagnostics() answers the
same question with the detail you need when that string turns out to be a lie:
const diag = server.createDiagnostics({
root: __dirname, // where package.json and node_modules live
native: ['better-sqlite3', 'keytar'], // the packages that differ per host
env: ['NODE_ENV', 'KEYSTORE_FILE_ONLY'], // NAMES only — reported as set / not set
probes: [ // the app's half; the package cannot know these
{ id: 'backup', label: 'Backup & restore', check: async () => ({ ok: true, detail: '2h ago' }) }
]
});
const snapshot = await diag.collect();<%- include('ui/diagnostics', { d: snapshot }) %>DECLARED versus RESOLVED is the point. An alias installs a different package under the
requested name — one consumer runs a SQLCipher fork as better-sqlite3 — so "better-sqlite3
13.0.3" can be a true statement about the wrong package. Both names appear. A row is flagged
only where the two certainly disagree: a wrong badge is worse than no badge, because it sends
someone to investigate a non-problem and the next real one gets ignored.
Environment variables report names and set/not-set by default, never values — a debug page is exactly where a session secret leaks to whoever holds one permission, a lower bar than a credential store which is separately gated and audited.
A variable can opt IN, one at a time: { name: 'NODE_ENV', show: true } renders its value.
That exists because "set" answers a question next to the one that matters for a flag —
NODE_ENV=staging satisfies it as well as production, and CREDENTIALS_DB_ENCRYPTED=false
as well as true, while those decide whether cookies are secure and whether a credential store
is encrypted at all. Marking is a decision the app makes about a variable it named itself;
there is deliberately no heuristic guessing which values look secret, because a heuristic that
is wrong once has published a credential.
A probe that throws is a red row carrying its message, never a 500, and never stops the probes after it. The page exists to be readable when something is broken.
Package resolution reads each package.json directly rather than require.resolve(), which
resolves an entry point and so reports a package with no main as missing.
