roster-server 2.4.13 → 2.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -149,7 +149,18 @@ Register `virtualServer.onClose(fn)` in a factory for timers, databases, queues,
149
149
 
150
150
  `closeTimeoutMs` (default `30000`) bounds the whole operation. At the deadline Roster destroys remaining owned connections and active routed responses, attempts cleanup hooks, and rejects with a timeout. It cannot forcibly interrupt an application Promise or an ACME operation already in progress.
151
151
 
152
- Closing is **idempotent and terminal**: repeated calls return the same Promise. Create a new instance after closure or failed initialization/startup. Roster does not install process signal handlers; connect shutdown to the application's lifecycle:
152
+ Closing is **idempotent and terminal**: repeated calls return the same Promise. Create a new instance after closure or failed initialization/startup.
153
+
154
+ For automatic shutdown on `SIGINT` or `SIGTERM`, explicitly enable `handleSignals`:
155
+
156
+ ```javascript
157
+ const roster = new Roster({ handleSignals: true });
158
+ await roster.start();
159
+ ```
160
+
161
+ The option defaults to `false`. Listeners are installed once when `init()` or `start()` begins and removed when closure completes, including failed startup or cleanup. Signals call `roster.close()`; repeated signals during closure do not repeat cleanup. Existing application signal listeners remain installed. Shutdown failures are logged and set `process.exitCode = 1`.
162
+
163
+ Roster does not force process termination or close caller-owned servers. The process exits naturally when its remaining work finishes; other resources or cluster workers still require application coordination. If the application already coordinates shutdown, leave `handleSignals` disabled and connect Roster to that flow:
153
164
 
