roster-server 2.4.0 β†’ 2.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "2.4.0",
3
+ "version": "2.4.4",
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: false
16
+ local: true
17
17
  });
18
18
 
19
19
  roster.start();
@@ -123,6 +123,30 @@ roster.register('*.example.com:8080', handler);
123
123
  ### Pattern 5: Static Site (no code)
124
124
  Place only `index.html` (and assets) in `www/example.com/`. No `index.js` needed. RosterServer serves files with path-traversal protection; `/` β†’ `index.html`, other paths β†’ file or 404. Implemented in `lib/static-site-handler.js` and `lib/resolve-site-app.js`.
125
125
 
126
+ ### Pattern 6: Cluster-Friendly (external server)
127
+ ```javascript
128
+ const https = require('https');
129
+ const Roster = require('roster-server');
130
+
131
+ const roster = new Roster({
132
+ email: 'admin@example.com',
133
+ wwwPath: '/srv/www',
134
+ greenlockStorePath: '/srv/greenlock.d'
135
+ });
136
+
137
+ await roster.init();
138
+
139
+ const server = https.createServer({ SNICallback: roster.sniCallback() });
140
+ roster.attach(server);
141
+
142
+ // Master passes connections β€” worker never calls listen()
143
+ process.on('message', (msg, connection) => {
144
+ if (msg === 'sticky-session:connection') {
145
+ server.emit('connection', connection);
146
+ }
147
+ });
148
+ ```
149
+
126
150
  ## Key Configuration Options
127
151
 
128
152
  ```javascript
@@ -137,7 +161,7 @@ new Roster({
137
161
  staging: false, // true = Let's Encrypt staging
138
162
 
139
163
  // Server
140
- hostname: '0.0.0.0',
164
+ hostname: '::',
141
165
  port: 443, // Default HTTPS port (NOT 80!)
142
166
 
143
167
  // Local mode
@@ -153,38 +177,28 @@ new Roster({
153
177
  ## Core API
154
178
 
155
179
  ### `roster.start()`
156
- Loads sites, generates SSL config, starts servers. Returns `Promise<void>`.
157
-
158
- ### `roster.register(domain, handler, options?)`
159
- Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
180
+ Loads sites, generates SSL config, starts servers. Returns `Promise<void>`. Calls `init()` internally.
160
181
 
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
182
+ ### `roster.init()`
183
+ Loads sites, creates VirtualServers, prepares dispatchers β€” but creates **no servers** and calls **no `.listen()`**. Returns `Promise<Roster>`. Idempotent. Use this for cluster-friendly integration where an external manager owns the socket.
164
184
 
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(...)`.
185
+ ### `roster.requestHandler(port?)`
186
+ Returns `(req, res) => void` dispatcher for a port (defaults to `defaultPort`). Requires `init()` first. Handles Host-header routing, www→non-www redirects, wildcard matching.
167
187
 
168
- Options:
169
- - `targetPort` (optional): only prepare one port, defaults to all registered ports
188
+ ### `roster.upgradeHandler(port?)`
189
+ Returns `(req, socket, head) => void` for WebSocket upgrade routing. Requires `init()` first.
170
190
 
171
- Returns:
172
- - `{ sitesByPort }` with `virtualServers` and `appHandlers` per port
191
+ ### `roster.sniCallback()`
192
+ Returns `(servername, callback) => void` TLS SNI callback that resolves certs from `greenlockStorePath`. With `autoCertificates` enabled (default), it can issue missing certs automatically. Production mode only. Requires `init()` first.
173
193
 
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.
194
+ ### `roster.ensureCertificate(servername)`
195
+ Forces certificate availability for a domain and returns `{ key, cert }`. With `autoCertificates` enabled (default), it issues certs automatically when missing.
176
196
 
177
- Options:
178
- - `targetPort` (optional): defaults to `roster.defaultPort`
179
- - `hostAliases` (optional): object map or callback `(host) => mappedHost`
180
- - `allowWwwRedirect` (optional, default `true`)
197
+ ### `roster.attach(server, { port }?)`
198
+ Convenience: wires `requestHandler` + `upgradeHandler` onto an external `http.Server` or `https.Server`. Returns `this`. Requires `init()` first.
181
199
 
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)
200
+ ### `roster.register(domain, handler)`
201
+ Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
188
202
 
