roster-server 2.4.0 β†’ 2.4.2

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.2",
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,25 @@ 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`. 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.attach(server, { port }?)`
195
+ Convenience: wires `requestHandler` + `upgradeHandler` onto an external `http.Server` or `https.Server`. Returns `this`. Requires `init()` first.
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`)
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)
197
+ ### `roster.register(domain, handler)`
198
+ Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
188
199
 
189
200
  ### `roster.getUrl(domain)`
190
201
  Get environment-aware URL:
@@ -200,12 +211,6 @@ Get environment-aware URL:
200
211
  3. Looks up domain β†’ Gets `VirtualServer` instance
201
212
  4. Routes to handler via `virtualServer.processRequest(req, res)`
202
213
 
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
214
  ### VirtualServer Architecture
210
215
  Each domain gets isolated server instance that simulates `http.Server`:
211
216
  - Captures `request` and `upgrade` event listeners
@@ -276,30 +281,6 @@ roster.register('test.local', (server) => {
276
281
  roster.start();
277
282
  ```
278
283
 
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
284
  ### Environment-Aware Configuration
304
285
  ```javascript
305
286
  const isProduction = process.env.NODE_ENV === 'production';
@@ -332,4 +313,3 @@ When implementing RosterServer:
332
313
  - [ ] Use `roster.getUrl(domain)` for environment-aware URLs
333
314
  - [ ] Handle Socket.IO paths correctly in returned handler
334
315
  - [ ] 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');
@@ -350,21 +335,6 @@ describe('Roster', () => {
350
335
  assert.strictEqual(roster.sites['api.example.com'], handler);
351
336
  assert.strictEqual(roster.sites['www.api.example.com'], undefined);
352
337
  });
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
338
  });
369
339
 
370
340
  describe('getUrl (exact domain)', () => {
@@ -445,111 +415,6 @@ describe('Roster', () => {
445
415
  });
446
416
  });
447
417
 
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
418
  describe('Roster local mode (local: true)', () => {
554
419
  it('starts HTTP server and responds for registered domain', async () => {
555
420
  const roster = new Roster({
@@ -594,21 +459,6 @@ describe('Roster local mode (local: true)', () => {
594
459
  closePortServers(roster);
595
460
  }
596
461
  });
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
462
  });
613
463
 
614
464
  describe('Roster loadSites', () => {
@@ -868,3 +718,276 @@ describe('Roster generateConfigJson', () => {
868
718
  }
869
719
  });
870
720
  });
721
+
722
+ describe('Roster init() (cluster-friendly API)', () => {
723
+ it('initializes without creating or listening on any server', async () => {
724
+ const roster = new Roster({
725
+ local: true,
726
+ minLocalPort: 19300,
727
+ maxLocalPort: 19309
728
+ });
729
+ roster.register('init-test.example', (server) => {
730
+ return (req, res) => { res.writeHead(200); res.end('ok'); };
731
+ });
732
+ await roster.init();
733
+ assert.strictEqual(roster._initialized, true);
734
+ assert.strictEqual(Object.keys(roster.portServers).length, 0);
735
+ assert.ok(roster._sitesByPort[443]);
736
+ assert.ok(roster.domainServers['init-test.example']);
737
+ });
738
+
739
+ it('is idempotent (calling init twice does not reinitialize)', async () => {
740
+ const roster = new Roster({ local: true });
741
+ roster.register('idem.example', () => () => {});
742
+ await roster.init();
743
+ const firstSitesByPort = roster._sitesByPort;
744
+ await roster.init();
745
+ assert.strictEqual(roster._sitesByPort, firstSitesByPort);
746
+ });
747
+
748
+ it('start() still works after manual init()', async () => {
749
+ const roster = new Roster({
750
+ local: true,
751
+ minLocalPort: 19310,
752
+ maxLocalPort: 19319
753
+ });
754
+ roster.register('after-init.example', (server) => {
755
+ return (req, res) => { res.writeHead(200); res.end('after-init'); };
756
+ });
757
+ await roster.init();
758
+ await roster.start();
759
+ try {
760
+ const port = roster.domainPorts['after-init.example'];
761
+ assert.ok(typeof port === 'number');
762
+ await new Promise((r) => setTimeout(r, 50));
763
+ const result = await httpGet('localhost', port, '/');
764
+ assert.strictEqual(result.statusCode, 200);
765
+ assert.strictEqual(result.body, 'after-init');
766
+ } finally {
767
+ closePortServers(roster);
768
+ }
769
+ });
770
+ });
771
+
772
+ describe('Roster requestHandler() / upgradeHandler()', () => {
773
+ it('throws if called before init()', () => {
774
+ const roster = new Roster({ local: true });
775
+ assert.throws(() => roster.requestHandler(), /Call init\(\) before/);
776
+ assert.throws(() => roster.upgradeHandler(), /Call init\(\) before/);
777
+ });
778
+
779
+ it('returns a working request dispatcher after init()', async () => {
780
+ const roster = new Roster({ local: true });
781
+ roster.register('handler-test.example', (server) => {
782
+ return (req, res) => { res.writeHead(200); res.end('dispatched'); };
783
+ });
784
+ await roster.init();
785
+
786
+ const handler = roster.requestHandler();
787
+ assert.strictEqual(typeof handler, 'function');
788
+
789
+ let statusCode, body;
790
+ const fakeRes = {
791
+ writeHead: (s) => { statusCode = s; },
792
+ end: (b) => { body = b; }
793
+ };
794
+ handler({ headers: { host: 'handler-test.example' }, url: '/' }, fakeRes);
795
+ assert.strictEqual(statusCode, 200);
796
+ assert.strictEqual(body, 'dispatched');
797
+ });
798
+
799
+ it('returns 404 dispatcher for unregistered port', async () => {
800
+ const roster = new Roster({ local: true });
801
+ roster.register('port-test.example', () => () => {});
802
+ await roster.init();
803
+
804
+ const handler = roster.requestHandler(9999);
805
+ let statusCode;
806
+ const fakeRes = {
807
+ writeHead: (s) => { statusCode = s; },
808
+ end: () => {}
809
+ };
810
+ handler({ headers: { host: 'port-test.example' }, url: '/' }, fakeRes);
811
+ assert.strictEqual(statusCode, 404);
812
+ });
813
+
814
+ it('upgrade handler destroys socket for unknown host', async () => {
815
+ const roster = new Roster({ local: true });
816
+ roster.register('upgrade-test.example', () => () => {});
817
+ await roster.init();
818
+
819
+ const handler = roster.upgradeHandler();
820
+ let destroyed = false;
821
+ const fakeSocket = { destroy: () => { destroyed = true; } };
822
+ handler({ headers: { host: 'unknown.example' }, url: '/' }, fakeSocket, Buffer.alloc(0));
823
+ assert.strictEqual(destroyed, true);
824
+ });
825
+
826
+ it('www redirect uses http:// protocol in local mode', async () => {
827
+ const roster = new Roster({ local: true });
828
+ roster.register('redirect.example', (server) => {
829
+ return (req, res) => { res.writeHead(200); res.end('ok'); };
830
+ });
831
+ await roster.init();
832
+
833
+ const handler = roster.requestHandler();
834
+ let location;
835
+ const fakeRes = {
836
+ writeHead: (s, headers) => { location = headers?.Location; },
837
+ end: () => {}
838
+ };
839
+ handler({ headers: { host: 'www.redirect.example' }, url: '/path' }, fakeRes);
840
+ assert.ok(location);
841
+ assert.ok(location.startsWith('http://'), `Expected http:// redirect, got: ${location}`);
842
+ });
843
+
844
+ it('dispatches correctly for custom registered port via requestHandler(port)', async () => {
845
+ const roster = new Roster({ local: true });
846
+ roster.register('api.ported.example:8443', () => {
847
+ return (req, res) => { res.writeHead(200); res.end('port-8443'); };
848
+ });
849
+ await roster.init();
850
+
851
+ const handler = roster.requestHandler(8443);
852
+ let statusCode;
853
+ let body;
854
+ const fakeRes = {
855
+ writeHead: (s) => { statusCode = s; },
856
+ end: (b) => { body = b; }
857
+ };
858
+ handler({ headers: { host: 'api.ported.example' }, url: '/' }, fakeRes);
859
+ assert.strictEqual(statusCode, 200);
860
+ assert.strictEqual(body, 'port-8443');
861
+ });
862
+
863
+ it('www redirect uses https:// protocol in production mode', async () => {
864
+ const roster = new Roster({ local: false });
865
+ roster.register('redirect-prod.example', () => {
866
+ return (req, res) => { res.writeHead(200); res.end('ok'); };
867
+ });
868
+ await roster.init();
869
+
870
+ const handler = roster.requestHandler();
871
+ let location;
872
+ const fakeRes = {
873
+ writeHead: (s, headers) => { location = headers?.Location; },
874
+ end: () => {}
875
+ };
876
+ handler({ headers: { host: 'www.redirect-prod.example' }, url: '/secure' }, fakeRes);
877
+ assert.ok(location);
878
+ assert.ok(location.startsWith('https://'), `Expected https:// redirect, got: ${location}`);
879
+ });
880
+ });
881
+
882
+ describe('Roster sniCallback()', () => {
883
+ it('throws if called before init()', () => {
884
+ const roster = new Roster({ local: false });
885
+ assert.throws(() => roster.sniCallback(), /Call init\(\) before/);
886
+ });
887
+
888
+ it('throws in local mode (no SNI in HTTP)', async () => {
889
+ const roster = new Roster({ local: true });
890
+ roster.register('sni-local.example', () => () => {});
891
+ await roster.init();
892
+ assert.throws(() => roster.sniCallback(), /not available in local mode/);
893
+ });
894
+
895
+ it('returns a function after init() in production mode', async () => {
896
+ const roster = new Roster({ local: false });
897
+ roster.register('sni-prod.example', () => () => {});
898
+ await roster.init();
899
+ const cb = roster.sniCallback();
900
+ assert.strictEqual(typeof cb, 'function');
901
+ });
902
+ });
903
+
904
+ describe('Roster attach()', () => {
905
+ it('throws if called before init()', () => {
906
+ const roster = new Roster({ local: true });
907
+ const fakeServer = { on: () => {} };
908
+ assert.throws(() => roster.attach(fakeServer), /Call init\(\) before/);
909
+ });
910
+
911
+ it('wires request and upgrade listeners onto external server', async () => {
912
+ const roster = new Roster({ local: true });
913
+ roster.register('attach-test.example', (server) => {
914
+ return (req, res) => { res.writeHead(200); res.end('attached'); };
915
+ });
916
+ await roster.init();
917
+
918
+ const listeners = {};
919
+ const fakeServer = {
920
+ on: (event, fn) => { listeners[event] = fn; }
921
+ };
922
+ const result = roster.attach(fakeServer);
923
+ assert.strictEqual(result, roster);
924
+ assert.strictEqual(typeof listeners['request'], 'function');
925
+ assert.strictEqual(typeof listeners['upgrade'], 'function');
926
+ });
927
+
928
+ it('uses provided port option when attaching', async () => {
929
+ const roster = new Roster({ local: true });
930
+ roster.register('attach-443.example', () => (req, res) => { res.writeHead(200); res.end('on-443'); });
931
+ roster.register('attach-9443.example:9443', () => (req, res) => { res.writeHead(200); res.end('on-9443'); });
932
+ await roster.init();
933
+
934
+ const listeners = {};
935
+ const fakeServer = {
936
+ on: (event, fn) => { listeners[event] = fn; }
937
+ };
938
+ roster.attach(fakeServer, { port: 9443 });
939
+
940
+ let statusCode;
941
+ let body;
942
+ const fakeRes = {
943
+ writeHead: (s) => { statusCode = s; },
944
+ end: (b) => { body = b; }
945
+ };
946
+ listeners.request({ headers: { host: 'attach-9443.example' }, url: '/' }, fakeRes);
947
+ assert.strictEqual(statusCode, 200);
948
+ assert.strictEqual(body, 'on-9443');
949
+ });
950
+
951
+ it('attached handler dispatches requests correctly', async () => {
952
+ const roster = new Roster({
953
+ local: true,
954
+ minLocalPort: 19400,
955
+ maxLocalPort: 19409
956
+ });
957
+ roster.register('attach-http.example', (server) => {
958
+ return (req, res) => {
959
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
960
+ res.end('from-attach');
961
+ };
962
+ });
963
+ await roster.init();
964
+
965
+ const server = http.createServer();
966
+ roster.attach(server);
967
+
968
+ const port = 19400;
969
+ await new Promise((resolve, reject) => {
970
+ server.listen(port, 'localhost', resolve);
971
+ server.on('error', reject);
972
+ });
973
+ try {
974
+ await new Promise((r) => setTimeout(r, 50));
975
+ const result = await new Promise((resolve, reject) => {
976
+ const req = http.get(
977
+ { host: 'localhost', port, path: '/', headers: { host: 'attach-http.example' } },
978
+ (res) => {
979
+ let body = '';
980
+ res.on('data', (chunk) => { body += chunk; });
981
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
982
+ }
983
+ );
984
+ req.on('error', reject);
985
+ req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
986
+ });
987
+ assert.strictEqual(result.statusCode, 200);
988
+ assert.strictEqual(result.body, 'from-attach');
989
+ } finally {
990
+ server.close();
991
+ }
992
+ });
993
+ });
@@ -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.