roster-server 2.3.14 β†’ 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -350,6 +350,62 @@ console.log(customRoster.getUrl('api.example.com'));
350
350
  // β†’ https://api.example.com:8443
351
351
  ```
352
352
 
353
+ ## πŸ”€ External Clusters / Sticky Runtimes
354
+
355
+ If your runtime already owns `server.listen(...)` (for example sticky-session workers in Node cluster, PM2, or custom LB workers), you can still use Roster's domain routing, virtual servers, and Socket.IO-compatible upgrade flow without calling `roster.start()`.
356
+
357
+ ### Ownership Model
358
+
359
+ - `roster.start()` = Roster-owned HTTP/HTTPS lifecycle (Greenlock, listeners, ports)
360
+ - `roster.buildRuntimeRouter()` = externally-owned server lifecycle (you call `listen`)
361
+
362
+ ### Minimal Worker Example
363
+
364
+ ```javascript
365
+ import http from 'http';
366
+ import Roster from 'roster-server';
367
+
368
+ const roster = new Roster({ local: false, port: 443 });
369
+
370
+ roster.register('example.com', (virtualServer) => {
371
+ return (req, res) => {
372
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
373
+ res.end('hello from example.com');
374
+ };
375
+ });
376
+
377
+ roster.register('*.example.com', (virtualServer) => {
378
+ // Socket.IO (or raw WS) can bind to virtualServer "upgrade" listeners here.
379
+ return (req, res) => {
380
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
381
+ res.end('hello from wildcard');
382
+ };
383
+ });
384
+
385
+ const server = http.createServer();
386
+ const router = roster.buildRuntimeRouter({
387
+ targetPort: 443, // optional, defaults to roster.defaultPort
388
+ hostAliases: {
389
+ localhost: 'example.com'
390
+ }, // optional map, or callback: (host) => mappedHost
391
+ allowWwwRedirect: true // optional, defaults to true
392
+ });
393
+
394
+ router.attach(server);
395
+
396
+ // External runtime controls binding/lifecycle:
397
+ server.listen(3000);
398
+ ```
399
+
400
+ ### API Notes
401
+
402
+ - `roster.prepareSites(options?)`: builds virtual servers + app handlers without listening.
403
+ - `roster.buildRuntimeRouter(options?)` returns:
404
+ - `attach(server)` to bind `request` + `upgrade`
405
+ - `dispatchRequest(req, res)` for manual request dispatch
406
+ - `dispatchUpgrade(req, socket, head)` for manual upgrade dispatch
407
+ - `portData` + `diagnostics` snapshots for debugging
408
+
353
409
  ## πŸ§‚ A Touch of Magic
354
410
 
355
411
  You might be thinking, "But setting up HTTPS and virtual hosts is supposed to be complicated and time-consuming!" Well, not anymore. With RosterServer, you can get back to writing code that matters, like defending Earth from alien invaders! πŸ‘ΎπŸ‘ΎπŸ‘Ύ
@@ -0,0 +1,12 @@
1
+ ---
2
+ id: zezv04723c
3
+ type: decision
4
+ title: 'Decision: External runtime router API'
5
+ created: '2026-03-20 16:17:36'
6
+ ---
7
+ # Decision: External runtime router API
8
+
9
+ **What**: Added `buildRuntimeRouter(options)` and `prepareSites(options)` so cluster/sticky runtimes can use Roster host dispatch + VirtualServer + upgrade routing without calling `start()`.
10
+ **Where**: `index.js` (`prepareSites`, `buildRuntimeRouter`, extracted request/upgrade dispatcher helpers).
11
+ **Why**: Existing consumers had to duplicate internal `start()` dispatcher logic to integrate with externally-managed `listen()` lifecycles.
12
+ **Alternatives rejected**: Auto-starting hidden servers from `buildRuntimeRouter` (rejected to keep ownership explicit and avoid side effects).
@@ -0,0 +1,7 @@
1
+ ---
2
+ id: 47bowe7gen
3
+ type: decision
4
+ title: Pushed ACME retry commit
5
+ created: '2026-03-17 11:47:46'
6
+ ---
7
+ Committed and pushed master commit ecb2723. Changes: added retry loop (3 attempts with incremental backoff) around ACME directory init in vendor/greenlock/greenlock.js and updated package-lock.json version from 2.2.10 to 2.2.12.
@@ -0,0 +1,12 @@
1
+ ---
2
+ id: jgoo52m5h0
3
+ type: pattern
4
+ title: 'Pattern: Reusable host dispatch internals'
5
+ created: '2026-03-20 16:17:36'
6
+ ---
7
+ # Pattern: Reusable host dispatch internals
8
+
9
+ **What**: Extracted shared host routing pieces from `start()` into reusable methods (`resolveRoutedHost`, `createPortRequestDispatcher`, `createPortUpgradeDispatcher`, `prepareSites`).
10
+ **Where used**: `start()` production path and new `buildRuntimeRouter()` path.
11
+ **When to apply**: Any future feature needing Roster host routing in non-Greenlock server lifecycles or custom server ownership.
12
+ **Notes**: `register()` now supports `{ silent, skipDomainBookkeeping }` for worker-safe registration and reduced duplicate log noise.
package/index.js CHANGED
@@ -109,6 +109,19 @@ function parseBooleanFlag(value, fallback = false) {
109
109
  return fallback;
110
110
  }
111
111
 
112
+ function normalizeHostInput(value) {
113
+ if (typeof value === 'string') return value;
114
+ if (!value || typeof value !== 'object') return '';
115
+ if (typeof value.servername === 'string') return value.servername;
116
+ if (typeof value.hostname === 'string') return value.hostname;
117
+ if (typeof value.subject === 'string') return value.subject;
118
+ return '';
119
+ }
120
+
121
+ function hostWithoutPort(value) {
122
+ return normalizeHostInput(value).split(':')[0].trim().toLowerCase();
123
+ }
124
+
112
125
  function normalizeDomainForLocalHost(domain) {
113
126
  return (domain || '').trim().toLowerCase().replace(/^www\./, '');
114
127
  }
@@ -549,13 +562,21 @@ class Roster {
549
562
  }
550
563
  }
551
564
 
552
- register(domainString, requestHandler) {
565
+ /**
566
+ * Register a domain handler.
567
+ * @param {string} domainString Domain, optionally with :port.
568
+ * @param {(virtualServer: VirtualServer) => Function} requestHandler Site app factory.
569
+ * @param {{ silent?: boolean, skipDomainBookkeeping?: boolean }} [options]
570
+ * @returns {Roster}
571
+ */
572
+ register(domainString, requestHandler, options = {}) {
553
573
  if (!domainString) {
554
574
  throw new Error('Domain is required');
555
575
  }
556
576
  if (typeof requestHandler !== 'function') {
557
577
  throw new Error('requestHandler must be a function');
558
578
  }
579
+ const { silent = false, skipDomainBookkeeping = false } = options || {};
559
580
 
560
581
  const { domain, port } = this.parseDomainWithPort(domainString);
561
582
 
@@ -565,11 +586,13 @@ class Roster {
565
586
  return this;
566
587
  }
567
588
  const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
568
- this.domains.push(domain);
589
+ if (!skipDomainBookkeeping) this.domains.push(domain);
569
590
  this.sites[domainKey] = requestHandler;
570
591
  const root = wildcardRoot(domain);
571
592
  if (root) this.wildcardZones.add(root);
572
- log.info(`(βœ”) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
593
+ if (!silent) {
594
+ log.info(`(βœ”) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
595
+ }
573
596
  return this;
574
597
  }
575
598
 
@@ -578,13 +601,17 @@ class Roster {
578
601
  domainEntries.push(`www.${domain}`);
579
602
  }
580
603
 
581
- this.domains.push(...domainEntries);
604
+ if (!skipDomainBookkeeping) {
605
+ this.domains.push(...domainEntries);
606
+ }
582
607
  domainEntries.forEach(d => {
583
608
  const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
584
609
  this.sites[domainKey] = requestHandler;
585
610
  });
586
611
 
587
- log.info(`(βœ”) Registered site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
612
+ if (!silent) {
613
+ log.info(`(βœ”) Registered site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
614
+ }
588
615
  return this;
589
616
  }
590
617
 
@@ -628,6 +655,151 @@ class Roster {
628
655
  return new VirtualServer(domain);
629
656
  }
630
657
 
658
+ resolveRoutedHost(rawHost, hostAliases) {
659
+ const normalizedHost = hostWithoutPort(rawHost);
660
+ if (!normalizedHost) {
661
+ return {
662
+ normalizedHost: '',
663
+ routedHost: '',
664
+ isWww: false
665
+ };
666
+ }
667
+
668
+ let aliasedHost = normalizedHost;
669
+ if (typeof hostAliases === 'function') {
670
+ const mapped = hostAliases(normalizedHost);
671
+ if (mapped) aliasedHost = hostWithoutPort(mapped);
672
+ } else if (hostAliases && typeof hostAliases === 'object') {
673
+ const mapped = hostAliases[normalizedHost];
674
+ if (mapped) aliasedHost = hostWithoutPort(mapped);
675
+ }
676
+
677
+ const isWww = aliasedHost.startsWith('www.');
678
+ return {
679
+ normalizedHost: aliasedHost,
680
+ routedHost: isWww ? aliasedHost.slice(4) : aliasedHost,
681
+ isWww
682
+ };
683
+ }
684
+
685
+ createPortRequestDispatcher(portData, options = {}) {
686
+ const {
687
+ hostAliases,
688
+ allowWwwRedirect = true
689
+ } = options;
690
+
691
+ return (req, res) => {
692
+ const { normalizedHost, routedHost, isWww } = this.resolveRoutedHost(req.headers.host || '', hostAliases);
693
+
694
+ if (allowWwwRedirect && isWww) {
695
+ res.writeHead(301, { Location: `https://${routedHost}${req.url}` });
696
+ res.end();
697
+ return;
698
+ }
699
+
700
+ const resolved = this.getHandlerForPortData(routedHost, portData);
701
+ if (!resolved) {
702
+ res.writeHead(404);
703
+ res.end('Site not found');
704
+ return;
705
+ }
706
+ const { virtualServer, appHandler } = resolved;
707
+
708
+ if (virtualServer && virtualServer.requestListeners.length > 0) {
709
+ virtualServer.fallbackHandler = appHandler;
710
+ virtualServer.processRequest(req, res);
711
+ } else if (appHandler) {
712
+ appHandler(req, res);
713
+ } else {
714
+ res.writeHead(404);
715
+ res.end('Site not found');
716
+ }
717
+ };
718
+ }
719
+
720
+ createPortUpgradeDispatcher(portData, options = {}) {
721
+ const { hostAliases } = options;
722
+ return (req, socket, head) => {
723
+ const { routedHost } = this.resolveRoutedHost(req.headers.host || '', hostAliases);
724
+ const resolved = this.getHandlerForPortData(routedHost, portData);
725
+ if (resolved && resolved.virtualServer) {
726
+ resolved.virtualServer.processUpgrade(req, socket, head);
727
+ } else {
728
+ socket.destroy();
729
+ }
730
+ };
731
+ }
732
+
733
+ /**
734
+ * Prepare port-based virtual server/app handler data without listening.
735
+ * @param {{ targetPort?: number }} [options]
736
+ * @returns {{ sitesByPort: Record<number, { virtualServers: Record<string, VirtualServer>, appHandlers: Record<string, Function> }> }}
737
+ */
738
+ prepareSites(options = {}) {
739
+ const { targetPort } = options;
740
+ const sitesByPort = {};
741
+
742
+ for (const [hostKey, siteApp] of Object.entries(this.sites)) {
743
+ if (hostKey.startsWith('www.')) continue;
744
+
745
+ const { domain, port } = this.parseDomainWithPort(hostKey);
746
+ if (targetPort !== undefined && port !== targetPort) continue;
747
+
748
+ if (!sitesByPort[port]) {
749
+ sitesByPort[port] = {
750
+ virtualServers: {},
751
+ appHandlers: {}
752
+ };
753
+ }
754
+
755
+ const virtualServer = this.createVirtualServer(domain);
756
+ sitesByPort[port].virtualServers[domain] = virtualServer;
757
+ this.domainServers[domain] = virtualServer;
758
+
759
+ const appHandler = siteApp(virtualServer);
760
+ sitesByPort[port].appHandlers[domain] = appHandler;
761
+ if (!domain.startsWith('*.')) {
762
+ sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
763
+ }
764
+ }
765
+
766
+ return { sitesByPort };
767
+ }
768
+
769
+ /**
770
+ * Build a router that can be attached to externally managed servers (e.g. sticky-session workers).
771
+ * This does not call listen() and does not boot Greenlock.
772
+ * @param {{ targetPort?: number, hostAliases?: Record<string, string>|((host: string) => string|undefined), allowWwwRedirect?: boolean }} [options]
773
+ * @returns {{ attach: (server: import('http').Server) => import('http').Server, dispatchRequest: Function, dispatchUpgrade: Function, portData: { virtualServers: Record<string, VirtualServer>, appHandlers: Record<string, Function> }, diagnostics: { targetPort: number, hosts: string[], virtualServers: string[] } }}
774
+ */
775
+ buildRuntimeRouter(options = {}) {
776
+ const {
777
+ targetPort = this.defaultPort,
778
+ hostAliases,
779
+ allowWwwRedirect = true
780
+ } = options;
781
+ const { sitesByPort } = this.prepareSites({ targetPort });
782
+ const portData = sitesByPort[targetPort] || { virtualServers: {}, appHandlers: {} };
783
+ const dispatchRequest = this.createPortRequestDispatcher(portData, { hostAliases, allowWwwRedirect });
784
+ const dispatchUpgrade = this.createPortUpgradeDispatcher(portData, { hostAliases });
785
+
786
+ return {
787
+ attach: (server) => {
788
+ server.on('request', dispatchRequest);
789
+ server.on('upgrade', dispatchUpgrade);
790
+ return server;
791
+ },
792
+ dispatchRequest,
793
+ dispatchUpgrade,
794
+ portData,
795
+ diagnostics: {
796
+ targetPort,
797
+ hosts: Object.keys(portData.appHandlers),
798
+ virtualServers: Object.keys(portData.virtualServers)
799
+ }
800
+ };
801
+ }
802
+
631
803
  // Assign port to domain, detecting collisions with already assigned ports
632
804
  assignPortToDomain(domain) {
633
805
  let port = domainToPort(domain, this.minLocalPort, this.maxLocalPort);
@@ -814,100 +986,20 @@ class Roster {
814
986
 
815
987
  return greenlock.ready(async glx => {
816
988
  const httpServer = glx.httpServer();
817
-
818
- // Group sites by port
819
- const sitesByPort = {};
820
- for (const [hostKey, siteApp] of Object.entries(this.sites)) {
821
- if (!hostKey.startsWith('www.')) {
822
- const { domain, port } = this.parseDomainWithPort(hostKey);
823
- if (!sitesByPort[port]) {
824
- sitesByPort[port] = {
825
- virtualServers: {},
826
- appHandlers: {}
827
- };
828
- }
829
-
830
- const virtualServer = this.createVirtualServer(domain);
831
- sitesByPort[port].virtualServers[domain] = virtualServer;
832
- this.domainServers[domain] = virtualServer;
833
-
834
- const appHandler = siteApp(virtualServer);
835
- sitesByPort[port].appHandlers[domain] = appHandler;
836
- if (!domain.startsWith('*.')) {
837
- sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
838
- }
839
- }
840
- }
989
+ const { sitesByPort } = this.prepareSites();
841
990
 
842
991
  const bunTlsHotReloadHandlers = [];
843
992
 
844
- // Create dispatcher for each port
845
- const createDispatcher = (portData) => {
846
- return (req, res) => {
847
- const host = req.headers.host || '';
848
-
849
- const hostWithoutPort = host.split(':')[0].toLowerCase();
850
- const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
851
-
852
- if (hostWithoutPort.startsWith('www.')) {
853
- res.writeHead(301, { Location: `https://${domain}${req.url}` });
854
- res.end();
855
- return;
856
- }
857
-
858
- const resolved = this.getHandlerForPortData(domain, portData);
859
- if (!resolved) {
860
- res.writeHead(404);
861
- res.end('Site not found');
862
- return;
863
- }
864
- const { virtualServer, appHandler } = resolved;
865
-
866
- if (virtualServer && virtualServer.requestListeners.length > 0) {
867
- virtualServer.fallbackHandler = appHandler;
868
- virtualServer.processRequest(req, res);
869
- } else if (appHandler) {
870
- appHandler(req, res);
871
- } else {
872
- res.writeHead(404);
873
- res.end('Site not found');
874
- }
875
- };
876
- };
877
-
878
993
  httpServer.listen(80, this.hostname, () => {
879
994
  log.info('HTTP server listening on port 80');
880
995
  });
881
996
 
882
- const createUpgradeHandler = (portData) => {
883
- return (req, socket, head) => {
884
- const host = req.headers.host || '';
885
- const hostWithoutPort = host.split(':')[0].toLowerCase();
886
- const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
887
-
888
- const resolved = this.getHandlerForPortData(domain, portData);
889
- if (resolved && resolved.virtualServer) {
890
- resolved.virtualServer.processUpgrade(req, socket, head);
891
- } else {
892
- socket.destroy();
893
- }
894
- };
895
- };
896
-
897
997
  // Handle different port types
898
998
  for (const [port, portData] of Object.entries(sitesByPort)) {
899
999
  const portNum = parseInt(port);
900
- const dispatcher = createDispatcher(portData);
901
- const upgradeHandler = createUpgradeHandler(portData);
1000
+ const dispatcher = this.createPortRequestDispatcher(portData);
1001
+ const upgradeHandler = this.createPortUpgradeDispatcher(portData);
902
1002
  const greenlockStorePath = this.greenlockStorePath;
903
- const normalizeHostInput = (value) => {
904
- if (typeof value === 'string') return value;
905
- if (!value || typeof value !== 'object') return '';
906
- if (typeof value.servername === 'string') return value.servername;
907
- if (typeof value.hostname === 'string') return value.hostname;
908
- if (typeof value.subject === 'string') return value.subject;
909
- return '';
910
- };
911
1003
  const loadCert = (subjectDir) => {
912
1004
  const normalizedSubject = normalizeHostInput(subjectDir).trim().toLowerCase();
913
1005
  if (!normalizedSubject) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "2.3.14",
3
+ "version": "2.4.0",
4
4
  "description": "πŸ‘Ύ RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -13,7 +13,7 @@ const roster = new Roster({
13
13
  email: 'admin@example.com',
14
14
  wwwPath: '/srv/www',
15
15
  greenlockStorePath: '/srv/greenlock.d',
16
- local: true
16
+ local: false
17
17
  });
18
18
 
19
19
  roster.start();
@@ -155,9 +155,37 @@ new Roster({
155
155
  ### `roster.start()`
156
156
  Loads sites, generates SSL config, starts servers. Returns `Promise<void>`.
157
157
 
158
- ### `roster.register(domain, handler)`
158
+ ### `roster.register(domain, handler, options?)`
159
159
  Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
160
160
 
161
+ Optional registration options:
162
+ - `silent: true` suppresses registration logs (useful in clustered workers)
163
+ - `skipDomainBookkeeping: true` avoids `domains[]` side effects when external runtime owns registration/state
164
+
165
+ ### `roster.prepareSites(options?)`
166
+ Builds VirtualServer + app handler wiring without listening. Useful when you need Roster routing internals but your runtime owns `server.listen(...)`.
167
+
168
+ Options:
169
+ - `targetPort` (optional): only prepare one port, defaults to all registered ports
170
+
171
+ Returns:
172
+ - `{ sitesByPort }` with `virtualServers` and `appHandlers` per port
173
+
174
+ ### `roster.buildRuntimeRouter(options?)`
175
+ Build a cluster/sticky-runtime-compatible router for externally managed HTTP servers. This API does **not** call `listen()` and does **not** boot Greenlock.
176
+
177
+ Options:
178
+ - `targetPort` (optional): defaults to `roster.defaultPort`
179
+ - `hostAliases` (optional): object map or callback `(host) => mappedHost`
180
+ - `allowWwwRedirect` (optional, default `true`)
181
+
182
+ Returns:
183
+ - `attach(server)` binds request + upgrade handlers to an existing server
184
+ - `dispatchRequest(req, res)` pure request dispatcher
185
+ - `dispatchUpgrade(req, socket, head)` pure upgrade dispatcher
186
+ - `portData` routing snapshot
187
+ - `diagnostics` metadata (`targetPort`, hosts, virtual servers)
188
+
161
189
  ### `roster.getUrl(domain)`
162
190
  Get environment-aware URL:
163
191
  - Local mode: `http://localhost:{port}`
@@ -172,6 +200,12 @@ Get environment-aware URL:
172
200
  3. Looks up domain β†’ Gets `VirtualServer` instance
173
201
  4. Routes to handler via `virtualServer.processRequest(req, res)`
174
202
 
203
+ ### External Runtime Flow (cluster/sticky workers)
204
+ 1. Register handlers with `roster.register(...)`
205
+ 2. Build router via `roster.buildRuntimeRouter(...)`
206
+ 3. Attach to externally created server: `router.attach(server)`
207
+ 4. External runtime calls `server.listen(...)`
208
+
175
209
  ### VirtualServer Architecture
176
210
  Each domain gets isolated server instance that simulates `http.Server`:
177
211
  - Captures `request` and `upgrade` event listeners
@@ -242,6 +276,30 @@ roster.register('test.local', (server) => {
242
276
  roster.start();
243
277
  ```
244
278
 
279
+ ### External Cluster/Sticky Worker
280
+ ```javascript
281
+ const http = require('http');
282
+ const Roster = require('roster-server');
283
+
284
+ const roster = new Roster({ local: false, port: 443 });
285
+
286
+ roster.register('example.com', (virtualServer) => {
287
+ return (req, res) => {
288
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
289
+ res.end('worker response');
290
+ };
291
+ }, { silent: true });
292
+
293
+ const server = http.createServer();
294
+ const router = roster.buildRuntimeRouter({
295
+ targetPort: 443,
296
+ hostAliases: { localhost: 'example.com' }
297
+ });
298
+
299
+ router.attach(server);
300
+ server.listen(3000); // owned by external runtime
301
+ ```
302
+
245
303
  ### Environment-Aware Configuration
246
304
  ```javascript
247
305
  const isProduction = process.env.NODE_ENV === 'production';
@@ -274,3 +332,4 @@ When implementing RosterServer:
274
332
  - [ ] Use `roster.getUrl(domain)` for environment-aware URLs
275
333
  - [ ] Handle Socket.IO paths correctly in returned handler
276
334
  - [ ] Implement error handling in handlers
335
+ - [ ] For sticky/cluster runtimes, use `buildRuntimeRouter().attach(server)` instead of `start()`
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const fs = require('fs');
7
7
  const http = require('http');
8
8
  const os = require('os');
9
+ const rosterLog = require('lemonlog')('roster');
9
10
  const Roster = require('../index.js');
10
11
  const {
11
12
  wildcardRoot,
@@ -39,6 +40,20 @@ function httpGet(host, port, pathname = '/') {
39
40
  });
40
41
  }
41
42
 
43
+ async function withPatchedInfoLogger(fn) {
44
+ const originalInfo = rosterLog.info;
45
+ const messages = [];
46
+ rosterLog.info = (...args) => {
47
+ messages.push(args.map((value) => String(value)).join(' '));
48
+ };
49
+ try {
50
+ await fn();
51
+ } finally {
52
+ rosterLog.info = originalInfo;
53
+ }
54
+ return messages;
55
+ }
56
+
42
57
  describe('wildcardRoot', () => {
43
58
  it('returns root domain for *.example.com', () => {
44
59
  assert.strictEqual(wildcardRoot('*.example.com'), 'example.com');
@@ -335,6 +350,21 @@ describe('Roster', () => {
335
350
  assert.strictEqual(roster.sites['api.example.com'], handler);
336
351
  assert.strictEqual(roster.sites['www.api.example.com'], undefined);
337
352
  });
353
+ it('skipDomainBookkeeping avoids domain list side effects', () => {
354
+ const roster = new Roster({ local: true });
355
+ const handler = () => {};
356
+ roster.register('bookkeep.example', handler, { skipDomainBookkeeping: true });
357
+ assert.strictEqual(roster.sites['bookkeep.example'], handler);
358
+ assert.strictEqual(roster.sites['www.bookkeep.example'], handler);
359
+ assert.deepStrictEqual(roster.domains, []);
360
+ });
361
+ it('silent register suppresses registration logs', async () => {
362
+ const roster = new Roster({ local: true });
363
+ const messages = await withPatchedInfoLogger(async () => {
364
+ roster.register('silent.example', () => {}, { silent: true });
365
+ });
366
+ assert.strictEqual(messages.some((line) => line.includes('Registered site: silent.example')), false);
367
+ });
338
368
  });
339
369
 
340
370
  describe('getUrl (exact domain)', () => {
@@ -415,6 +445,111 @@ describe('Roster', () => {
415
445
  });
416
446
  });
417
447
 
448
+ describe('Roster buildRuntimeRouter', () => {
449
+ it('attaches to external HTTP server and dispatches exact + wildcard hosts', async () => {
450
+ const roster = new Roster({ local: true, hostname: 'localhost' });
451
+ roster.register('example.com', () => (req, res) => {
452
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
453
+ res.end('exact');
454
+ });
455
+ roster.register('*.wild.example', () => (req, res) => {
456
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
457
+ res.end('wild');
458
+ });
459
+
460
+ const server = http.createServer();
461
+ const router = roster.buildRuntimeRouter({ targetPort: roster.defaultPort });
462
+ router.attach(server);
463
+
464
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
465
+ const { port } = server.address();
466
+ try {
467
+ const exact = await new Promise((resolve, reject) => {
468
+ const req = http.request({
469
+ host: '127.0.0.1',
470
+ port,
471
+ path: '/',
472
+ method: 'GET',
473
+ headers: { host: 'example.com' }
474
+ }, (res) => {
475
+ let body = '';
476
+ res.on('data', (chunk) => { body += chunk; });
477
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
478
+ });
479
+ req.on('error', reject);
480
+ req.end();
481
+ });
482
+ assert.strictEqual(exact.statusCode, 200);
483
+ assert.strictEqual(exact.body, 'exact');
484
+
485
+ const wildcard = await new Promise((resolve, reject) => {
486
+ const req = http.request({
487
+ host: '127.0.0.1',
488
+ port,
489
+ path: '/',
490
+ method: 'GET',
491
+ headers: { host: 'api.wild.example' }
492
+ }, (res) => {
493
+ let body = '';
494
+ res.on('data', (chunk) => { body += chunk; });
495
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
496
+ });
497
+ req.on('error', reject);
498
+ req.end();
499
+ });
500
+ assert.strictEqual(wildcard.statusCode, 200);
501
+ assert.strictEqual(wildcard.body, 'wild');
502
+ } finally {
503
+ await new Promise((resolve) => server.close(resolve));
504
+ }
505
+ });
506
+
507
+ it('dispatchUpgrade reaches virtual server upgrade listeners', () => {
508
+ const roster = new Roster({ local: true });
509
+ let upgraded = false;
510
+ roster.register('sio.example.com', (virtualServer) => {
511
+ virtualServer.on('upgrade', () => {
512
+ upgraded = true;
513
+ });
514
+ return () => {};
515
+ });
516
+
517
+ const router = roster.buildRuntimeRouter();
518
+ const socket = {
519
+ destroyed: false,
520
+ destroy() {
521
+ this.destroyed = true;
522
+ }
523
+ };
524
+ router.dispatchUpgrade(
525
+ { headers: { host: 'sio.example.com' }, url: '/socket.io/' },
526
+ socket,
527
+ Buffer.alloc(0)
528
+ );
529
+
530
+ assert.strictEqual(upgraded, true);
531
+ assert.strictEqual(socket.destroyed, false);
532
+ });
533
+
534
+ it('buildRuntimeRouter itself does not call listen', () => {
535
+ const roster = new Roster({ local: true });
536
+ roster.register('nolisten.example', () => () => {});
537
+
538
+ let listenCalled = false;
539
+ const originalListen = http.Server.prototype.listen;
540
+ http.Server.prototype.listen = function (...args) {
541
+ listenCalled = true;
542
+ return originalListen.apply(this, args);
543
+ };
544
+ try {
545
+ roster.buildRuntimeRouter();
546
+ } finally {
547
+ http.Server.prototype.listen = originalListen;
548
+ }
549
+ assert.strictEqual(listenCalled, false);
550
+ });
551
+ });
552
+
418
553
  describe('Roster local mode (local: true)', () => {
419
554
  it('starts HTTP server and responds for registered domain', async () => {
420
555
  const roster = new Roster({
@@ -459,6 +594,21 @@ describe('Roster local mode (local: true)', () => {
459
594
  closePortServers(roster);
460
595
  }
461
596
  });
597
+ it('start local mode keeps subdomain localhost mapping', async () => {
598
+ const roster = new Roster({
599
+ local: true,
600
+ minLocalPort: 19110,
601
+ maxLocalPort: 19119
602
+ });
603
+ roster.register('api.logtest.example', () => () => {});
604
+ await roster.start();
605
+ try {
606
+ const localUrl = roster.getUrl('api.logtest.example');
607
+ assert.ok(localUrl && localUrl.startsWith('http://api.localhost:'));
608
+ } finally {
609
+ closePortServers(roster);
610
+ }
611
+ });
462
612
  });
463
613
 
464
614
  describe('Roster loadSites', () => {
@@ -384,13 +384,34 @@ G.create = function(gconf) {
384
384
  return dir.promise;
385
385
  }
386
386
 
387
- await acme.init(dirUrl).catch(function(err) {
388
- log.error(
389
- "ACME init failed (directory may be down or directoryUrl wrong):",
390
- err.message
391
- );
392
- throw err;
393
- });
387
+ var maxRetries = 3;
388
+ var lastErr;
389
+ for (var attempt = 1; attempt <= maxRetries; attempt++) {
390
+ try {
391
+ await acme.init(dirUrl);
392
+ lastErr = null;
393
+ break;
394
+ } catch (err) {
395
+ lastErr = err;
396
+ log.error(
397
+ "ACME init attempt " +
398
+ attempt +
399
+ "/" +
400
+ maxRetries +
401
+ " failed (directory may be down or directoryUrl wrong):",
402
+ err.message
403
+ );
404
+ if (attempt < maxRetries) {
405
+ await new Promise(function(resolve) {
406
+ setTimeout(resolve, 1000 * attempt);
407
+ });
408
+ }
409
+ }
410
+ }
411
+ if (lastErr) {
412
+ delete caches[dirUrl];
413
+ throw lastErr;
414
+ }
394
415
 
395
416
  caches[dirUrl] = {
396
417
  promise: Promise.resolve(acme),