roster-server 2.4.12 → 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';
@@ -327,6 +373,7 @@ class Roster {
327
373
  .filter(dirent => dirent.isDirectory());
328
374
 
329
375
  for (const dirent of sites) {
376
+ this._assertOpen();
330
377
  const domain = dirent.name;
331
378
  const domainPath = path.join(this.wwwPath, domain);
332
379
 
@@ -343,6 +390,8 @@ class Roster {
343
390
  continue;
344
391
  }
345
392
 
393
+ this._assertOpen();
394
+
346
395
  const { siteApp, type } = resolved;
347
396
 
348
397
  if (domain.startsWith('*.')) {
@@ -528,7 +577,7 @@ class Roster {
528
577
  getHandlerForPortData(host, portData) {
529
578
  const virtualServer = portData.virtualServers[host];
530
579
  const appHandler = portData.appHandlers[host];
531
- if (virtualServer && appHandler !== undefined) return { virtualServer, appHandler };
580
+ if (Object.hasOwn(portData.virtualServers, host)) return { virtualServer, appHandler };
532
581
  for (const key of Object.keys(portData.appHandlers)) {
533
582
  if (key.startsWith('*.') && hostMatchesWildcard(host, key)) {
534
583
  return {
@@ -541,6 +590,7 @@ class Roster {
541
590
  }
542
591
 
543
592
  handleRequest(req, res) {
593
+ if (!this._beginRequest(res)) return;
544
594
  const host = req.headers.host || '';
545
595
  const hostWithoutPort = host.split(':')[0];
546
596
  const normalizedHost = hostWithoutPort.toLowerCase();
@@ -557,7 +607,7 @@ class Roster {
557
607
 
558
608
  const siteApp = this.getHandlerForHost(hostWithoutPort);
559
609
  if (siteApp) {
560
- siteApp(req, res);
610
+ return invokeRequest(siteApp, req, res);
561
611
  } else {
562
612
  res.writeHead(404);
563
613
  res.end('Site not found');
@@ -565,6 +615,7 @@ class Roster {
565
615
  }
566
616
 
567
617
  register(domainString, requestHandler) {
618
+ this._assertOpen();
568
619
  if (!domainString) {
569
620
  throw new Error('Domain is required');
570
621
  }
@@ -604,6 +655,7 @@ class Roster {
604
655
  }
605
656
 
606
657
  use(plugin) {
658
+ this._assertOpen();
607
659
  if (typeof plugin !== 'function') {
608
660
  throw new Error('plugin must be a function');
609
661
  }
@@ -612,14 +664,20 @@ class Roster {
612
664
  }
613
665
 
614
666
  _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');
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;
619
675
  }
620
- if (handled === true) return true;
676
+ return false;
677
+ } catch (error) {
678
+ requestError(error, res);
679
+ return true;
621
680
  }
622
- return false;
623
681
  }
624
682
 
625
683
  parseDomainWithPort(domainString) {
@@ -664,6 +722,9 @@ class Roster {
664
722
 
665
723
  // Assign port to domain, detecting collisions with already assigned ports
666
724
  assignPortToDomain(domain) {
725
+ if (this.assignedPorts.size >= this.maxLocalPort - this.minLocalPort + 1) {
726
+ throw new Error('Local port range is exhausted');
727
+ }
667
728
  let port = domainToPort(domain, this.minLocalPort, this.maxLocalPort);
668
729
 
669
730
  // If port is already assigned to another domain, increment until we find a free one
@@ -735,6 +796,7 @@ class Roster {
735
796
  _initSiteHandlers() {
736
797
  this._sitesByPort = {};
737
798
  for (const [hostKey, siteApp] of Object.entries(this.sites)) {
799
+ this._assertOpen();
738
800
  if (hostKey.startsWith('www.')) continue;
739
801
  const { domain, port } = this.parseDomainWithPort(hostKey);
740
802
  if (!this._sitesByPort[port]) {
@@ -748,7 +810,13 @@ class Roster {
748
810
  this._sitesByPort[port].virtualServers[domain] = virtualServer;
749
811
  this.domainServers[domain] = virtualServer;
750
812
 
751
- 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);
752
820
  this._sitesByPort[port].appHandlers[domain] = appHandler;
753
821
  if (!domain.startsWith('*.')) {
754
822
  this._sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
@@ -758,6 +826,7 @@ class Roster {
758
826
 
759
827
  _createDispatcher(portData) {
760
828
  return (req, res) => {
829
+ if (!this._beginRequest(res)) return;
761
830
  const host = req.headers.host || '';
762
831
  const hostWithoutPort = host.split(':')[0].toLowerCase();
763
832
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
@@ -766,7 +835,7 @@ class Roster {
766
835
 
767
836
  if (hostWithoutPort.startsWith('www.')) {
768
837
  const protocol = this.local ? 'http' : 'https';
769
- res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
838
+ res.writeHead(301, { Location: `${protocol}://${host.toLowerCase().slice(4)}${req.url}` });
770
839
  res.end();
771
840
  return;
772
841
  }
@@ -780,10 +849,9 @@ class Roster {
780
849
  const { virtualServer, appHandler } = resolved;
781
850
 
782
851
  if (virtualServer && virtualServer.requestListeners.length > 0) {
783
- virtualServer.fallbackHandler = appHandler;
784
852
  virtualServer.processRequest(req, res);
785
853
  } else if (appHandler) {
786
- appHandler(req, res);
854
+ return invokeRequest(appHandler, req, res);
787
855
  } else {
788
856
  res.writeHead(404);
789
857
  res.end('Site not found');
@@ -791,8 +859,16 @@ class Roster {
791
859
  };
792
860
  }
793
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
+
794
868
  _createUpgradeHandler(portData) {
795
869
  return (req, socket, head) => {
870
+ if (this._closing) { socket.destroy(); return; }
871
+ this._trackUpgrade(socket);
796
872
  const host = req.headers.host || '';
797
873
  const hostWithoutPort = host.split(':')[0].toLowerCase();
798
874
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
@@ -806,38 +882,69 @@ class Roster {
806
882
  };
807
883
  }
808
884
 
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;
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);
821
893
  }
894
+ const context = await pending;
895
+ if (context) return context;
896
+ }
897
+ return null;
898
+ }
822
899
 
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
- }
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
+ }
828
921
 
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
- });
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);
841
948
  };
842
949
  }
843
950
 
@@ -850,6 +957,7 @@ class Roster {
850
957
  staging: this.staging,
851
958
  skipDryRun: this.skipLocalCheck,
852
959
  skipChallengeTest: this.skipLocalCheck,
960
+ renew: false,
853
961
  notify: (event, details) => {
854
962
  const eventDomain = (() => {
855
963
  if (!details || typeof details !== 'object') return null;
@@ -923,12 +1031,12 @@ class Roster {
923
1031
  }
924
1032
 
925
1033
  _startCertificateRenewLoop() {
926
- if (!this._greenlockRuntime || this._certificateRenewTimer) return;
1034
+ if (this._closing || !this._greenlockRuntime || this._certificateRenewTimer) return;
927
1035
  const subjects = this._getManagedCertificateSubjects();
928
1036
  if (subjects.length === 0) return;
929
1037
  this._certificateRenewTimer = setInterval(() => {
930
1038
  subjects.forEach((subject) => {
931
- this._greenlockRuntime.get({ servername: subject }).catch((error) => {
1039
+ this._checkCertificate(subject).catch((error) => {
932
1040
  log.warn(`⚠️ Certificate renew check failed for ${subject}: ${error?.message || error}`);
933
1041
  });
934
1042
  });
@@ -939,6 +1047,7 @@ class Roster {
939
1047
  }
940
1048
 
941
1049
  async ensureCertificate(servername) {
1050
+ this._assertOpen();
942
1051
  if (this.local) {
943
1052
  throw new Error('ensureCertificate() is not available in local mode');
944
1053
  }
@@ -954,7 +1063,7 @@ class Roster {
954
1063
  if (!this._greenlockRuntime) {
955
1064
  throw new Error('autoCertificates is disabled; enable { autoCertificates: true } to issue certificates automatically');
956
1065
  }
957
- await this._greenlockRuntime.get({ servername: normalizedServername });
1066
+ await this._checkCertificate(normalizedServername);
958
1067
  pems = this._resolvePemsForServername(normalizedServername);
959
1068
  if (!pems) {
960
1069
  throw new Error(`Certificate issuance completed but no PEM files were found for ${normalizedServername}`);
@@ -963,6 +1072,7 @@ class Roster {
963
1072
  }
964
1073
 
965
1074
  loadCertificate(servername) {
1075
+ this._assertOpen();
966
1076
  if (this.local) {
967
1077
  throw new Error('loadCertificate() is not available in local mode');
968
1078
  }
@@ -980,9 +1090,28 @@ class Roster {
980
1090
  return pems;
981
1091
  }
982
1092
 
983
- async init() {
984
- 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() {
985
1113
  await this.loadSites();
1114
+ this._assertOpen();
986
1115
  if (!this.local) {
987
1116
  this.generateConfigJson();
988
1117
  if (this.autoCertificates) {
@@ -990,6 +1119,7 @@ class Roster {
990
1119
  }
991
1120
  }
992
1121
  this._initSiteHandlers();
1122
+ this._assertOpen();
993
1123
  if (!this.local) {
994
1124
  this._initSniResolver();
995
1125
  if (this.autoCertificates) {
@@ -1000,6 +1130,147 @@ class Roster {
1000
1130
  return this;
1001
1131
  }
1002
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
+
1003
1274
  requestHandler(port) {
1004
1275
  if (!this._initialized) throw new Error('Call init() before requestHandler()');
1005
1276
  const targetPort = port || this.defaultPort;
@@ -1030,9 +1301,18 @@ class Roster {
1030
1301
  }
1031
1302
 
1032
1303
  attach(server, { port } = {}) {
1304
+ this._assertOpen();
1033
1305
  if (!this._initialized) throw new Error('Call init() before attach()');
1034
- server.on('request', this.requestHandler(port));
1035
- 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);
1036
1316
  return this;
1037
1317
  }
1038
1318
 
@@ -1076,7 +1356,7 @@ class Roster {
1076
1356
  });
1077
1357
  }
1078
1358
 
1079
- startLocalMode() {
1359
+ async startLocalMode() {
1080
1360
  this.domainPorts = {};
1081
1361
 
1082
1362
  for (const portData of Object.values(this._sitesByPort)) {
@@ -1089,13 +1369,13 @@ class Roster {
1089
1369
  const appHandler = portData.appHandlers[domain];
1090
1370
 
1091
1371
  const dispatcher = (req, res) => {
1372
+ if (!this._beginRequest(res)) return;
1092
1373
  const host = (req.headers.host || '').split(':')[0].toLowerCase();
1093
1374
  if (this._runRequestPlugins(req, res, { host, domain })) return;
1094
- virtualServer.fallbackHandler = appHandler;
1095
1375
  if (virtualServer.requestListeners.length > 0) {
1096
1376
  virtualServer.processRequest(req, res);
1097
1377
  } else if (appHandler) {
1098
- appHandler(req, res);
1378
+ return invokeRequest(appHandler, req, res);
1099
1379
  } else {
1100
1380
  res.writeHead(404);
1101
1381
  res.end('Site not found');
@@ -1106,17 +1386,14 @@ class Roster {
1106
1386
  this.portServers[port] = httpServer;
1107
1387
 
1108
1388
  httpServer.on('upgrade', (req, socket, head) => {
1389
+ if (this._closing) { socket.destroy(); return; }
1390
+ this._trackUpgrade(socket);
1109
1391
  virtualServer.processUpgrade(req, socket, head);
1110
1392
  });
1111
1393
 
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
- });
1394
+ await this._listen(httpServer, port, 'localhost');
1395
+ const cleanDomain = normalizeDomainForLocalHost(domain);
1396
+ log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
1120
1397
  }
1121
1398
  }
1122
1399
 
@@ -1124,27 +1401,50 @@ class Roster {
1124
1401
  return Promise.resolve();
1125
1402
  }
1126
1403
 
1127
- 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() {
1128
1420
  await this.init();
1421
+ this._assertOpen();
1129
1422
 
1130
1423
  if (this.local) {
1131
1424
  return this.startLocalMode();
1132
1425
  }
1133
1426
 
1134
1427
  const greenlockOptions = this._buildGreenlockOptions();
1135
- const greenlockRuntime = GreenlockShim.create(greenlockOptions);
1428
+ const greenlockRuntime = this._greenlockRuntime || GreenlockShim.create(greenlockOptions);
1429
+ this._greenlockRuntime = greenlockRuntime;
1430
+ this._startCertificateRenewLoop();
1136
1431
  const greenlock = Greenlock.init({
1137
1432
  ...greenlockOptions,
1138
- greenlock: greenlockRuntime
1433
+ greenlock: greenlockRuntime,
1434
+ onServerError: error => log.error('Server error:', error.message)
1139
1435
  });
1140
1436
 
1141
- 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
+ {
1142
1443
  const httpServer = glx.httpServer();
1143
1444
  const bunTlsHotReloadHandlers = [];
1144
1445
 
1145
- httpServer.listen(80, this.hostname, () => {
1146
- log.info('HTTP server listening on port 80');
1147
- });
1446
+ await this._listen(httpServer, 80, this.hostname);
1447
+ log.info('HTTP server listening on port 80');
1148
1448
 
1149
1449
  for (const [port, portData] of Object.entries(this._sitesByPort)) {
1150
1450
  const portNum = parseInt(port);
@@ -1159,7 +1459,7 @@ class Roster {
1159
1459
  if (pems) return pems;
1160
1460
 
1161
1461
  try {
1162
- await greenlockRuntime.get({ servername: host });
1462
+ await this._checkCertificate(host);
1163
1463
  } catch (error) {
1164
1464
  log.warn(`⚠️ Greenlock issuance failed for ${host}: ${error?.message || error}`);
1165
1465
  }
@@ -1172,7 +1472,7 @@ class Roster {
1172
1472
  if (zone) {
1173
1473
  const bootstrapHost = `bun-bootstrap.${zone}`;
1174
1474
  try {
1175
- await greenlockRuntime.get({ servername: bootstrapHost });
1475
+ await this._checkCertificate(bootstrapHost);
1176
1476
  } catch (error) {
1177
1477
  log.warn(`⚠️ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
1178
1478
  }
@@ -1201,7 +1501,7 @@ class Roster {
1201
1501
  const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
1202
1502
  log.warn(`⚠️ Bun: requesting ${needsWildcard ? 'combined wildcard' : ''} certificate for ${certSubject} via Greenlock before HTTPS bind`);
1203
1503
  try {
1204
- await greenlockRuntime.get({ servername: certSubject });
1504
+ await this._checkCertificate(certSubject);
1205
1505
  } catch (error) {
1206
1506
  log.error(`❌ Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
1207
1507
  }
@@ -1222,23 +1522,28 @@ class Roster {
1222
1522
  if (isBunRuntime) {
1223
1523
  const primaryDomain = Object.keys(portData.virtualServers)[0];
1224
1524
  let defaultPems = await ensureBunDefaultPems(primaryDomain);
1525
+ let defaultContext = tls.createSecureContext(defaultPems);
1225
1526
  httpsServer = https.createServer({
1226
1527
  ...tlsOpts,
1227
1528
  key: defaultPems.key,
1228
1529
  cert: defaultPems.cert,
1229
1530
  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);
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);
1236
1539
  }
1237
1540
  }, dispatcher);
1238
1541
  const reloadBunDefaultTls = async (servername, reason) => {
1239
1542
  const nextPems = await issueAndReloadPemsForServername(servername);
1240
1543
  if (!nextPems) return false;
1544
+ this._assertOpen();
1241
1545
  defaultPems = nextPems;
1546
+ defaultContext = tls.createSecureContext(defaultPems);
1242
1547
  if (typeof httpsServer.setSecureContext === 'function') {
1243
1548
  try {
1244
1549
  httpsServer.setSecureContext({ key: defaultPems.key, cert: defaultPems.cert });
@@ -1258,34 +1563,20 @@ class Roster {
1258
1563
  this.portServers[portNum] = httpsServer;
1259
1564
  httpsServer.on('upgrade', upgradeHandler);
1260
1565
 
1261
- httpsServer.listen(portNum, this.hostname, () => {
1262
- log.info(`HTTPS server listening on port ${portNum}`);
1263
- });
1566
+ await this._listen(httpsServer, portNum, this.hostname);
1567
+ log.info(`HTTPS server listening on port ${portNum}`);
1264
1568
  } else {
1265
1569
  const httpsOptions = {
1266
1570
  minVersion: this.tlsMinVersion,
1267
1571
  maxVersion: this.tlsMaxVersion,
1268
1572
  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
- }
1573
+ this._getSecureContext(servername, false).then(context => callback(null, context), callback);
1279
1574
  }
1280
1575
  };
1281
1576
 
1282
1577
  const httpsServer = https.createServer(httpsOptions, dispatcher);
1283
1578
  httpsServer.on('upgrade', upgradeHandler);
1284
1579
 
1285
- httpsServer.on('error', (error) => {
1286
- log.error(`HTTPS server error on port ${portNum}:`, error.message);
1287
- });
1288
-
1289
1580
  httpsServer.on('tlsClientError', (error) => {
1290
1581
  if (!error.message.includes('http request')) {
1291
1582
  log.error(`TLS error on port ${portNum}:`, error.message);
@@ -1294,13 +1585,8 @@ class Roster {
1294
1585
 
1295
1586
  this.portServers[portNum] = httpsServer;
1296
1587
 
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
- });
1588
+ await this._listen(httpsServer, portNum, this.hostname);
1589
+ log.info(`HTTPS server listening on port ${portNum}`);
1304
1590
  }
1305
1591
  }
1306
1592
 
@@ -1315,6 +1601,7 @@ class Roster {
1315
1601
  for (const zone of this.wildcardZones) {
1316
1602
  const bootstrapHost = `bun-bootstrap.${zone}`;
1317
1603
  const attemptPrewarm = async (attempt = 1) => {
1604
+ if (this._closing) return;
1318
1605
  try {
1319
1606
  log.warn(`⚠️ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
1320
1607
  let reloaded = false;
@@ -1331,16 +1618,20 @@ class Roster {
1331
1618
  log.warn(`⚠️ Bun wildcard prewarm stopped for ${zone} after ${attempt} attempts`);
1332
1619
  return;
1333
1620
  }
1334
- setTimeout(() => {
1621
+ if (this._closing) return;
1622
+ const timer = setTimeout(() => {
1623
+ this._retryTimers.delete(timer);
1335
1624
  attemptPrewarm(attempt + 1).catch(() => {});
1336
1625
  }, retryDelayMs);
1626
+ this._retryTimers.add(timer);
1337
1627
  }
1338
1628
  };
1339
1629
 
1340
1630
  attemptPrewarm().catch(() => {});
1341
1631
  }
1342
1632
  }
1343
- });
1633
+ }
1634
+ return this;
1344
1635
  }
1345
1636
  }
1346
1637