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/README.md +192 -404
- package/index.js +417 -126
- package/lib/static-site-handler.js +36 -27
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +50 -338
- package/test/https-integration.test.js +149 -0
- package/test/lifecycle.test.js +469 -0
- package/test/roster-server.test.js +22 -22
- package/test/scanner-blocker.test.js +3 -6
- package/test/static-and-tls.test.js +273 -0
- package/vendor/greenlock-express/greenlock-express.js +1 -1
- package/vendor/greenlock-express/servers.js +2 -1
- package/vendor/greenlock-express/single.js +1 -1
- package/vendor/greenlock-express/worker.js +2 -2
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
|
-
}
|
|
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() {
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}://${
|
|
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
|
|
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
|
-
|
|
810
|
-
this.
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
984
|
-
if (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
|
-
|
|
1035
|
-
|
|
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
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1231
|
-
.
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
.
|
|
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
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
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
|
-
|
|
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
|
|