roster-server 2.4.12 → 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/index.js CHANGED
@@ -9,6 +9,30 @@ 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'];
13
+
14
+ function requestError(error, res) {
15
+ log.error('Request handler failed:', error?.message || error);
16
+ if (res.destroyed || res.writableEnded) return;
17
+ if (res.headersSent) {
18
+ res.destroy(error);
19
+ return;
20
+ }
21
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
22
+ res.end('Internal Server Error');
23
+ }
24
+
25
+ function invokeRequest(handler, req, res) {
26
+ try {
27
+ const result = handler(req, res);
28
+ if (result && typeof result.then === 'function') {
29
+ return Promise.resolve(result).catch(error => requestError(error, res));
30
+ }
31
+ return result;
32
+ } catch (error) {
33
+ requestError(error, res);
34
+ }
35
+ }
12
36
 
13
37
  const isBunRuntime = typeof Bun !== 'undefined' || (typeof process !== 'undefined' && process.release?.name === 'bun');
14
38
 
@@ -129,6 +153,8 @@ class VirtualServer extends EventEmitter {
129
153
  this.domain = domain;
130
154
  this.requestListeners = [];
131
155
  this.upgradeListeners = [];
156
+ this._closeHooks = [];
157
+ this._closed = false;
132
158
 
133
159
  // Simulate http.Server properties
134
160
  this.listening = false;
@@ -156,6 +182,7 @@ class VirtualServer extends EventEmitter {
156
182
  // Socket.IO compatibility methods
157
183
  listeners(event) {
158
184
  if (event === 'request') {
185
+ if (this.requestListeners.length === 0 && this.fallbackHandler) return [this.fallbackHandler];
159
186
  return this.requestListeners.slice();
160
187
  } else if (event === 'upgrade') {
161
188
  return this.upgradeListeners.slice();
@@ -179,9 +206,10 @@ class VirtualServer extends EventEmitter {
179
206
  }
180
207
 
181
208
  removeAllListeners(event) {
182
- if (event === 'request') {
209
+ if (event === undefined || event === 'request') {
183
210
  this.requestListeners = [];
184
- } else if (event === 'upgrade') {
211
+ }
212
+ if (event === undefined || event === 'upgrade') {
185
213
  this.upgradeListeners = [];
186
214
  }
187
215
  return super.removeAllListeners(event);
@@ -189,34 +217,34 @@ class VirtualServer extends EventEmitter {
189
217
 
190
218
  // Simulate other http.Server methods
191
219
  listen() { this.listening = true; return this; }
192
- close() { this.listening = false; return this; }
220
+ close(callback) {
221
+ this.listening = false;
222
+ if (!this._closed) {
223
+ this._closed = true;
224
+ this.emit('close');
225
+ }
226
+ if (callback) process.nextTick(callback);
227
+ return this;
228
+ }
229
+ onClose(handler) {
230
+ if (typeof handler !== 'function') throw new TypeError('Close hook must be a function');
231
+ if (this._closed) throw new Error('Virtual server is closed');
232
+ this._closeHooks.push(handler);
233
+ return this;
234
+ }
193
235
  setTimeout() { return this; }
194
236
 
195
237
  // Process request with this virtual server's listeners
196
238
  processRequest(req, res) {
197
- let handled = false;
198
-
199
- // Track if response was handled
200
- const originalEnd = res.end;
201
- res.end = function (...args) {
202
- handled = true;
203
- return originalEnd.apply(this, args);
204
- };
205
-
206
- // Try all listeners
207
- for (const listener of this.requestListeners) {
208
- if (!handled) {
209
- listener(req, res);
239
+ const listeners = this.requestListeners.slice();
240
+ if (listeners.length > 0) {
241
+ for (const listener of listeners) {
242
+ if (res.writableEnded || res.destroyed) break;
243
+ invokeRequest(listener.bind(this), req, res);
210
244
  }
211
- }
212
-
213
- // Restore original end method
214
- res.end = originalEnd;
215
-
216
- // If no listener handled the request, try fallback handler
217
- if (!handled && this.fallbackHandler) {
218
- this.fallbackHandler(req, res);
219
- } else if (!handled) {
245
+ } else if (this.fallbackHandler) {
246
+ return invokeRequest(this.fallbackHandler, req, res);
247
+ } else {
220
248
  res.writeHead(404);
221
249
  res.end('No handler found');
222
250
  }
@@ -255,6 +283,27 @@ class Roster {
255
283
  this.assignedPorts = new Set(); // Track ports assigned to domains (not OS availability)
256
284
  this._sitesByPort = {};
257
285
  this._initialized = false;
286
+ this._closing = false;
287
+ this._initTask = null;
288
+ this._initPromise = null;
289
+ this._startPromise = null;
290
+ this._closePromise = null;
291
+ this.handleSignals = parseBooleanFlag(options.handleSignals, false);
292
+ this._signalHandler = null;
293
+ this._ownedServers = new Set();
294
+ this._sockets = new Set();
295
+ this._upgradedSockets = new Set();
296
+ this._responses = new Set();
297
+ this._attachments = new Map();
298
+ this._retryTimers = new Set();
299
+ this._pendingListens = new Set();
300
+ this._certificateChecks = new Map();
301
+ this._secureContexts = new Map();
302
+ this._contextLoads = new Map();
303
+ this.closeTimeoutMs = options.closeTimeoutMs ?? 30000;
304
+ if (!Number.isFinite(this.closeTimeoutMs) || this.closeTimeoutMs <= 0) {
305
+ throw new TypeError('closeTimeoutMs must be a positive finite number');
306
+ }
258
307
  this._sniCallback = null;
259
308
  this.hostname = options.hostname ?? '::';
260
309
  this.filename = options.filename || 'index';
@@ -327,6 +376,7 @@ class Roster {
327
376
  .filter(dirent => dirent.isDirectory());
328
377
 
329
378
  for (const dirent of sites) {
379
+ this._assertOpen();
330
380
  const domain = dirent.name;
331
381
  const domainPath = path.join(this.wwwPath, domain);
332
382
 
@@ -343,6 +393,8 @@ class Roster {
343
393
  continue;
344
394
  }
345
395
 
396
+ this._assertOpen();
397
+
346
398
  const { siteApp, type } = resolved;
347
399
 
348
400
  if (domain.startsWith('*.')) {
@@ -528,7 +580,7 @@ class Roster {
528
580
  getHandlerForPortData(host, portData) {
529
581
  const virtualServer = portData.virtualServers[host];
530
582
  const appHandler = portData.appHandlers[host];
531
- if (virtualServer && appHandler !== undefined) return { virtualServer, appHandler };
583
+ if (Object.hasOwn(portData.virtualServers, host)) return { virtualServer, appHandler };
532
584
  for (const key of Object.keys(portData.appHandlers)) {
533
585
  if (key.startsWith('*.') && hostMatchesWildcard(host, key)) {
534
586
  return {
@@ -541,6 +593,7 @@ class Roster {
541
593
  }
542
594
 
543
595
  handleRequest(req, res) {
596
+ if (!this._beginRequest(res)) return;
544
597
  const host = req.headers.host || '';
545
598
  const hostWithoutPort = host.split(':')[0];
546
599
  const normalizedHost = hostWithoutPort.toLowerCase();
@@ -557,7 +610,7 @@ class Roster {
557
610
 
558
611
  const siteApp = this.getHandlerForHost(hostWithoutPort);
559
612
  if (siteApp) {
560
- siteApp(req, res);
613
+ return invokeRequest(siteApp, req, res);
561
614
  } else {
562
615
  res.writeHead(404);
563
616
  res.end('Site not found');
@@ -565,6 +618,7 @@ class Roster {
565
618
  }
566
619
 
567
620
  register(domainString, requestHandler) {
621
+ this._assertOpen();
568
622
  if (!domainString) {
569
623
  throw new Error('Domain is required');
570
624
  }
@@ -604,6 +658,7 @@ class Roster {
604
658
  }
605
659
 
606
660
  use(plugin) {
661
+ this._assertOpen();
607
662
  if (typeof plugin !== 'function') {
608
663
  throw new Error('plugin must be a function');
609
664
  }
@@ -612,14 +667,20 @@ class Roster {
612
667
  }
613
668
 
614
669
  _runRequestPlugins(req, res, context) {
615
- for (const plugin of this.plugins) {
616
- const handled = plugin(req, res, context);
617
- if (handled && typeof handled.then === 'function') {
618
- throw new Error('Request plugins must be synchronous');
670
+ try {
671
+ for (const plugin of this.plugins) {
672
+ const handled = plugin(req, res, context);
673
+ if (handled && typeof handled.then === 'function') {
674
+ Promise.resolve(handled).catch(() => {});
675
+ throw new Error('Request plugins must be synchronous');
676
+ }
677
+ if (handled === true) return true;
619
678
  }
620
- if (handled === true) return true;
679
+ return false;
680
+ } catch (error) {
681
+ requestError(error, res);
682
+ return true;
621
683
  }
622
- return false;
623
684
  }
624
685
 
625
686
  parseDomainWithPort(domainString) {
@@ -664,6 +725,9 @@ class Roster {
664
725
 
665
726
  // Assign port to domain, detecting collisions with already assigned ports
666
727
  assignPortToDomain(domain) {
728
+ if (this.assignedPorts.size >= this.maxLocalPort - this.minLocalPort + 1) {
729
+ throw new Error('Local port range is exhausted');
730
+ }
667
731
  let port = domainToPort(domain, this.minLocalPort, this.maxLocalPort);
668
732
 
669
733
  // If port is already assigned to another domain, increment until we find a free one
@@ -735,6 +799,7 @@ class Roster {
735
799
  _initSiteHandlers() {
736
800
  this._sitesByPort = {};
737
801
  for (const [hostKey, siteApp] of Object.entries(this.sites)) {
802
+ this._assertOpen();
738
803
  if (hostKey.startsWith('www.')) continue;
739
804
  const { domain, port } = this.parseDomainWithPort(hostKey);
740
805
  if (!this._sitesByPort[port]) {
@@ -748,7 +813,13 @@ class Roster {
748
813
  this._sitesByPort[port].virtualServers[domain] = virtualServer;
749
814
  this.domainServers[domain] = virtualServer;
750
815
 
751
- const appHandler = siteApp(virtualServer);
816
+ let appHandler;
817
+ virtualServer.fallbackHandler = (req, res) => {
818
+ if (appHandler) return appHandler(req, res);
819
+ res.writeHead(404);
820
+ res.end('Site not found');
821
+ };
822
+ appHandler = siteApp(virtualServer);
752
823
  this._sitesByPort[port].appHandlers[domain] = appHandler;
753
824
  if (!domain.startsWith('*.')) {
754
825
  this._sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
@@ -758,6 +829,7 @@ class Roster {
758
829
 
759
830
  _createDispatcher(portData) {
760
831
  return (req, res) => {
832
+ if (!this._beginRequest(res)) return;
761
833
  const host = req.headers.host || '';
762
834
  const hostWithoutPort = host.split(':')[0].toLowerCase();
763
835
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
@@ -766,7 +838,7 @@ class Roster {
766
838
 
767
839
  if (hostWithoutPort.startsWith('www.')) {
768
840
  const protocol = this.local ? 'http' : 'https';
769
- res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
841
+ res.writeHead(301, { Location: `${protocol}://${host.toLowerCase().slice(4)}${req.url}` });
770
842
  res.end();
771
843
  return;
772
844
  }
@@ -780,10 +852,9 @@ class Roster {
780
852
  const { virtualServer, appHandler } = resolved;
781
853
 
782
854
  if (virtualServer && virtualServer.requestListeners.length > 0) {
783
- virtualServer.fallbackHandler = appHandler;
784
855
  virtualServer.processRequest(req, res);
785
856
  } else if (appHandler) {
786
- appHandler(req, res);
857
+ return invokeRequest(appHandler, req, res);
787
858
  } else {
788
859
  res.writeHead(404);
789
860
  res.end('Site not found');
@@ -791,8 +862,16 @@ class Roster {
791
862
  };
792
863
  }
793
864
 
865
+ _trackUpgrade(socket) {
866
+ if (typeof socket.once !== 'function') return;
867
+ this._upgradedSockets.add(socket);
868
+ socket.once('close', () => this._upgradedSockets.delete(socket));
869
+ }
870
+
794
871
  _createUpgradeHandler(portData) {
795
872
  return (req, socket, head) => {
873
+ if (this._closing) { socket.destroy(); return; }
874
+ this._trackUpgrade(socket);
796
875
  const host = req.headers.host || '';
797
876
  const hostWithoutPort = host.split(':')[0].toLowerCase();
798
877
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
@@ -806,38 +885,69 @@ class Roster {
806
885
  };
807
886
  }
808
887
 
809
- _initSniResolver() {
810
- this._sniCallback = (servername, callback) => {
811
- const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
812
- try {
813
- const pems = this._resolvePemsForServername(normalizedServername);
814
- if (pems) {
815
- callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
816
- return;
817
- }
818
- } catch (error) {
819
- callback(error);
820
- return;
888
+ async _resolveSecureContext(servername) {
889
+ this._assertOpen();
890
+ const host = this._normalizeHostInput(servername).trim().toLowerCase();
891
+ for (const subject of buildCertLookupCandidates(host)) {
892
+ let pending = this._contextLoads.get(subject);
893
+ if (!pending) {
894
+ pending = this._loadSecureContext(subject).finally(() => this._contextLoads.delete(subject));
895
+ this._contextLoads.set(subject, pending);
821
896
  }
897
+ const context = await pending;
898
+ if (context) return context;
899
+ }
900
+ return null;
901
+ }
822
902
 
823
- // Cluster-friendly automatic issuance path (no internal listen lifecycle).
824
- if (!this._greenlockRuntime || !normalizedServername) {
825
- callback(new Error(`No certificate files available for ${servername}`));
826
- return;
827
- }
903
+ async _loadSecureContext(subject) {
904
+ const directory = path.join(this.greenlockStorePath, 'live', subject);
905
+ const files = ['privkey.pem', 'cert.pem', 'chain.pem'].map(name => path.join(directory, name));
906
+ let stats;
907
+ try {
908
+ stats = await Promise.all(files.map(file => fs.promises.stat(file, { bigint: true })));
909
+ } catch (error) {
910
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
911
+ this._secureContexts.delete(subject);
912
+ return null;
913
+ }
914
+ this._assertOpen();
915
+ const version = stats.map(stat => `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`).join('|');
916
+ const cached = this._secureContexts.get(subject);
917
+ if (cached && cached.version === version) return cached.context;
918
+ const [key, cert, chain] = await Promise.all(files.map(file => fs.promises.readFile(file, 'utf8')));
919
+ this._assertOpen();
920
+ const context = tls.createSecureContext({ key, cert: cert + chain });
921
+ this._secureContexts.set(subject, { version, context });
922
+ return context;
923
+ }
828
924
 
829
- this._greenlockRuntime.get({ servername: normalizedServername })
830
- .then(() => {
831
- const issued = this._resolvePemsForServername(normalizedServername);
832
- if (issued) {
833
- callback(null, tls.createSecureContext({ key: issued.key, cert: issued.cert }));
834
- } else {
835
- callback(new Error(`No certificate files available for ${servername}`));
836
- }
837
- })
838
- .catch((error) => {
839
- callback(error);
840
- });
925
+ _checkCertificate(servername) {
926
+ this._assertOpen();
927
+ if (!this._certificateChecks.has(servername)) {
928
+ const pending = Promise.resolve().then(() => {
929
+ this._assertOpen();
930
+ return this._greenlockRuntime.get({ servername });
931
+ }).finally(() => this._certificateChecks.delete(servername));
932
+ this._certificateChecks.set(servername, pending);
933
+ }
934
+ return this._certificateChecks.get(servername);
935
+ }
936
+
937
+ async _getSecureContext(servername, issue = true) {
938
+ const host = this._normalizeHostInput(servername).trim().toLowerCase();
939
+ let context = await this._resolveSecureContext(host);
940
+ if (!context && issue && this._greenlockRuntime && host) {
941
+ await this._checkCertificate(host);
942
+ context = await this._resolveSecureContext(host);
943
+ }
944
+ if (!context) throw new Error(`No certificate files available for ${servername}`);
945
+ return context;
946
+ }
947
+
948
+ _initSniResolver() {
949
+ this._sniCallback = (servername, callback) => {
950
+ this._getSecureContext(servername).then(context => callback(null, context), callback);
841
951
  };
842
952
  }
843
953
 
@@ -850,6 +960,7 @@ class Roster {
850
960
  staging: this.staging,
851
961
  skipDryRun: this.skipLocalCheck,
852
962
  skipChallengeTest: this.skipLocalCheck,
963
+ renew: false,
853
964
  notify: (event, details) => {
854
965
  const eventDomain = (() => {
855
966
  if (!details || typeof details !== 'object') return null;
@@ -923,12 +1034,12 @@ class Roster {
923
1034
  }
924
1035
 
925
1036
  _startCertificateRenewLoop() {
926
- if (!this._greenlockRuntime || this._certificateRenewTimer) return;
1037
+ if (this._closing || !this._greenlockRuntime || this._certificateRenewTimer) return;
927
1038
  const subjects = this._getManagedCertificateSubjects();
928
1039
  if (subjects.length === 0) return;
929
1040
  this._certificateRenewTimer = setInterval(() => {
930
1041
  subjects.forEach((subject) => {
931
- this._greenlockRuntime.get({ servername: subject }).catch((error) => {
1042
+ this._checkCertificate(subject).catch((error) => {
932
1043
  log.warn(`⚠️ Certificate renew check failed for ${subject}: ${error?.message || error}`);
933
1044
  });
934
1045
  });
@@ -939,6 +1050,7 @@ class Roster {
939
1050
  }
940
1051
 
941
1052
  async ensureCertificate(servername) {
1053
+ this._assertOpen();
942
1054
  if (this.local) {
943
1055
  throw new Error('ensureCertificate() is not available in local mode');
944
1056
  }
@@ -954,7 +1066,7 @@ class Roster {
954
1066
  if (!this._greenlockRuntime) {
955
1067
  throw new Error('autoCertificates is disabled; enable { autoCertificates: true } to issue certificates automatically');
956
1068
  }
957
- await this._greenlockRuntime.get({ servername: normalizedServername });
1069
+ await this._checkCertificate(normalizedServername);
958
1070
  pems = this._resolvePemsForServername(normalizedServername);
959
1071
  if (!pems) {
960
1072
  throw new Error(`Certificate issuance completed but no PEM files were found for ${normalizedServername}`);
@@ -963,6 +1075,7 @@ class Roster {
963
1075
  }
964
1076
 
965
1077
  loadCertificate(servername) {
1078
+ this._assertOpen();
966
1079
  if (this.local) {
967
1080
  throw new Error('loadCertificate() is not available in local mode');
968
1081
  }
@@ -980,9 +1093,38 @@ class Roster {
980
1093
  return pems;
981
1094
  }
982
1095
 
983
- async init() {
984
- if (this._initialized) return this;
1096
+ _assertOpen() {
1097
+ if (this._closing) throw new Error('Roster is closing or closed');
1098
+ }
1099
+
1100
+ init() {
1101
+ if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
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
+ }
1113
+ this._initTask = this._initialize();
1114
+ this._initPromise = this._initTask.catch(async error => {
1115
+ if (!this._closing) {
1116
+ try { await this.close(); } catch (cleanupError) {
1117
+ throw new AggregateError([error, cleanupError], 'Initialization and cleanup failed');
1118
+ }
1119
+ }
1120
+ throw error;
1121
+ });
1122
+ return this._initPromise;
1123
+ }
1124
+
1125
+ async _initialize() {
985
1126
  await this.loadSites();
1127
+ this._assertOpen();
986
1128
  if (!this.local) {
987
1129
  this.generateConfigJson();
988
1130
  if (this.autoCertificates) {
@@ -990,6 +1132,7 @@ class Roster {
990
1132
  }
991
1133
  }
992
1134
  this._initSiteHandlers();
1135
+ this._assertOpen();
993
1136
  if (!this.local) {
994
1137
  this._initSniResolver();
995
1138
  if (this.autoCertificates) {
@@ -1000,6 +1143,151 @@ class Roster {
1000
1143
  return this;
1001
1144
  }
1002
1145
 
1146
+ _beginRequest(res) {
1147
+ if (this._closing) {
1148
+ res.writeHead(503, { Connection: 'close' });
1149
+ res.end('Server is closing');
1150
+ return false;
1151
+ }
1152
+ if (typeof res.once === 'function') {
1153
+ this._responses.add(res);
1154
+ const done = () => {
1155
+ this._responses.delete(res);
1156
+ res.removeListener('finish', done);
1157
+ res.removeListener('close', done);
1158
+ };
1159
+ res.once('finish', done);
1160
+ res.once('close', done);
1161
+ }
1162
+ return true;
1163
+ }
1164
+
1165
+ _listen(server, port, hostname) {
1166
+ this._assertOpen();
1167
+ this._ownedServers.add(server);
1168
+ server.on('connection', socket => {
1169
+ this._sockets.add(socket);
1170
+ socket.once('close', () => this._sockets.delete(socket));
1171
+ if (this._closing) socket.destroy();
1172
+ });
1173
+ return new Promise((resolve, reject) => {
1174
+ const done = error => {
1175
+ server.removeListener('error', failed);
1176
+ server.removeListener('listening', listening);
1177
+ this._pendingListens.delete(cancel);
1178
+ if (error) reject(error);
1179
+ else resolve();
1180
+ };
1181
+ const failed = error => done(error);
1182
+ const listening = () => done();
1183
+ const cancel = () => {
1184
+ // A pending DNS lookup may complete after close().
1185
+ server.once('listening', () => server.close());
1186
+ done(new Error('Roster is closing or closed'));
1187
+ };
1188
+ this._pendingListens.add(cancel);
1189
+ server.once('error', failed);
1190
+ server.once('listening', listening);
1191
+ server.on('error', error => log.error(`Server error on port ${port}:`, error.message));
1192
+ try { server.listen(port, hostname); } catch (error) { done(error); }
1193
+ });
1194
+ }
1195
+
1196
+ close() {
1197
+ if (this._closePromise) return this._closePromise;
1198
+ this._closing = true;
1199
+ this._closePromise = this._close();
1200
+ return this._closePromise;
1201
+ }
1202
+
1203
+ async _close() {
1204
+ const errors = [];
1205
+ let timer;
1206
+ const timeoutError = new Error('Roster close timed out');
1207
+ const timeout = new Promise((resolve, reject) => {
1208
+ timer = setTimeout(() => {
1209
+ for (const res of this._responses) res.destroy();
1210
+ for (const socket of this._sockets) socket.destroy();
1211
+ for (const server of this._ownedServers) server.closeAllConnections();
1212
+ reject(timeoutError);
1213
+ }, this.closeTimeoutMs);
1214
+ });
1215
+ const wait = async promise => {
1216
+ try { await Promise.race([promise, timeout]); } catch (error) {
1217
+ if (!errors.includes(error)) errors.push(error);
1218
+ }
1219
+ };
1220
+ try {
1221
+ clearInterval(this._certificateRenewTimer);
1222
+ this._certificateRenewTimer = null;
1223
+ for (const timer of this._retryTimers) clearTimeout(timer);
1224
+ this._retryTimers.clear();
1225
+ for (const cancel of this._pendingListens) cancel();
1226
+ if (this._initTask) await wait(this._initTask.catch(() => {}));
1227
+
1228
+ const draining = [...this._responses].map(res => new Promise(resolve => {
1229
+ const done = () => {
1230
+ res.removeListener('finish', done);
1231
+ res.removeListener('close', done);
1232
+ resolve();
1233
+ };
1234
+ res.once('finish', done);
1235
+ res.once('close', done);
1236
+ }));
1237
+ const closeServer = server => new Promise(resolve => {
1238
+ try {
1239
+ server.close(error => {
1240
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') errors.push(error);
1241
+ resolve();
1242
+ });
1243
+ server.closeIdleConnections();
1244
+ } catch (error) {
1245
+ errors.push(error);
1246
+ resolve();
1247
+ }
1248
+ });
1249
+ // Bun's close() releases its native server handle before upgraded
1250
+ // connections finish, preventing a later closeAllConnections().
1251
+ const closing = isBunRuntime ? [] : [...this._ownedServers].map(closeServer);
1252
+ const virtualServers = Object.values(this._sitesByPort).flatMap(portData => Object.values(portData.virtualServers));
1253
+ for (const server of virtualServers) {
1254
+ try { server.close(); } catch (error) { errors.push(error); }
1255
+ }
1256
+ for (const socket of this._upgradedSockets) socket.destroy();
1257
+ await wait(Promise.all([
1258
+ ...closing, ...draining,
1259
+ Promise.allSettled([...this._certificateChecks.values(), ...this._contextLoads.values()])
1260
+ ]));
1261
+ if (isBunRuntime) {
1262
+ for (const server of this._ownedServers) server.closeAllConnections();
1263
+ await wait(Promise.all([...this._ownedServers].map(closeServer)));
1264
+ }
1265
+
1266
+ const hooks = virtualServers.flatMap(server => server._closeHooks.splice(0).map(hook =>
1267
+ Promise.resolve().then(() => hook())
1268
+ ));
1269
+ await wait(Promise.allSettled(hooks).then(results => {
1270
+ for (const result of results) {
1271
+ if (result.status === 'rejected') errors.push(result.reason);
1272
+ }
1273
+ }));
1274
+ } finally {
1275
+ clearTimeout(timer);
1276
+ if (this._signalHandler) {
1277
+ for (const signal of SHUTDOWN_SIGNALS) process.removeListener(signal, this._signalHandler);
1278
+ this._signalHandler = null;
1279
+ }
1280
+ for (const [server, handlers] of this._attachments) {
1281
+ server.removeListener('request', handlers.request);
1282
+ server.removeListener('upgrade', handlers.upgrade);
1283
+ }
1284
+ this._attachments.clear();
1285
+ this._secureContexts.clear();
1286
+ this._ownedServers.clear();
1287
+ }
1288
+ if (errors.length) throw new AggregateError(errors, 'Roster close failed');
1289
+ }
1290
+
1003
1291
  requestHandler(port) {
1004
1292
  if (!this._initialized) throw new Error('Call init() before requestHandler()');
1005
1293
  const targetPort = port || this.defaultPort;
@@ -1030,9 +1318,18 @@ class Roster {
1030
1318
  }
1031
1319
 
1032
1320
  attach(server, { port } = {}) {
1321
+ this._assertOpen();
1033
1322
  if (!this._initialized) throw new Error('Call init() before attach()');
1034
- server.on('request', this.requestHandler(port));
1035
- server.on('upgrade', this.upgradeHandler(port));
1323
+ const targetPort = port || this.defaultPort;
1324
+ const existing = this._attachments.get(server);
1325
+ if (existing) {
1326
+ if (existing.port !== targetPort) throw new Error('Server is already attached to another port');
1327
+ return this;
1328
+ }
1329
+ const handlers = { request: this.requestHandler(port), upgrade: this.upgradeHandler(port), port: targetPort };
1330
+ server.on('request', handlers.request);
1331
+ server.on('upgrade', handlers.upgrade);
1332
+ this._attachments.set(server, handlers);
1036
1333
  return this;
1037
1334
  }
1038
1335
 
@@ -1076,7 +1373,7 @@ class Roster {
1076
1373
  });
1077
1374
  }
1078
1375
 
1079
- startLocalMode() {
1376
+ async startLocalMode() {
1080
1377
  this.domainPorts = {};
1081
1378
 
1082
1379
  for (const portData of Object.values(this._sitesByPort)) {
@@ -1089,13 +1386,13 @@ class Roster {
1089
1386
  const appHandler = portData.appHandlers[domain];
1090
1387
 
1091
1388
  const dispatcher = (req, res) => {
1389
+ if (!this._beginRequest(res)) return;
1092
1390
  const host = (req.headers.host || '').split(':')[0].toLowerCase();
1093
1391
  if (this._runRequestPlugins(req, res, { host, domain })) return;
1094
- virtualServer.fallbackHandler = appHandler;
1095
1392
  if (virtualServer.requestListeners.length > 0) {
1096
1393
  virtualServer.processRequest(req, res);
1097
1394
  } else if (appHandler) {
1098
- appHandler(req, res);
1395
+ return invokeRequest(appHandler, req, res);
1099
1396
  } else {
1100
1397
  res.writeHead(404);
1101
1398
  res.end('Site not found');
@@ -1106,17 +1403,14 @@ class Roster {
1106
1403
  this.portServers[port] = httpServer;
1107
1404
 
1108
1405
  httpServer.on('upgrade', (req, socket, head) => {
1406
+ if (this._closing) { socket.destroy(); return; }
1407
+ this._trackUpgrade(socket);
1109
1408
  virtualServer.processUpgrade(req, socket, head);
1110
1409
  });
1111
1410
 
1112
- httpServer.listen(port, 'localhost', () => {
1113
- const cleanDomain = normalizeDomainForLocalHost(domain);
1114
- log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
1115
- });
1116
-
1117
- httpServer.on('error', (error) => {
1118
- log.error(`❌ Error on port ${port} for ${domain}:`, error.message);
1119
- });
1411
+ await this._listen(httpServer, port, 'localhost');
1412
+ const cleanDomain = normalizeDomainForLocalHost(domain);
1413
+ log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
1120
1414
  }
1121
1415
  }
1122
1416
 
@@ -1124,27 +1418,50 @@ class Roster {
1124
1418
  return Promise.resolve();
1125
1419
  }
1126
1420
 
1127
- async start() {
1421
+ start() {
1422
+ if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
1423
+ if (!this._startPromise) {
1424
+ this._startPromise = this._start().catch(async error => {
1425
+ if (!this._closing) {
1426
+ try { await this.close(); } catch (cleanupError) {
1427
+ throw new AggregateError([error, cleanupError], 'Startup and cleanup failed');
1428
+ }
1429
+ }
1430
+ throw error;
1431
+ });
1432
+ }
1433
+ return this._startPromise;
1434
+ }
1435
+
1436
+ async _start() {
1128
1437
  await this.init();
1438
+ this._assertOpen();
1129
1439
 
1130
1440
  if (this.local) {
1131
1441
  return this.startLocalMode();
1132
1442
  }
1133
1443
 
1134
1444
  const greenlockOptions = this._buildGreenlockOptions();
1135
- const greenlockRuntime = GreenlockShim.create(greenlockOptions);
1445
+ const greenlockRuntime = this._greenlockRuntime || GreenlockShim.create(greenlockOptions);
1446
+ this._greenlockRuntime = greenlockRuntime;
1447
+ this._startCertificateRenewLoop();
1136
1448
  const greenlock = Greenlock.init({
1137
1449
  ...greenlockOptions,
1138
- greenlock: greenlockRuntime
1450
+ greenlock: greenlockRuntime,
1451
+ onServerError: error => log.error('Server error:', error.message)
1139
1452
  });
1140
1453
 
1141
- return greenlock.ready(async glx => {
1454
+ if (this.cluster && require('cluster').isPrimary) {
1455
+ return greenlock.ready();
1456
+ }
1457
+ const glx = await new Promise(resolve => greenlock.ready(resolve));
1458
+ this._assertOpen();
1459
+ {
1142
1460
  const httpServer = glx.httpServer();
1143
1461
  const bunTlsHotReloadHandlers = [];
1144
1462
 
1145
- httpServer.listen(80, this.hostname, () => {
1146
- log.info('HTTP server listening on port 80');
1147
- });
1463
+ await this._listen(httpServer, 80, this.hostname);
1464
+ log.info('HTTP server listening on port 80');
1148
1465
 
1149
1466
  for (const [port, portData] of Object.entries(this._sitesByPort)) {
1150
1467
  const portNum = parseInt(port);
@@ -1159,7 +1476,7 @@ class Roster {
1159
1476
  if (pems) return pems;
1160
1477
 
1161
1478
  try {
1162
- await greenlockRuntime.get({ servername: host });
1479
+ await this._checkCertificate(host);
1163
1480
  } catch (error) {
1164
1481
  log.warn(`⚠️ Greenlock issuance failed for ${host}: ${error?.message || error}`);
1165
1482
  }
@@ -1172,7 +1489,7 @@ class Roster {
1172
1489
  if (zone) {
1173
1490
  const bootstrapHost = `bun-bootstrap.${zone}`;
1174
1491
  try {
1175
- await greenlockRuntime.get({ servername: bootstrapHost });
1492
+ await this._checkCertificate(bootstrapHost);
1176
1493
  } catch (error) {
1177
1494
  log.warn(`⚠️ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
1178
1495
  }
@@ -1201,7 +1518,7 @@ class Roster {
1201
1518
  const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
1202
1519
  log.warn(`⚠️ Bun: requesting ${needsWildcard ? 'combined wildcard' : ''} certificate for ${certSubject} via Greenlock before HTTPS bind`);
1203
1520
  try {
1204
- await greenlockRuntime.get({ servername: certSubject });
1521
+ await this._checkCertificate(certSubject);
1205
1522
  } catch (error) {
1206
1523
  log.error(`❌ Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
1207
1524
  }
@@ -1222,23 +1539,28 @@ class Roster {
1222
1539
  if (isBunRuntime) {
1223
1540
  const primaryDomain = Object.keys(portData.virtualServers)[0];
1224
1541
  let defaultPems = await ensureBunDefaultPems(primaryDomain);
1542
+ let defaultContext = tls.createSecureContext(defaultPems);
1225
1543
  httpsServer = https.createServer({
1226
1544
  ...tlsOpts,
1227
1545
  key: defaultPems.key,
1228
1546
  cert: defaultPems.cert,
1229
1547
  SNICallback: (servername, callback) => {
1230
- issueAndReloadPemsForServername(servername)
1231
- .then((pems) => {
1232
- const selected = pems || defaultPems;
1233
- callback(null, tls.createSecureContext({ key: selected.key, cert: selected.cert }));
1234
- })
1235
- .catch(callback);
1548
+ const resolveContext = async () => {
1549
+ const cached = await this._resolveSecureContext(servername);
1550
+ if (cached) return cached;
1551
+ const pems = await issueAndReloadPemsForServername(servername);
1552
+ this._assertOpen();
1553
+ return pems ? this._getSecureContext(servername, false) : defaultContext;
1554
+ };
1555
+ resolveContext().then(context => callback(null, context), callback);
1236
1556
  }
1237
1557
  }, dispatcher);
1238
1558
  const reloadBunDefaultTls = async (servername, reason) => {
1239
1559
  const nextPems = await issueAndReloadPemsForServername(servername);
1240
1560
  if (!nextPems) return false;
1561
+ this._assertOpen();
1241
1562
  defaultPems = nextPems;
1563
+ defaultContext = tls.createSecureContext(defaultPems);
1242
1564
  if (typeof httpsServer.setSecureContext === 'function') {
1243
1565
  try {
1244
1566
  httpsServer.setSecureContext({ key: defaultPems.key, cert: defaultPems.cert });
@@ -1258,34 +1580,20 @@ class Roster {
1258
1580
  this.portServers[portNum] = httpsServer;
1259
1581
  httpsServer.on('upgrade', upgradeHandler);
1260
1582
 
1261
- httpsServer.listen(portNum, this.hostname, () => {
1262
- log.info(`HTTPS server listening on port ${portNum}`);
1263
- });
1583
+ await this._listen(httpsServer, portNum, this.hostname);
1584
+ log.info(`HTTPS server listening on port ${portNum}`);
1264
1585
  } else {
1265
1586
  const httpsOptions = {
1266
1587
  minVersion: this.tlsMinVersion,
1267
1588
  maxVersion: this.tlsMaxVersion,
1268
1589
  SNICallback: (servername, callback) => {
1269
- try {
1270
- const pems = this._resolvePemsForServername(servername);
1271
- if (pems) {
1272
- callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
1273
- } else {
1274
- callback(new Error(`No certificate files available for ${servername}`));
1275
- }
1276
- } catch (error) {
1277
- callback(error);
1278
- }
1590
+ this._getSecureContext(servername, false).then(context => callback(null, context), callback);
1279
1591
  }
1280
1592
  };
1281
1593
 
1282
1594
  const httpsServer = https.createServer(httpsOptions, dispatcher);
1283
1595
  httpsServer.on('upgrade', upgradeHandler);
1284
1596
 
1285
- httpsServer.on('error', (error) => {
1286
- log.error(`HTTPS server error on port ${portNum}:`, error.message);
1287
- });
1288
-
1289
1597
  httpsServer.on('tlsClientError', (error) => {
1290
1598
  if (!error.message.includes('http request')) {
1291
1599
  log.error(`TLS error on port ${portNum}:`, error.message);
@@ -1294,13 +1602,8 @@ class Roster {
1294
1602
 
1295
1603
  this.portServers[portNum] = httpsServer;
1296
1604
 
1297
- httpsServer.listen(portNum, this.hostname, (error) => {
1298
- if (error) {
1299
- log.error(`Failed to start HTTPS server on port ${portNum}:`, error.message);
1300
- } else {
1301
- log.info(`HTTPS server listening on port ${portNum}`);
1302
- }
1303
- });
1605
+ await this._listen(httpsServer, portNum, this.hostname);
1606
+ log.info(`HTTPS server listening on port ${portNum}`);
1304
1607
  }
1305
1608
  }
1306
1609
 
@@ -1315,6 +1618,7 @@ class Roster {
1315
1618
  for (const zone of this.wildcardZones) {
1316
1619
  const bootstrapHost = `bun-bootstrap.${zone}`;
1317
1620
  const attemptPrewarm = async (attempt = 1) => {
1621
+ if (this._closing) return;
1318
1622
  try {
1319
1623
  log.warn(`⚠️ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
1320
1624
  let reloaded = false;
@@ -1331,16 +1635,20 @@ class Roster {
1331
1635
  log.warn(`⚠️ Bun wildcard prewarm stopped for ${zone} after ${attempt} attempts`);
1332
1636
  return;
1333
1637
  }
1334
- setTimeout(() => {
1638
+ if (this._closing) return;
1639
+ const timer = setTimeout(() => {
1640
+ this._retryTimers.delete(timer);
1335
1641
  attemptPrewarm(attempt + 1).catch(() => {});
1336
1642
  }, retryDelayMs);
1643
+ this._retryTimers.add(timer);
1337
1644
  }
1338
1645
  };
1339
1646
 
1340
1647
  attemptPrewarm().catch(() => {});
1341
1648
  }
1342
1649
  }
1343
- });
1650
+ }
1651
+ return this;
1344
1652
  }
1345
1653
  }
1346
1654