154
165
  ```javascript
155
166
  async function shutdown() {
@@ -233,6 +244,7 @@ Pass operational settings through the constructor, using the consuming applicati
233
244
  | `autoCertificates` | `true` | Certificate runtime during `init()`; disable for serving-only workers. |
234
245
  | `certificateRenewIntervalMs` | `43200000` (12h) | Roster renewal check interval; minimum `60000`. |
235
246
  | `closeTimeoutMs` | `30000` | Positive finite total shutdown deadline. |
247
+ | `handleSignals` | `false` | Close this instance on `SIGINT`/`SIGTERM`; does not force process exit. |
236
248
  | `tlsMinVersion`, `tlsMaxVersion` | `TLSv1.2`, `TLSv1.3` | Protocol limits for created HTTPS servers. |
237
249
  | `skipLocalCheck` | `true` | Skips Greenlock dry-run/local challenge checks. |
238
250
  | `dnsChallenge` | CLI wrapper | DNS-01 options or `false`; see [certificates](#certificates-and-dns). |
package/index.js CHANGED
@@ -9,6 +9,7 @@ const Greenlock = require('./vendor/greenlock-express/greenlock-express.js');
9
9
  const GreenlockShim = require('./vendor/greenlock-express/greenlock-shim.js');
10
10
  const { resolveSiteApp } = require('./lib/resolve-site-app.js');
11
11
  const log = require('lemonlog')('roster');
12
+ const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'];
12
13
 
13
14
  function requestError(error, res) {
14
15
  log.error('Request handler failed:', error?.message || error);
@@ -287,6 +288,8 @@ class Roster {
287
288
  this._initPromise = null;
288
289
  this._startPromise = null;
289
290
  this._closePromise = null;
291
+ this.handleSignals = parseBooleanFlag(options.handleSignals, false);
292
+ this._signalHandler = null;
290
293
  this._ownedServers = new Set();
291
294
  this._sockets = new Set();
292
295
  this._upgradedSockets = new Set();
@@ -1097,6 +1100,16 @@ class Roster {
1097
1100
  init() {
1098
1101
  if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
1099
1102
  if (this._initPromise) return this._initPromise;
1103
+ if (this.handleSignals) {
1104
+ this._signalHandler = () => {
1105
+ if (this._closing) return;
1106
+ this.close().catch(error => {
1107
+ log.error('Signal shutdown failed:', error.message);
1108
+ process.exitCode = 1;
1109
+ });
1110
+ };
1111
+ for (const signal of SHUTDOWN_SIGNALS) process.on(signal, this._signalHandler);
1112
+ }
1100
1113
  this._initTask = this._initialize();
1101
1114
  this._initPromise = this._initTask.catch(async error => {
1102
1115
  if (!this._closing) {
@@ -1260,6 +1273,10 @@ class Roster {
1260
1273
  }));
1261
1274
  } finally {
1262
1275
  clearTimeout(timer);
1276
+ if (this._signalHandler) {
1277
+ for (const signal of SHUTDOWN_SIGNALS) process.removeListener(signal, this._signalHandler);
1278
+ this._signalHandler = null;
1279
+ }
1263
1280
  for (const [server, handlers] of this._attachments) {
1264
1281
  server.removeListener('request', handlers.request);
1265
1282
  server.removeListener('upgrade', handlers.upgrade);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "2.4.13",
3
+ "version": "2.4.14",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -56,7 +56,7 @@ A virtual request listener owns its request even before it ends the response. Ro
56
56
 
57
57
  ## Shutdown
58
58
 
59
- - Connect `await roster.close()` to the application's existing shutdown flow. Roster does not install signal handlers or terminate processes.
59
+ - Connect `await roster.close()` to the application's existing shutdown flow, or explicitly set `handleSignals: true` for automatic `SIGINT`/`SIGTERM` cleanup. It defaults to `false`; listeners are installed during `init()`/`start()` and removed after closure, including failure. Repeated signals during closure do not repeat hooks. Existing signal listeners are preserved; shutdown errors are logged and set `process.exitCode = 1`. Roster never forces process termination.
60
60
  - Register `virtualServer.onClose(fn)` for each site's timers, databases, and other resources. Return/await its cleanup Promise; wrap callback APIs when needed.
61
61
  - Roster signals virtual `close` at shutdown start to release integrations such as long polling, closes routed WebSockets, drains HTTP, then runs cleanup hooks concurrently. Put dependent cleanup steps in one hook.
62
62
  - Hooks run once. Failures are aggregated after other hooks are attempted. `closeTimeoutMs` bounds the complete operation; timeout cannot cancel arbitrary user Promises or in-flight ACME work.
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ const { it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { spawn } = require('node:child_process');
6
+ const { once } = require('node:events');
7
+
8
+ async function runChild(t, body, onMessage = () => {}) {
9
+ const script = `
10
+ const assert = require('node:assert/strict');
11
+ const http = require('node:http');
12
+ const Roster = require(${JSON.stringify(require.resolve('../index.js'))});
13
+ const options = { local: true, wwwPath: '/tmp/roster-signals-' + process.pid };
14
+ const signals = ['SIGINT', 'SIGTERM'];
15
+ (async () => { ${body} })().catch(error => {
16
+ console.error(error);
17
+ process.exitCode = 1;
18
+ }).finally(() => process.disconnect());
19
+ `;
20
+ const child = spawn(process.execPath, ['-e', script], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] });
21
+ const messages = [];
22
+ let stderr = '';
23
+ child.stderr.on('data', chunk => { stderr += chunk; });
24
+ child.on('message', message => {
25
+ messages.push(message);
26
+ onMessage(message, child);
27
+ });
28
+ t.after(() => {
29
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
30
+ });
31
+ const [code, signal] = await once(child, 'close');
32
+ return { code, signal, messages, stderr };
33
+ }
34
+
35
+ function assertSuccess(result) {
36
+ assert.equal(result.signal, null, result.stderr);
37
+ assert.equal(result.code, 0, result.stderr);
38
+ }
39
+
40
+ it('does not install signal listeners unless explicitly enabled', { timeout: 10000 }, async t => {
41
+ assertSuccess(await runChild(t, `
42
+ const before = signals.map(signal => process.listeners(signal));
43
+ for (const handleSignals of [undefined, false]) {
44
+ const roster = new Roster({ ...options, handleSignals });
45
+ await roster.init();
46
+ signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
47
+ await roster.close();
48
+ signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
49
+ }
50
+ `));
51
+ });
52
+
53
+ it('installs listeners once at initialization and removes only the closing instance listeners', { timeout: 10000 }, async t => {
54
+ assertSuccess(await runChild(t, `
55
+ const existing = () => {};
56
+ signals.forEach(signal => process.on(signal, existing));
57
+ const before = signals.map(signal => process.listeners(signal));
58
+ const first = new Roster({ ...options, handleSignals: true });
59
+ const second = new Roster({ ...options, handleSignals: true });
60
+ signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
61
+ await Promise.all([first.init(), first.init(), second.init()]);
62
+ signals.forEach((signal, i) => assert.equal(process.listenerCount(signal), before[i].length + 2));
63
+ await Promise.all([first.close(), first.close()]);
64
+ signals.forEach((signal, i) => {
65
+ assert.equal(process.listenerCount(signal), before[i].length + 1);
66
+ assert.ok(process.listeners(signal).includes(existing));
67
+ });
68
+ await second.close();
69
+ signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
70
+ `));
71
+ });
72
+
73
+ it('removes signal listeners after initialization and cleanup both fail', { timeout: 10000 }, async t => {
74
+ assertSuccess(await runChild(t, `
75
+ const before = signals.map(signal => process.listeners(signal));
76
+ const roster = new Roster({ ...options, handleSignals: true });
77
+ roster.register('example.com', virtual => {
78
+ virtual.onClose(() => { throw new Error('cleanup failed'); });
79
+ throw new Error('factory failed');
80
+ });
81
+ await assert.rejects(roster.init(), AggregateError);
82
+ signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
83
+ `));
84
+ });
85
+
86
+ for (const signal of ['SIGINT', 'SIGTERM']) {
87
+ it(`drains HTTP and runs hooks once on real ${signal}, including repeated signals`, { timeout: 10000 }, async t => {
88
+ const result = await runChild(t, `
89
+ const roster = new Roster({ ...options, handleSignals: true });
90
+ roster.assignPortToDomain = () => 0;
91
+ let cleaned = 0;
92
+ let finished = false;
93
+ let response;
94
+ const repeated = new Promise(resolve => process.once(${JSON.stringify(signal === 'SIGINT' ? 'SIGTERM' : 'SIGINT')}, resolve));
95
+ roster.register('example.com', virtual => {
96
+ virtual.on('close', () => {
97
+ process.send('closing');
98
+ setImmediate(() => { finished = true; response.end(' drained'); });
99
+ });
100
+ virtual.onClose(async () => {
101
+ assert.equal(finished, true);
102
+ await repeated;
103
+ cleaned++;
104
+ });
105
+ return (req, res) => {
106
+ response = res;
107
+ res.write('active');
108
+ };
109
+ });
110
+ await Promise.all([roster.start(), roster.start()]);
111
+ const server = roster.portServers[0];
112
+ const body = await new Promise((resolve, reject) => {
113
+ http.get({ hostname: 'localhost', port: server.address().port, agent: false }, res => {
114
+ let body = '';
115
+ res.once('data', () => process.send('ready'));
116
+ res.on('data', chunk => { body += chunk; });
117
+ res.on('end', () => resolve(body));
118
+ res.on('error', reject);
119
+ }).on('error', reject);
120
+ });
121
+ await roster.close();
122
+ assert.equal(body, 'active drained');
123
+ assert.equal(cleaned, 1);
124
+ assert.equal(server.listening, false);
125
+ signals.forEach(signal => assert.equal(process.listenerCount(signal), 0));
126
+ process.send('complete');
127
+ `, (message, child) => {
128
+ if (message === 'ready') child.kill(signal);
129
+ if (message === 'closing') child.kill(signal === 'SIGINT' ? 'SIGTERM' : 'SIGINT');
130
+ });
131
+ assertSuccess(result);
132
+ assert.deepEqual(result.messages, ['ready', 'closing', 'complete']);
133
+ });
134
+ }
135
+
136
+ it('reports signal-triggered cleanup failure with exit code 1 and removes listeners', { timeout: 10000 }, async t => {
137
+ const result = await runChild(t, `
138
+ const roster = new Roster({ ...options, handleSignals: true });
139
+ roster.assignPortToDomain = () => 0;
140
+ let entered;
141
+ const closing = new Promise(resolve => { entered = resolve; });
142
+ roster.register('example.com', virtual => {
143
+ virtual.on('close', entered);
144
+ virtual.onClose(() => { throw new Error('cleanup failed'); });
145
+ return (req, res) => res.end();
146
+ });
147
+ await roster.start();
148
+ process.send('ready');
149
+ await closing;
150
+ await assert.rejects(roster.close(), AggregateError);
151
+ signals.forEach(signal => assert.equal(process.listenerCount(signal), 0));
152
+ assert.equal(process.exitCode, 1);
153
+ process.send('complete');
154
+ `, (message, child) => { if (message === 'ready') child.kill('SIGTERM'); });
155
+ assert.equal(result.signal, null, result.stderr);
156
+ assert.equal(result.code, 1, result.stderr);
157
+ assert.deepEqual(result.messages, ['ready', 'complete']);
158
+ });
159
+
160
+ it('handles signals during initialization and cleans up its listeners', { timeout: 10000 }, async t => {
161
+ const result = await runChild(t, `
162
+ const roster = new Roster({ ...options, handleSignals: true });
163
+ let release;
164
+ roster.loadSites = () => new Promise(resolve => { release = resolve; });
165
+ let called = false;
166
+ roster.register('example.com', () => { called = true; });
167
+ const initializing = assert.rejects(roster.init(), /closing or closed/);
168
+ process.once('message', () => release());
169
+ const close = roster.close.bind(roster);
170
+ roster.close = () => { const result = close(); process.send('closing'); return result; };
171
+ process.send('ready');
172
+ await initializing;
173
+ await close();
174
+ assert.equal(called, false);
175
+ signals.forEach(signal => assert.equal(process.listenerCount(signal), 0));
176
+ process.send('complete');
177
+ `, (message, child) => {
178
+ if (message === 'ready') child.kill('SIGTERM');
179
+ if (message === 'closing') child.send('release');
180
+ });
181
+ assertSuccess(result);
182
+ assert.deepEqual(result.messages, ['ready', 'closing', 'complete']);
183
+ });