189
203
  ### `roster.getUrl(domain)`
190
204
  Get environment-aware URL:
@@ -200,12 +214,6 @@ Get environment-aware URL:
200
214
  3. Looks up domain β†’ Gets `VirtualServer` instance
201
215
  4. Routes to handler via `virtualServer.processRequest(req, res)`
202
216
 
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
-
209
217
  ### VirtualServer Architecture
210
218
  Each domain gets isolated server instance that simulates `http.Server`:
211
219
  - Captures `request` and `upgrade` event listeners
@@ -276,30 +284,6 @@ roster.register('test.local', (server) => {
276
284
  roster.start();
277
285
  ```
278
286
 
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
-
303
287
  ### Environment-Aware Configuration
304
288
  ```javascript
305
289
  const isProduction = process.env.NODE_ENV === 'production';
@@ -332,4 +316,3 @@ When implementing RosterServer:
332
316
  - [ ] Use `roster.getUrl(domain)` for environment-aware URLs
333
317
  - [ ] Handle Socket.IO paths correctly in returned handler
334
318
  - [ ] Implement error handling in handlers
335
- - [ ] For sticky/cluster runtimes, use `buildRuntimeRouter().attach(server)` instead of `start()`
@@ -6,7 +6,6 @@ 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');
10
9
  const Roster = require('../index.js');
11
10
  const {
12
11
  wildcardRoot,
@@ -40,20 +39,6 @@ function httpGet(host, port, pathname = '/') {
40
39
  });
41
40
  }
42
41
 
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
-
57
42
  describe('wildcardRoot', () => {
58
43
  it('returns root domain for *.example.com', () => {
59
44
  assert.strictEqual(wildcardRoot('*.example.com'), 'example.com');
@@ -333,6 +318,14 @@ describe('Roster', () => {
333
318
  const roster = new Roster({ local: false, combineWildcardCerts: true });
334
319
  assert.strictEqual(roster.combineWildcardCerts, true);
335
320
  });
321
+ it('defaults autoCertificates to true', () => {
322
+ const roster = new Roster({ local: false });
323
+ assert.strictEqual(roster.autoCertificates, true);
324
+ });
325
+ it('allows disabling autoCertificates explicitly', () => {
326
+ const roster = new Roster({ local: false, autoCertificates: false });
327
+ assert.strictEqual(roster.autoCertificates, false);
328
+ });
336
329
  });
337
330
 
338
331
  describe('register (normal domain)', () => {
@@ -350,21 +343,6 @@ describe('Roster', () => {
350
343
  assert.strictEqual(roster.sites['api.example.com'], handler);
351
344
  assert.strictEqual(roster.sites['www.api.example.com'], undefined);
352
345
  });
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
- });
368
346
  });
369
347
 
370
348
  describe('getUrl (exact domain)', () => {
@@ -445,111 +423,6 @@ describe('Roster', () => {
445
423
  });
446
424
  });
447
425
 
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
-
553
426
  describe('Roster local mode (local: true)', () => {
554
427
  it('starts HTTP server and responds for registered domain', async () => {
555
428
  const roster = new Roster({
@@ -594,21 +467,6 @@ describe('Roster local mode (local: true)', () => {
594
467
  closePortServers(roster);
595
468
  }
596
469
  });
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
- });
612
470
  });
613
471
 
614
472
  describe('Roster loadSites', () => {
@@ -868,3 +726,300 @@ describe('Roster generateConfigJson', () => {
868
726
  }
869
727
  });
870
728
  });
729
+
730
+ describe('Roster init() (cluster-friendly API)', () => {
731
+ it('initializes without creating or listening on any server', async () => {
732
+ const roster = new Roster({
733
+ local: true,
734
+ minLocalPort: 19300,
735
+ maxLocalPort: 19309
736
+ });
737
+ roster.register('init-test.example', (server) => {
738
+ return (req, res) => { res.writeHead(200); res.end('ok'); };
739
+ });
740
+ await roster.init();
741
+ assert.strictEqual(roster._initialized, true);
742
+ assert.strictEqual(Object.keys(roster.portServers).length, 0);
743
+ assert.ok(roster._sitesByPort[443]);
744
+ assert.ok(roster.domainServers['init-test.example']);
745
+ });
746
+
747
+ it('is idempotent (calling init twice does not reinitialize)', async () => {
748
+ const roster = new Roster({ local: true });
749
+ roster.register('idem.example', () => () => {});
750
+ await roster.init();
751
+ const firstSitesByPort = roster._sitesByPort;
752
+ await roster.init();
753
+ assert.strictEqual(roster._sitesByPort, firstSitesByPort);
754
+ });
755
+
756
+ it('start() still works after manual init()', async () => {
757
+ const roster = new Roster({
758
+ local: true,
759
+ minLocalPort: 19310,
760
+ maxLocalPort: 19319
761
+ });
762
+ roster.register('after-init.example', (server) => {
763
+ return (req, res) => { res.writeHead(200); res.end('after-init'); };
764
+ });
765
+ await roster.init();
766
+ await roster.start();
767
+ try {
768
+ const port = roster.domainPorts['after-init.example'];
769
+ assert.ok(typeof port === 'number');
770
+ await new Promise((r) => setTimeout(r, 50));
771
+ const result = await httpGet('localhost', port, '/');
772
+ assert.strictEqual(result.statusCode, 200);
773
+ assert.strictEqual(result.body, 'after-init');
774
+ } finally {
775
+ closePortServers(roster);
776
+ }
777
+ });
778
+ });
779
+
780
+ describe('Roster requestHandler() / upgradeHandler()', () => {
781
+ it('throws if called before init()', () => {
782
+ const roster = new Roster({ local: true });
783
+ assert.throws(() => roster.requestHandler(), /Call init\(\) before/);
784
+ assert.throws(() => roster.upgradeHandler(), /Call init\(\) before/);
785
+ });
786
+
787
+ it('returns a working request dispatcher after init()', async () => {
788
+ const roster = new Roster({ local: true });
789
+ roster.register('handler-test.example', (server) => {
790
+ return (req, res) => { res.writeHead(200); res.end('dispatched'); };
791
+ });
792
+ await roster.init();
793
+
794
+ const handler = roster.requestHandler();
795
+ assert.strictEqual(typeof handler, 'function');
796
+
797
+ let statusCode, body;
798
+ const fakeRes = {
799
+ writeHead: (s) => { statusCode = s; },
800
+ end: (b) => { body = b; }
801
+ };
802
+ handler({ headers: { host: 'handler-test.example' }, url: '/' }, fakeRes);
803
+ assert.strictEqual(statusCode, 200);
804
+ assert.strictEqual(body, 'dispatched');
805
+ });
806
+
807
+ it('returns 404 dispatcher for unregistered port', async () => {
808
+ const roster = new Roster({ local: true });
809
+ roster.register('port-test.example', () => () => {});
810
+ await roster.init();
811
+
812
+ const handler = roster.requestHandler(9999);
813
+ let statusCode;
814
+ const fakeRes = {
815
+ writeHead: (s) => { statusCode = s; },
816
+ end: () => {}
817
+ };
818
+ handler({ headers: { host: 'port-test.example' }, url: '/' }, fakeRes);
819
+ assert.strictEqual(statusCode, 404);
820
+ });
821
+
822
+ it('upgrade handler destroys socket for unknown host', async () => {
823
+ const roster = new Roster({ local: true });
824
+ roster.register('upgrade-test.example', () => () => {});
825
+ await roster.init();
826
+
827
+ const handler = roster.upgradeHandler();
828
+ let destroyed = false;
829
+ const fakeSocket = { destroy: () => { destroyed = true; } };
830
+ handler({ headers: { host: 'unknown.example' }, url: '/' }, fakeSocket, Buffer.alloc(0));
831
+ assert.strictEqual(destroyed, true);
832
+ });
833
+
834
+ it('www redirect uses http:// protocol in local mode', async () => {
835
+ const roster = new Roster({ local: true });
836
+ roster.register('redirect.example', (server) => {
837
+ return (req, res) => { res.writeHead(200); res.end('ok'); };
838
+ });
839
+ await roster.init();
840
+
841
+ const handler = roster.requestHandler();
842
+ let location;
843
+ const fakeRes = {
844
+ writeHead: (s, headers) => { location = headers?.Location; },
845
+ end: () => {}
846
+ };
847
+ handler({ headers: { host: 'www.redirect.example' }, url: '/path' }, fakeRes);
848
+ assert.ok(location);
849
+ assert.ok(location.startsWith('http://'), `Expected http:// redirect, got: ${location}`);
850
+ });
851
+
852
+ it('dispatches correctly for custom registered port via requestHandler(port)', async () => {
853
+ const roster = new Roster({ local: true });
854
+ roster.register('api.ported.example:8443', () => {
855
+ return (req, res) => { res.writeHead(200); res.end('port-8443'); };
856
+ });
857
+ await roster.init();
858
+
859
+ const handler = roster.requestHandler(8443);
860
+ let statusCode;
861
+ let body;
862
+ const fakeRes = {
863
+ writeHead: (s) => { statusCode = s; },
864
+ end: (b) => { body = b; }
865
+ };
866
+ handler({ headers: { host: 'api.ported.example' }, url: '/' }, fakeRes);
867
+ assert.strictEqual(statusCode, 200);
868
+ assert.strictEqual(body, 'port-8443');
869
+ });
870
+
871
+ it('www redirect uses https:// protocol in production mode', async () => {
872
+ const roster = new Roster({ local: false });
873
+ roster.register('redirect-prod.example', () => {
874
+ return (req, res) => { res.writeHead(200); res.end('ok'); };
875
+ });
876
+ await roster.init();
877
+
878
+ const handler = roster.requestHandler();
879
+ let location;
880
+ const fakeRes = {
881
+ writeHead: (s, headers) => { location = headers?.Location; },
882
+ end: () => {}
883
+ };
884
+ handler({ headers: { host: 'www.redirect-prod.example' }, url: '/secure' }, fakeRes);
885
+ assert.ok(location);
886
+ assert.ok(location.startsWith('https://'), `Expected https:// redirect, got: ${location}`);
887
+ });
888
+ });
889
+
890
+ describe('Roster sniCallback()', () => {
891
+ it('throws if called before init()', () => {
892
+ const roster = new Roster({ local: false });
893
+ assert.throws(() => roster.sniCallback(), /Call init\(\) before/);
894
+ });
895
+
896
+ it('throws in local mode (no SNI in HTTP)', async () => {
897
+ const roster = new Roster({ local: true });
898
+ roster.register('sni-local.example', () => () => {});
899
+ await roster.init();
900
+ assert.throws(() => roster.sniCallback(), /not available in local mode/);
901
+ });
902
+
903
+ it('returns a function after init() in production mode', async () => {
904
+ const roster = new Roster({ local: false });
905
+ roster.register('sni-prod.example', () => () => {});
906
+ await roster.init();
907
+ const cb = roster.sniCallback();
908
+ assert.strictEqual(typeof cb, 'function');
909
+ });
910
+ });
911
+
912
+ describe('Roster ensureCertificate()', () => {
913
+ it('throws if called before init()', async () => {
914
+ const roster = new Roster({ local: false, autoCertificates: true });
915
+ await assert.rejects(() => roster.ensureCertificate('example.com'), /Call init\(\) before ensureCertificate/);
916
+ });
917
+
918
+ it('throws in local mode', async () => {
919
+ const roster = new Roster({ local: true, autoCertificates: true });
920
+ roster.register('local-cert.example', () => () => {});
921
+ await roster.init();
922
+ await assert.rejects(() => roster.ensureCertificate('local-cert.example'), /not available in local mode/);
923
+ });
924
+
925
+ it('throws when autoCertificates is disabled and cert is missing', async () => {
926
+ const roster = new Roster({ local: false, autoCertificates: false });
927
+ roster.register('missing-cert.example', () => () => {});
928
+ await roster.init();
929
+ await assert.rejects(
930
+ () => roster.ensureCertificate('missing-cert.example'),
931
+ /autoCertificates is disabled/
932
+ );
933
+ });
934
+ });
935
+
936
+ describe('Roster attach()', () => {
937
+ it('throws if called before init()', () => {
938
+ const roster = new Roster({ local: true });
939
+ const fakeServer = { on: () => {} };
940
+ assert.throws(() => roster.attach(fakeServer), /Call init\(\) before/);
941
+ });
942
+
943
+ it('wires request and upgrade listeners onto external server', async () => {
944
+ const roster = new Roster({ local: true });
945
+ roster.register('attach-test.example', (server) => {
946
+ return (req, res) => { res.writeHead(200); res.end('attached'); };
947
+ });
948
+ await roster.init();
949
+
950
+ const listeners = {};
951
+ const fakeServer = {
952
+ on: (event, fn) => { listeners[event] = fn; }
953
+ };
954
+ const result = roster.attach(fakeServer);
955
+ assert.strictEqual(result, roster);
956
+ assert.strictEqual(typeof listeners['request'], 'function');
957
+ assert.strictEqual(typeof listeners['upgrade'], 'function');
958
+ });
959
+
960
+ it('uses provided port option when attaching', async () => {
961
+ const roster = new Roster({ local: true });
962
+ roster.register('attach-443.example', () => (req, res) => { res.writeHead(200); res.end('on-443'); });
963
+ roster.register('attach-9443.example:9443', () => (req, res) => { res.writeHead(200); res.end('on-9443'); });
964
+ await roster.init();
965
+
966
+ const listeners = {};
967
+ const fakeServer = {
968
+ on: (event, fn) => { listeners[event] = fn; }
969
+ };
970
+ roster.attach(fakeServer, { port: 9443 });
971
+
972
+ let statusCode;
973
+ let body;
974
+ const fakeRes = {
975
+ writeHead: (s) => { statusCode = s; },
976
+ end: (b) => { body = b; }
977
+ };
978
+ listeners.request({ headers: { host: 'attach-9443.example' }, url: '/' }, fakeRes);
979
+ assert.strictEqual(statusCode, 200);
980
+ assert.strictEqual(body, 'on-9443');
981
+ });
982
+
983
+ it('attached handler dispatches requests correctly', async () => {
984
+ const roster = new Roster({
985
+ local: true,
986
+ minLocalPort: 19400,
987
+ maxLocalPort: 19409
988
+ });
989
+ roster.register('attach-http.example', (server) => {
990
+ return (req, res) => {
991
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
992
+ res.end('from-attach');
993
+ };
994
+ });
995
+ await roster.init();
996
+
997
+ const server = http.createServer();
998
+ roster.attach(server);
999
+
1000
+ const port = 19400;
1001
+ await new Promise((resolve, reject) => {
1002
+ server.listen(port, 'localhost', resolve);
1003
+ server.on('error', reject);
1004
+ });
1005
+ try {
1006
+ await new Promise((r) => setTimeout(r, 50));
1007
+ const result = await new Promise((resolve, reject) => {
1008
+ const req = http.get(
1009
+ { host: 'localhost', port, path: '/', headers: { host: 'attach-http.example' } },
1010
+ (res) => {
1011
+ let body = '';
1012
+ res.on('data', (chunk) => { body += chunk; });
1013
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
1014
+ }
1015
+ );
1016
+ req.on('error', reject);
1017
+ req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
1018
+ });
1019
+ assert.strictEqual(result.statusCode, 200);
1020
+ assert.strictEqual(result.body, 'from-attach');
1021
+ } finally {
1022
+ server.close();
1023
+ }
1024
+ });
1025
+ });
@@ -1,12 +0,0 @@
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).
@@ -1,12 +0,0 @@
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.