roster-server 2.4.11 → 2.4.13

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
@@ -10,6 +10,29 @@ 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
12
 
13
+ function requestError(error, res) {
14
+ log.error('Request handler failed:', error?.message || error);
15
+ if (res.destroyed || res.writableEnded) return;
16
+ if (res.headersSent) {
17
+ res.destroy(error);
18
+ return;
19
+ }
20
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
21
+ res.end('Internal Server Error');
22
+ }
23
+
24
+ function invokeRequest(handler, req, res) {
25
+ try {
26
+ const result = handler(req, res);
27
+ if (result && typeof result.then === 'function') {
28
+ return Promise.resolve(result).catch(error => requestError(error, res));
29
+ }
30
+ return result;
31
+ } catch (error) {
32
+ requestError(error, res);
33
+ }
34
+ }
35
+
13
36
  const isBunRuntime = typeof Bun !== 'undefined' || (typeof process !== 'undefined' && process.release?.name === 'bun');
14
37
 
15
38
  // CRC32 implementation for deterministic port assignment
@@ -129,6 +152,8 @@ class VirtualServer extends EventEmitter {
129
152
  this.domain = domain;
130
153
  this.requestListeners = [];
131
154
  this.upgradeListeners = [];
155
+ this._closeHooks = [];
156
+ this._closed = false;
132
157
 
133
158
  // Simulate http.Server properties
134
159
  this.listening = false;
@@ -156,6 +181,7 @@ class VirtualServer extends EventEmitter {
156
181
  // Socket.IO compatibility methods
157
182
  listeners(event) {
158
183
  if (event === 'request') {
184
+ if (this.requestListeners.length === 0 && this.fallbackHandler) return [this.fallbackHandler];
159
185
  return this.requestListeners.slice();
160
186
  } else if (event === 'upgrade') {
161
187
  return this.upgradeListeners.slice();
@@ -179,9 +205,10 @@ class VirtualServer extends EventEmitter {
179
205
  }
180
206
 
181
207
  removeAllListeners(event) {
182
- if (event === 'request') {
208
+ if (event === undefined || event === 'request') {
183
209
  this.requestListeners = [];
184
- } else if (event === 'upgrade') {
210
+ }
211
+ if (event === undefined || event === 'upgrade') {
185
212
  this.upgradeListeners = [];
186
213
  }
187
214
  return super.removeAllListeners(event);
@@ -189,34 +216,34 @@ class VirtualServer extends EventEmitter {
189
216
 
190
217
  // Simulate other http.Server methods
191
218
  listen() { this.listening = true; return this; }
192
- close() { this.listening = false; return this; }
219
+ close(callback) {
220
+ this.listening = false;
221
+ if (!this._closed) {
222
+ this._closed = true;
223
+ this.emit('close');
224
+ }
225
+ if (callback) process.nextTick(callback);
226
+ return this;
227
+ }
228
+ onClose(handler) {
229
+ if (typeof handler !== 'function') throw new TypeError('Close hook must be a function');
230
+ if (this._closed) throw new Error('Virtual server is closed');
231
+ this._closeHooks.push(handler);
232
+ return this;
233
+ }
193
234
  setTimeout() { return this; }
194
235
 
195
236
  // Process request with this virtual server's listeners
196
237
  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);
238
+ const listeners = this.requestListeners.slice();
239
+ if (listeners.length > 0) {
240
+ for (const listener of listeners) {
241
+ if (res.writableEnded || res.destroyed) break;
242
+ invokeRequest(listener.bind(this), req, res);
210
243
  }
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) {
244
+ } else if (this.fallbackHandler) {
245
+ return invokeRequest(this.fallbackHandler, req, res);
246
+ } else {
220
247
  res.writeHead(404);
221
248
  res.end('No handler found');
222
249
  }
@@ -255,6 +282,25 @@ class Roster {
255
282
  this.assignedPorts = new Set(); // Track ports assigned to domains (not OS availability)
256
283
  this._sitesByPort = {};
257
284
  this._initialized = false;
285
+ this._closing = false;
286
+ this._initTask = null;
287
+ this._initPromise = null;
288
+ this._startPromise = null;
289
+ this._closePromise = null;
290
+ this._ownedServers = new Set();
291
+ this._sockets = new Set();
292
+ this._upgradedSockets = new Set();
293
+ this._responses = new Set();
294
+ this._attachments = new Map();
295
+ this._retryTimers = new Set();
296
+ this._pendingListens = new Set();
297
+ this._certificateChecks = new Map();
298
+ this._secureContexts = new Map();
299
+ this._contextLoads = new Map();
300
+ this.closeTimeoutMs = options.closeTimeoutMs ?? 30000;
301
+ if (!Number.isFinite(this.closeTimeoutMs) || this.closeTimeoutMs <= 0) {
302
+ throw new TypeError('closeTimeoutMs must be a positive finite number');
303
+ }
258
304
  this._sniCallback = null;
259
305
  this.hostname = options.hostname ?? '::';
260
306
  this.filename = options.filename || 'index';
@@ -322,10 +368,12 @@ class Roster {
322
368
  return;
323
369
  }
324
370
 
371
+ const siteKeyForDomain = (domain) => this.defaultPort === 443 ? domain : `${domain}:443`;
325
372
  const sites = fs.readdirSync(this.wwwPath, { withFileTypes: true })
326
373
  .filter(dirent => dirent.isDirectory());
327
374
 
328
375
  for (const dirent of sites) {
376
+ this._assertOpen();
329
377
  const domain = dirent.name;
330
378
  const domainPath = path.join(this.wwwPath, domain);
331
379
 
@@ -342,6 +390,8 @@ class Roster {
342
390
  continue;
343
391
  }
344
392
 
393
+ this._assertOpen();
394
+
345
395
  const { siteApp, type } = resolved;
346
396
 
347
397
  if (domain.startsWith('*.')) {
@@ -350,7 +400,7 @@ class Roster {
350
400
  continue;
351
401
  }
352
402
  this.domains.push(domain);
353
- this.sites[domain] = siteApp;
403
+ this.sites[siteKeyForDomain(domain)] = siteApp;
354
404
  const root = wildcardRoot(domain);
355
405
  if (root) this.wildcardZones.add(root);
356
406
  log.info(`(✔) Loaded wildcard site: https://${domain}${type === 'static' ? ' (static)' : ''}`);
@@ -358,7 +408,7 @@ class Roster {
358
408
  const domainEntries = [domain, `www.${domain}`];
359
409
  this.domains.push(...domainEntries);
360
410
  domainEntries.forEach(d => {
361
- this.sites[d] = siteApp;
411
+ this.sites[siteKeyForDomain(d)] = siteApp;
362
412
  });
363
413
  log.info(`(✔) Loaded site: https://${domain}${type === 'static' ? ' (static)' : ''}`);
364
414
  }
@@ -527,7 +577,7 @@ class Roster {
527
577
  getHandlerForPortData(host, portData) {
528
578
  const virtualServer = portData.virtualServers[host];
529
579
  const appHandler = portData.appHandlers[host];
530
- if (virtualServer && appHandler !== undefined) return { virtualServer, appHandler };
580
+ if (Object.hasOwn(portData.virtualServers, host)) return { virtualServer, appHandler };
531
581
  for (const key of Object.keys(portData.appHandlers)) {
532
582
  if (key.startsWith('*.') && hostMatchesWildcard(host, key)) {
533
583
  return {
@@ -540,6 +590,7 @@ class Roster {
540
590
  }
541
591
 
542
592
  handleRequest(req, res) {
593
+ if (!this._beginRequest(res)) return;
543
594
  const host = req.headers.host || '';
544
595
  const hostWithoutPort = host.split(':')[0];
545
596
  const normalizedHost = hostWithoutPort.toLowerCase();
@@ -556,7 +607,7 @@ class Roster {
556
607
 
557
608
  const siteApp = this.getHandlerForHost(hostWithoutPort);
558
609
  if (siteApp) {
559
- siteApp(req, res);
610
+ return invokeRequest(siteApp, req, res);
560
611
  } else {
561
612
  res.writeHead(404);
562
613
  res.end('Site not found');
@@ -564,6 +615,7 @@ class Roster {
564
615
  }
565
616
 
566
617
  register(domainString, requestHandler) {
618
+ this._assertOpen();
567
619
  if (!domainString) {
568
620
  throw new Error('Domain is required');
569
621
  }
@@ -603,6 +655,7 @@ class Roster {
603
655
  }
604
656
 
605
657
  use(plugin) {
658
+ this._assertOpen();
606
659
  if (typeof plugin !== 'function') {
607
660
  throw new Error('plugin must be a function');
608
661
  }
@@ -611,14 +664,20 @@ class Roster {
611
664
  }
612
665
 
613
666
  _runRequestPlugins(req, res, context) {
614
- for (const plugin of this.plugins) {
615
- const handled = plugin(req, res, context);
616
- if (handled && typeof handled.then === 'function') {
617
- throw new Error('Request plugins must be synchronous');
667
+ try {
668
+ for (const plugin of this.plugins) {
669
+ const handled = plugin(req, res, context);
670
+ if (handled && typeof handled.then === 'function') {
671
+ Promise.resolve(handled).catch(() => {});
672
+ throw new Error('Request plugins must be synchronous');
673
+ }
674
+ if (handled === true) return true;
618
675
  }
619
- if (handled === true) return true;
676
+ return false;
677
+ } catch (error) {
678
+ requestError(error, res);
679
+ return true;
620
680
  }
621
- return false;
622
681
  }
623
682
 
624
683
  parseDomainWithPort(domainString) {
@@ -663,6 +722,9 @@ class Roster {
663
722
 
664
723
  // Assign port to domain, detecting collisions with already assigned ports
665
724
  assignPortToDomain(domain) {
725
+ if (this.assignedPorts.size >= this.maxLocalPort - this.minLocalPort + 1) {
726
+ throw new Error('Local port range is exhausted');
727
+ }
666
728
  let port = domainToPort(domain, this.minLocalPort, this.maxLocalPort);
667
729
 
668
730
  // If port is already assigned to another domain, increment until we find a free one
@@ -734,6 +796,7 @@ class Roster {
734
796
  _initSiteHandlers() {
735
797
  this._sitesByPort = {};
736
798
  for (const [hostKey, siteApp] of Object.entries(this.sites)) {
799
+ this._assertOpen();
737
800
  if (hostKey.startsWith('www.')) continue;
738
801
  const { domain, port } = this.parseDomainWithPort(hostKey);
739
802
  if (!this._sitesByPort[port]) {
@@ -747,7 +810,13 @@ class Roster {
747
810
  this._sitesByPort[port].virtualServers[domain] = virtualServer;
748
811
  this.domainServers[domain] = virtualServer;
749
812
 
750
- const appHandler = siteApp(virtualServer);
813
+ let appHandler;
814
+ virtualServer.fallbackHandler = (req, res) => {
815
+ if (appHandler) return appHandler(req, res);
816
+ res.writeHead(404);
817
+ res.end('Site not found');
818
+ };
819
+ appHandler = siteApp(virtualServer);
751
820
  this._sitesByPort[port].appHandlers[domain] = appHandler;
752
821
  if (!domain.startsWith('*.')) {
753
822
  this._sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
@@ -757,6 +826,7 @@ class Roster {
757
826
 
758
827
  _createDispatcher(portData) {
759
828
  return (req, res) => {
829
+ if (!this._beginRequest(res)) return;
760
830
  const host = req.headers.host || '';
761
831
  const hostWithoutPort = host.split(':')[0].toLowerCase();
762
832
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
@@ -765,7 +835,7 @@ class Roster {
765
835
 
766
836
  if (hostWithoutPort.startsWith('www.')) {
767
837
  const protocol = this.local ? 'http' : 'https';
768
- res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
838
+ res.writeHead(301, { Location: `${protocol}://${host.toLowerCase().slice(4)}${req.url}` });
769
839
  res.end();
770
840
  return;
771
841
  }
@@ -779,10 +849,9 @@ class Roster {
779
849
  const { virtualServer, appHandler } = resolved;
780
850
 
781
851
  if (virtualServer && virtualServer.requestListeners.length > 0) {
782
- virtualServer.fallbackHandler = appHandler;
783
852
  virtualServer.processRequest(req, res);
784
853
  } else if (appHandler) {
785
- appHandler(req, res);
854
+ return invokeRequest(appHandler, req, res);
786
855
  } else {
787
856
  res.writeHead(404);
788
857
  res.end('Site not found');
@@ -790,8 +859,16 @@ class Roster {
790
859
  };
791
860
  }
792
861
 
862
+ _trackUpgrade(socket) {
863
+ if (typeof socket.once !== 'function') return;
864
+ this._upgradedSockets.add(socket);
865
+ socket.once('close', () => this._upgradedSockets.delete(socket));
866
+ }
867
+
793
868
  _createUpgradeHandler(portData) {
794
869
  return (req, socket, head) => {
870
+ if (this._closing) { socket.destroy(); return; }
871
+ this._trackUpgrade(socket);
795
872
  const host = req.headers.host || '';
796
873
  const hostWithoutPort = host.split(':')[0].toLowerCase();
797
874
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
@@ -805,38 +882,69 @@ class Roster {
805
882
  };
806
883
  }
807
884
 
808
- _initSniResolver() {
809
- this._sniCallback = (servername, callback) => {
810
- const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
811
- try {
812
- const pems = this._resolvePemsForServername(normalizedServername);
813
- if (pems) {
814
- callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
815
- return;
816
- }
817
- } catch (error) {
818
- callback(error);
819
- return;
885
+ async _resolveSecureContext(servername) {
886
+ this._assertOpen();
887
+ const host = this._normalizeHostInput(servername).trim().toLowerCase();
888
+ for (const subject of buildCertLookupCandidates(host)) {
889
+ let pending = this._contextLoads.get(subject);
890
+ if (!pending) {
891
+ pending = this._loadSecureContext(subject).finally(() => this._contextLoads.delete(subject));
892
+ this._contextLoads.set(subject, pending);
820
893
  }
894
+ const context = await pending;
895
+ if (context) return context;
896
+ }
897
+ return null;
898
+ }
821
899
 
822
- // Cluster-friendly automatic issuance path (no internal listen lifecycle).
823
- if (!this._greenlockRuntime || !normalizedServername) {
824
- callback(new Error(`No certificate files available for ${servername}`));
825
- return;
826
- }
900
+ async _loadSecureContext(subject) {
901
+ const directory = path.join(this.greenlockStorePath, 'live', subject);
902
+ const files = ['privkey.pem', 'cert.pem', 'chain.pem'].map(name => path.join(directory, name));
903
+ let stats;
904
+ try {
905
+ stats = await Promise.all(files.map(file => fs.promises.stat(file, { bigint: true })));
906
+ } catch (error) {
907
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
908
+ this._secureContexts.delete(subject);
909
+ return null;
910
+ }
911
+ this._assertOpen();
912
+ const version = stats.map(stat => `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`).join('|');
913
+ const cached = this._secureContexts.get(subject);
914
+ if (cached && cached.version === version) return cached.context;
915
+ const [key, cert, chain] = await Promise.all(files.map(file => fs.promises.readFile(file, 'utf8')));
916
+ this._assertOpen();
917
+ const context = tls.createSecureContext({ key, cert: cert + chain });
918
+ this._secureContexts.set(subject, { version, context });
919
+ return context;
920
+ }
827
921
 
828
- this._greenlockRuntime.get({ servername: normalizedServername })
829
- .then(() => {
830
- const issued = this._resolvePemsForServername(normalizedServername);
831
- if (issued) {
832
- callback(null, tls.createSecureContext({ key: issued.key, cert: issued.cert }));
833
- } else {
834
- callback(new Error(`No certificate files available for ${servername}`));
835
- }
836
- })
837
- .catch((error) => {
838
- callback(error);
839
- });
922
+ _checkCertificate(servername) {
923
+ this._assertOpen();
924
+ if (!this._certificateChecks.has(servername)) {
925
+ const pending = Promise.resolve().then(() => {
926
+ this._assertOpen();
927
+ return this._greenlockRuntime.get({ servername });
928
+ }).finally(() => this._certificateChecks.delete(servername));
929
+ this._certificateChecks.set(servername, pending);
930
+ }
931
+ return this._certificateChecks.get(servername);
932
+ }
933
+
934
+ async _getSecureContext(servername, issue = true) {
935
+ const host = this._normalizeHostInput(servername).trim().toLowerCase();
936
+ let context = await this._resolveSecureContext(host);
937
+ if (!context && issue && this._greenlockRuntime && host) {
938
+ await this._checkCertificate(host);
939
+ context = await this._resolveSecureContext(host);
940
+ }
941
+ if (!context) throw new Error(`No certificate files available for ${servername}`);
942
+ return context;
943
+ }
944
+
945
+ _initSniResolver() {
946
+ this._sniCallback = (servername, callback) => {
947
+ this._getSecureContext(servername).then(context => callback(null, context), callback);
840
948
  };
841
949
  }
842
950
 
@@ -849,6 +957,7 @@ class Roster {
849
957
  staging: this.staging,
850
958
  skipDryRun: this.skipLocalCheck,
851
959
  skipChallengeTest: this.skipLocalCheck,
960
+ renew: false,
852
961
  notify: (event, details) => {
853
962
  const eventDomain = (() => {
854
963
  if (!details || typeof details !== 'object') return null;
@@ -922,12 +1031,12 @@ class Roster {
922
1031
  }
923
1032
 
924
1033
  _startCertificateRenewLoop() {
925
- if (!this._greenlockRuntime || this._certificateRenewTimer) return;
1034
+ if (this._closing || !this._greenlockRuntime || this._certificateRenewTimer) return;
926
1035
  const subjects = this._getManagedCertificateSubjects();
927
1036
  if (subjects.length === 0) return;
928
1037
  this._certificateRenewTimer = setInterval(() => {
929
1038
  subjects.forEach((subject) => {
930
- this._greenlockRuntime.get({ servername: subject }).catch((error) => {
1039
+ this._checkCertificate(subject).catch((error) => {
931
1040
  log.warn(`⚠️ Certificate renew check failed for ${subject}: ${error?.message || error}`);
932
1041
  });
933
1042
  });
@@ -938,6 +1047,7 @@ class Roster {
938
1047
  }
939
1048
 
940
1049
  async ensureCertificate(servername) {
1050
+ this._assertOpen();
941
1051
  if (this.local) {
942
1052
  throw new Error('ensureCertificate() is not available in local mode');
943
1053
  }
@@ -953,7 +1063,7 @@ class Roster {
953
1063
  if (!this._greenlockRuntime) {
954
1064
  throw new Error('autoCertificates is disabled; enable { autoCertificates: true } to issue certificates automatically');
955
1065
  }
956
- await this._greenlockRuntime.get({ servername: normalizedServername });
1066
+ await this._checkCertificate(normalizedServername);
957
1067
  pems = this._resolvePemsForServername(normalizedServername);
958
1068
  if (!pems) {
959
1069
  throw new Error(`Certificate issuance completed but no PEM files were found for ${normalizedServername}`);
@@ -962,6 +1072,7 @@ class Roster {
962
1072
  }
963
1073
 
964
1074
  loadCertificate(servername) {
1075
+ this._assertOpen();
965
1076
  if (this.local) {
966
1077
  throw new Error('loadCertificate() is not available in local mode');
967
1078
  }
@@ -979,9 +1090,28 @@ class Roster {
979
1090
  return pems;
980
1091
  }
981
1092
 
982
- async init() {
983
- if (this._initialized) return this;
1093
+ _assertOpen() {
1094
+ if (this._closing) throw new Error('Roster is closing or closed');
1095
+ }
1096
+
1097
+ init() {
1098
+ if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
1099
+ if (this._initPromise) return this._initPromise;
1100
+ this._initTask = this._initialize();
1101
+ this._initPromise = this._initTask.catch(async error => {
1102
+ if (!this._closing) {
1103
+ try { await this.close(); } catch (cleanupError) {
1104
+ throw new AggregateError([error, cleanupError], 'Initialization and cleanup failed');
1105
+ }
1106
+ }
1107
+ throw error;
1108
+ });
1109
+ return this._initPromise;
1110
+ }
1111
+
1112
+ async _initialize() {
984
1113
  await this.loadSites();
1114
+ this._assertOpen();
985
1115
  if (!this.local) {
986
1116
  this.generateConfigJson();
987
1117
  if (this.autoCertificates) {
@@ -989,6 +1119,7 @@ class Roster {
989
1119
  }
990
1120
  }
991
1121
  this._initSiteHandlers();
1122
+ this._assertOpen();
992
1123
  if (!this.local) {
993
1124
  this._initSniResolver();
994
1125
  if (this.autoCertificates) {
@@ -999,6 +1130,147 @@ class Roster {
999
1130
  return this;
1000
1131
  }
1001
1132
 
1133
+ _beginRequest(res) {
1134
+ if (this._closing) {
1135
+ res.writeHead(503, { Connection: 'close' });
1136
+ res.end('Server is closing');
1137
+ return false;
1138
+ }
1139
+ if (typeof res.once === 'function') {
1140
+ this._responses.add(res);
1141
+ const done = () => {
1142
+ this._responses.delete(res);
1143
+ res.removeListener('finish', done);
1144
+ res.removeListener('close', done);
1145
+ };
1146
+ res.once('finish', done);
1147
+ res.once('close', done);
1148
+ }
1149
+ return true;
1150
+ }
1151
+
1152
+ _listen(server, port, hostname) {
1153
+ this._assertOpen();
1154
+ this._ownedServers.add(server);
1155
+ server.on('connection', socket => {
1156
+ this._sockets.add(socket);
1157
+ socket.once('close', () => this._sockets.delete(socket));
1158
+ if (this._closing) socket.destroy();
1159
+ });
1160
+ return new Promise((resolve, reject) => {
1161
+ const done = error => {
1162
+ server.removeListener('error', failed);
1163
+ server.removeListener('listening', listening);
1164
+ this._pendingListens.delete(cancel);
1165
+ if (error) reject(error);
1166
+ else resolve();
1167
+ };
1168
+ const failed = error => done(error);
1169
+ const listening = () => done();
1170
+ const cancel = () => {
1171
+ // A pending DNS lookup may complete after close().
1172
+ server.once('listening', () => server.close());
1173
+ done(new Error('Roster is closing or closed'));
1174
+ };
1175
+ this._pendingListens.add(cancel);
1176
+ server.once('error', failed);
1177
+ server.once('listening', listening);
1178
+ server.on('error', error => log.error(`Server error on port ${port}:`, error.message));
1179
+ try { server.listen(port, hostname); } catch (error) { done(error); }
1180
+ });
1181
+ }
1182
+
1183
+ close() {
1184
+ if (this._closePromise) return this._closePromise;
1185
+ this._closing = true;
1186
+ this._closePromise = this._close();
1187
+ return this._closePromise;
1188
+ }
1189
+
1190
+ async _close() {
1191
+ const errors = [];
1192
+ let timer;
1193
+ const timeoutError = new Error('Roster close timed out');
1194
+ const timeout = new Promise((resolve, reject) => {
1195
+ timer = setTimeout(() => {
1196
+ for (const res of this._responses) res.destroy();
1197
+ for (const socket of this._sockets) socket.destroy();
1198
+ for (const server of this._ownedServers) server.closeAllConnections();
1199
+ reject(timeoutError);
1200
+ }, this.closeTimeoutMs);
1201
+ });
1202
+ const wait = async promise => {
1203
+ try { await Promise.race([promise, timeout]); } catch (error) {
1204
+ if (!errors.includes(error)) errors.push(error);
1205
+ }
1206
+ };
1207
+ try {
1208
+ clearInterval(this._certificateRenewTimer);
1209
+ this._certificateRenewTimer = null;
1210
+ for (const timer of this._retryTimers) clearTimeout(timer);
1211
+ this._retryTimers.clear();
1212
+ for (const cancel of this._pendingListens) cancel();
1213
+ if (this._initTask) await wait(this._initTask.catch(() => {}));
1214
+
1215
+ const draining = [...this._responses].map(res => new Promise(resolve => {
1216
+ const done = () => {
1217
+ res.removeListener('finish', done);
1218
+ res.removeListener('close', done);
1219
+ resolve();
1220
+ };
1221
+ res.once('finish', done);
1222
+ res.once('close', done);
1223
+ }));
1224
+ const closeServer = server => new Promise(resolve => {
1225
+ try {
1226
+ server.close(error => {
1227
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') errors.push(error);
1228
+ resolve();
1229
+ });
1230
+ server.closeIdleConnections();
1231
+ } catch (error) {
1232
+ errors.push(error);
1233
+ resolve();
1234
+ }
1235
+ });
1236
+ // Bun's close() releases its native server handle before upgraded
1237
+ // connections finish, preventing a later closeAllConnections().
1238
+ const closing = isBunRuntime ? [] : [...this._ownedServers].map(closeServer);
1239
+ const virtualServers = Object.values(this._sitesByPort).flatMap(portData => Object.values(portData.virtualServers));
1240
+ for (const server of virtualServers) {
1241
+ try { server.close(); } catch (error) { errors.push(error); }
1242
+ }
1243
+ for (const socket of this._upgradedSockets) socket.destroy();
1244
+ await wait(Promise.all([
1245
+ ...closing, ...draining,
1246
+ Promise.allSettled([...this._certificateChecks.values(), ...this._contextLoads.values()])
1247
+ ]));
1248
+ if (isBunRuntime) {
1249
+ for (const server of this._ownedServers) server.closeAllConnections();
1250
+ await wait(Promise.all([...this._ownedServers].map(closeServer)));
1251
+ }
1252
+
1253
+ const hooks = virtualServers.flatMap(server => server._closeHooks.splice(0).map(hook =>
1254
+ Promise.resolve().then(() => hook())
1255
+ ));
1256
+ await wait(Promise.allSettled(hooks).then(results => {
1257
+ for (const result of results) {
1258
+ if (result.status === 'rejected') errors.push(result.reason);
1259
+ }
1260
+ }));
1261
+ } finally {
1262
+ clearTimeout(timer);
1263
+ for (const [server, handlers] of this._attachments) {
1264
+ server.removeListener('request', handlers.request);
1265
+ server.removeListener('upgrade', handlers.upgrade);
1266
+ }
1267
+ this._attachments.clear();
1268
+ this._secureContexts.clear();
1269
+ this._ownedServers.clear();
1270
+ }
1271
+ if (errors.length) throw new AggregateError(errors, 'Roster close failed');
1272
+ }
1273
+
1002
1274
  requestHandler(port) {
1003
1275
  if (!this._initialized) throw new Error('Call init() before requestHandler()');
1004
1276
  const targetPort = port || this.defaultPort;
@@ -1029,9 +1301,18 @@ class Roster {
1029
1301
  }
1030
1302
 
1031
1303
  attach(server, { port } = {}) {
1304
+ this._assertOpen();
1032
1305
  if (!this._initialized) throw new Error('Call init() before attach()');
1033
- server.on('request', this.requestHandler(port));
1034
- server.on('upgrade', this.upgradeHandler(port));
1306
+ const targetPort = port || this.defaultPort;
1307
+ const existing = this._attachments.get(server);
1308
+ if (existing) {
1309
+ if (existing.port !== targetPort) throw new Error('Server is already attached to another port');
1310
+ return this;
1311
+ }
1312
+ const handlers = { request: this.requestHandler(port), upgrade: this.upgradeHandler(port), port: targetPort };
1313
+ server.on('request', handlers.request);
1314
+ server.on('upgrade', handlers.upgrade);
1315
+ this._attachments.set(server, handlers);
1035
1316
  return this;
1036
1317
  }
1037
1318
 
@@ -1075,7 +1356,7 @@ class Roster {
1075
1356
  });
1076
1357
  }
1077
1358
 
1078
- startLocalMode() {
1359
+ async startLocalMode() {
1079
1360
  this.domainPorts = {};
1080
1361
 
1081
1362
  for (const portData of Object.values(this._sitesByPort)) {
@@ -1088,13 +1369,13 @@ class Roster {
1088
1369
  const appHandler = portData.appHandlers[domain];
1089
1370
 
1090
1371
  const dispatcher = (req, res) => {
1372
+ if (!this._beginRequest(res)) return;
1091
1373
  const host = (req.headers.host || '').split(':')[0].toLowerCase();
1092
1374
  if (this._runRequestPlugins(req, res, { host, domain })) return;
1093
- virtualServer.fallbackHandler = appHandler;
1094
1375
  if (virtualServer.requestListeners.length > 0) {
1095
1376
  virtualServer.processRequest(req, res);
1096
1377
  } else if (appHandler) {
1097
- appHandler(req, res);
1378
+ return invokeRequest(appHandler, req, res);
1098
1379
  } else {
1099
1380
  res.writeHead(404);
1100
1381
  res.end('Site not found');
@@ -1105,17 +1386,14 @@ class Roster {
1105
1386
  this.portServers[port] = httpServer;
1106
1387
 
1107
1388
  httpServer.on('upgrade', (req, socket, head) => {
1389
+ if (this._closing) { socket.destroy(); return; }
1390
+ this._trackUpgrade(socket);
1108
1391
  virtualServer.processUpgrade(req, socket, head);
1109
1392
  });
1110
1393
 
1111
- httpServer.listen(port, 'localhost', () => {
1112
- const cleanDomain = normalizeDomainForLocalHost(domain);
1113
- log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
1114
- });
1115
-
1116
- httpServer.on('error', (error) => {
1117
- log.error(`❌ Error on port ${port} for ${domain}:`, error.message);
1118
- });
1394
+ await this._listen(httpServer, port, 'localhost');
1395
+ const cleanDomain = normalizeDomainForLocalHost(domain);
1396
+ log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
1119
1397
  }
1120
1398
  }
1121
1399
 
@@ -1123,27 +1401,50 @@ class Roster {
1123
1401
  return Promise.resolve();
1124
1402
  }
1125
1403
 
1126
- async start() {
1404
+ start() {
1405
+ if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
1406
+ if (!this._startPromise) {
1407
+ this._startPromise = this._start().catch(async error => {
1408
+ if (!this._closing) {
1409
+ try { await this.close(); } catch (cleanupError) {
1410
+ throw new AggregateError([error, cleanupError], 'Startup and cleanup failed');
1411
+ }
1412
+ }
1413
+ throw error;
1414
+ });
1415
+ }
1416
+ return this._startPromise;
1417
+ }
1418
+
1419
+ async _start() {
1127
1420
  await this.init();
1421
+ this._assertOpen();
1128
1422
 
1129
1423
  if (this.local) {
1130
1424
  return this.startLocalMode();
1131
1425
  }
1132
1426
 
1133
1427
  const greenlockOptions = this._buildGreenlockOptions();
1134
- const greenlockRuntime = GreenlockShim.create(greenlockOptions);
1428
+ const greenlockRuntime = this._greenlockRuntime || GreenlockShim.create(greenlockOptions);
1429
+ this._greenlockRuntime = greenlockRuntime;
1430
+ this._startCertificateRenewLoop();
1135
1431
  const greenlock = Greenlock.init({
1136
1432
  ...greenlockOptions,
1137
- greenlock: greenlockRuntime
1433
+ greenlock: greenlockRuntime,
1434
+ onServerError: error => log.error('Server error:', error.message)
1138
1435
  });
1139
1436
 
1140
- return greenlock.ready(async glx => {
1437
+ if (this.cluster && require('cluster').isPrimary) {
1438
+ return greenlock.ready();
1439
+ }
1440
+ const glx = await new Promise(resolve => greenlock.ready(resolve));
1441
+ this._assertOpen();
1442
+ {
1141
1443
  const httpServer = glx.httpServer();
1142
1444
  const bunTlsHotReloadHandlers = [];
1143
1445
 
1144
- httpServer.listen(80, this.hostname, () => {
1145
- log.info('HTTP server listening on port 80');
1146
- });
1446
+ await this._listen(httpServer, 80, this.hostname);
1447
+ log.info('HTTP server listening on port 80');
1147
1448
 
1148
1449
  for (const [port, portData] of Object.entries(this._sitesByPort)) {
1149
1450
  const portNum = parseInt(port);
@@ -1158,7 +1459,7 @@ class Roster {
1158
1459
  if (pems) return pems;
1159
1460
 
1160
1461
  try {
1161
- await greenlockRuntime.get({ servername: host });
1462
+ await this._checkCertificate(host);
1162
1463
  } catch (error) {
1163
1464
  log.warn(`⚠️ Greenlock issuance failed for ${host}: ${error?.message || error}`);
1164
1465
  }
@@ -1171,7 +1472,7 @@ class Roster {
1171
1472
  if (zone) {
1172
1473
  const bootstrapHost = `bun-bootstrap.${zone}`;
1173
1474
  try {
1174
- await greenlockRuntime.get({ servername: bootstrapHost });
1475
+ await this._checkCertificate(bootstrapHost);
1175
1476
  } catch (error) {
1176
1477
  log.warn(`⚠️ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
1177
1478
  }
@@ -1200,7 +1501,7 @@ class Roster {
1200
1501
  const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
1201
1502
  log.warn(`⚠️ Bun: requesting ${needsWildcard ? 'combined wildcard' : ''} certificate for ${certSubject} via Greenlock before HTTPS bind`);
1202
1503
  try {
1203
- await greenlockRuntime.get({ servername: certSubject });
1504
+ await this._checkCertificate(certSubject);
1204
1505
  } catch (error) {
1205
1506
  log.error(`❌ Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
1206
1507
  }
@@ -1221,23 +1522,28 @@ class Roster {
1221
1522
  if (isBunRuntime) {
1222
1523
  const primaryDomain = Object.keys(portData.virtualServers)[0];
1223
1524
  let defaultPems = await ensureBunDefaultPems(primaryDomain);
1525
+ let defaultContext = tls.createSecureContext(defaultPems);
1224
1526
  httpsServer = https.createServer({
1225
1527
  ...tlsOpts,
1226
1528
  key: defaultPems.key,
1227
1529
  cert: defaultPems.cert,
1228
1530
  SNICallback: (servername, callback) => {
1229
- issueAndReloadPemsForServername(servername)
1230
- .then((pems) => {
1231
- const selected = pems || defaultPems;
1232
- callback(null, tls.createSecureContext({ key: selected.key, cert: selected.cert }));
1233
- })
1234
- .catch(callback);
1531
+ const resolveContext = async () => {
1532
+ const cached = await this._resolveSecureContext(servername);
1533
+ if (cached) return cached;
1534
+ const pems = await issueAndReloadPemsForServername(servername);
1535
+ this._assertOpen();
1536
+ return pems ? this._getSecureContext(servername, false) : defaultContext;
1537
+ };
1538
+ resolveContext().then(context => callback(null, context), callback);
1235
1539
  }
1236
1540
  }, dispatcher);
1237
1541
  const reloadBunDefaultTls = async (servername, reason) => {
1238
1542
  const nextPems = await issueAndReloadPemsForServername(servername);
1239
1543
  if (!nextPems) return false;
1544
+ this._assertOpen();
1240
1545
  defaultPems = nextPems;
1546
+ defaultContext = tls.createSecureContext(defaultPems);
1241
1547
  if (typeof httpsServer.setSecureContext === 'function') {
1242
1548
  try {
1243
1549
  httpsServer.setSecureContext({ key: defaultPems.key, cert: defaultPems.cert });
@@ -1257,34 +1563,20 @@ class Roster {
1257
1563
  this.portServers[portNum] = httpsServer;
1258
1564
  httpsServer.on('upgrade', upgradeHandler);
1259
1565
 
1260
- httpsServer.listen(portNum, this.hostname, () => {
1261
- log.info(`HTTPS server listening on port ${portNum}`);
1262
- });
1566
+ await this._listen(httpsServer, portNum, this.hostname);
1567
+ log.info(`HTTPS server listening on port ${portNum}`);
1263
1568
  } else {
1264
1569
  const httpsOptions = {
1265
1570
  minVersion: this.tlsMinVersion,
1266
1571
  maxVersion: this.tlsMaxVersion,
1267
1572
  SNICallback: (servername, callback) => {
1268
- try {
1269
- const pems = this._resolvePemsForServername(servername);
1270
- if (pems) {
1271
- callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
1272
- } else {
1273
- callback(new Error(`No certificate files available for ${servername}`));
1274
- }
1275
- } catch (error) {
1276
- callback(error);
1277
- }
1573
+ this._getSecureContext(servername, false).then(context => callback(null, context), callback);
1278
1574
  }
1279
1575
  };
1280
1576
 
1281
1577
  const httpsServer = https.createServer(httpsOptions, dispatcher);
1282
1578
  httpsServer.on('upgrade', upgradeHandler);
1283
1579
 
1284
- httpsServer.on('error', (error) => {
1285
- log.error(`HTTPS server error on port ${portNum}:`, error.message);
1286
- });
1287
-
1288
1580
  httpsServer.on('tlsClientError', (error) => {
1289
1581
  if (!error.message.includes('http request')) {
1290
1582
  log.error(`TLS error on port ${portNum}:`, error.message);
@@ -1293,13 +1585,8 @@ class Roster {
1293
1585
 
1294
1586
  this.portServers[portNum] = httpsServer;
1295
1587
 
1296
- httpsServer.listen(portNum, this.hostname, (error) => {
1297
- if (error) {
1298
- log.error(`Failed to start HTTPS server on port ${portNum}:`, error.message);
1299
- } else {
1300
- log.info(`HTTPS server listening on port ${portNum}`);
1301
- }
1302
- });
1588
+ await this._listen(httpsServer, portNum, this.hostname);
1589
+ log.info(`HTTPS server listening on port ${portNum}`);
1303
1590
  }
1304
1591
  }
1305
1592
 
@@ -1314,6 +1601,7 @@ class Roster {
1314
1601
  for (const zone of this.wildcardZones) {
1315
1602
  const bootstrapHost = `bun-bootstrap.${zone}`;
1316
1603
  const attemptPrewarm = async (attempt = 1) => {
1604
+ if (this._closing) return;
1317
1605
  try {
1318
1606
  log.warn(`⚠️ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
1319
1607
  let reloaded = false;
@@ -1330,16 +1618,20 @@ class Roster {
1330
1618
  log.warn(`⚠️ Bun wildcard prewarm stopped for ${zone} after ${attempt} attempts`);
1331
1619
  return;
1332
1620
  }
1333
- setTimeout(() => {
1621
+ if (this._closing) return;
1622
+ const timer = setTimeout(() => {
1623
+ this._retryTimers.delete(timer);
1334
1624
  attemptPrewarm(attempt + 1).catch(() => {});
1335
1625
  }, retryDelayMs);
1626
+ this._retryTimers.add(timer);
1336
1627
  }
1337
1628
  };
1338
1629
 
1339
1630
  attemptPrewarm().catch(() => {});
1340
1631
  }
1341
1632
  }
1342
- });
1633
+ }
1634
+ return this;
1343
1635
  }
1344
1636
  }
1345
1637