roster-server 2.3.16 β†’ 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/README.md CHANGED
@@ -350,6 +350,71 @@ console.log(customRoster.getUrl('api.example.com'));
350
350
  // β†’ https://api.example.com:8443
351
351
  ```
352
352
 
353
+ ## πŸ”Œ Cluster-Friendly API (init / attach)
354
+
355
+ RosterServer can coexist with external cluster managers (sticky-session libraries, PM2 cluster, custom master/worker architectures) that already own the TCP socket and distribute connections. Instead of letting Roster create and bind servers, you initialize routing separately and wire it into your own server.
356
+
357
+ ### How It Works
358
+
359
+ `roster.init()` loads sites, creates VirtualServers, and prepares dispatchers β€” but creates **no servers** and calls **no `.listen()`**. You then get handler functions to wire into any `http.Server` or `https.Server`.
360
+
361
+ ### Quick Example: Sticky-Session Worker
362
+
363
+ ```javascript
364
+ import https from 'https';
365
+ import Roster from 'roster-server';
366
+
367
+ const roster = new Roster({
368
+ email: 'admin@example.com',
369
+ wwwPath: '/srv/www',
370
+ greenlockStorePath: '/srv/greenlock.d'
371
+ });
372
+
373
+ await roster.init();
374
+
375
+ // Create your own HTTPS server with Roster's SNI + routing
376
+ const server = https.createServer({ SNICallback: roster.sniCallback() });
377
+ roster.attach(server);
378
+
379
+ // Master passes connections via IPC β€” worker never calls listen()
380
+ process.on('message', (msg, connection) => {
381
+ if (msg === 'sticky-session:connection') {
382
+ server.emit('connection', connection);
383
+ }
384
+ });
385
+ ```
386
+
387
+ ### API Reference
388
+
389
+ #### `roster.init()` β†’ `Promise<Roster>`
390
+
391
+ Loads sites, generates SSL config (production), creates VirtualServers and initializes handlers. Idempotent β€” calling it twice is safe. Returns `this` for chaining.
392
+
393
+ #### `roster.requestHandler(port?)` β†’ `(req, res) => void`
394
+
395
+ Returns the Host-header dispatch function for a given port (defaults to `defaultPort`). Handles www→non-www redirects, wildcard matching, and VirtualServer dispatch.
396
+
397
+ #### `roster.upgradeHandler(port?)` β†’ `(req, socket, head) => void`
398
+
399
+ Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to the correct VirtualServer.
400
+
401
+ #### `roster.sniCallback()` β†’ `(servername, callback) => void`
402
+
403
+ Returns a TLS SNI callback that resolves certificates from `greenlockStorePath` on disk. Not available in local mode.
404
+
405
+ #### `roster.attach(server, { port }?)` β†’ `Roster`
406
+
407
+ Convenience method. Wires `requestHandler` and `upgradeHandler` onto `server.on('request', ...)` and `server.on('upgrade', ...)`. Returns `this` for chaining.
408
+
409
+ ### Standalone Mode (unchanged)
410
+
411
+ `roster.start()` still works exactly as before β€” it calls `init()` internally, then creates and binds servers:
412
+
413
+ ```javascript
414
+ const roster = new Roster({ ... });
415
+ await roster.start(); // full standalone mode, no changes needed
416
+ ```
417
+
353
418
  ## πŸ§‚ A Touch of Magic
354
419
 
355
420
  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,90 @@
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const Roster = require('../index.js');
5
+
6
+ function hasCertificateFor(storePath, subject) {
7
+ const base = path.join(storePath, 'live', subject);
8
+ return (
9
+ fs.existsSync(path.join(base, 'privkey.pem')) &&
10
+ fs.existsSync(path.join(base, 'cert.pem')) &&
11
+ fs.existsSync(path.join(base, 'chain.pem'))
12
+ );
13
+ }
14
+
15
+ async function main() {
16
+ // In this demo, certs are expected to already exist in greenlock.d/live/<subject>.
17
+ // init()+attach() do routing only; no ACME issuance/listen lifecycle is started.
18
+ const greenlockStorePath = process.env.GREENLOCK_STORE_PATH || path.join(__dirname, '..', 'greenlock.d');
19
+ const listenPort = Number(process.env.DEMO_HTTPS_PORT || 19643);
20
+
21
+ const roster = new Roster({
22
+ local: false,
23
+ email: process.env.ADMIN_EMAIL || 'admin@example.com',
24
+ greenlockStorePath
25
+ });
26
+
27
+ roster.register('example.com', () => {
28
+ return (req, res) => {
29
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
30
+ res.end('hello from external HTTPS worker (default port routes)');
31
+ };
32
+ });
33
+
34
+ roster.register('api.example.com:9443', () => {
35
+ return (req, res) => {
36
+ res.writeHead(200, { 'Content-Type': 'application/json' });
37
+ res.end(JSON.stringify({ ok: true, source: 'api.example.com:9443 route table' }));
38
+ };
39
+ });
40
+
41
+ await roster.init();
42
+
43
+ const missing = ['example.com', '*.example.com'].filter((subject) => !hasCertificateFor(greenlockStorePath, subject));
44
+ if (missing.length > 0) {
45
+ console.log('\n⚠️ Missing certificate files for:');
46
+ missing.forEach((subject) => console.log(` - ${subject}`));
47
+ console.log('\nExpected files:');
48
+ console.log(' greenlock.d/live/<subject>/privkey.pem');
49
+ console.log(' greenlock.d/live/<subject>/cert.pem');
50
+ console.log(' greenlock.d/live/<subject>/chain.pem');
51
+ console.log('\nTip: run regular roster.start() once (or your cert manager) to issue certs first.\n');
52
+ }
53
+
54
+ const server = https.createServer({
55
+ SNICallback: roster.sniCallback(),
56
+ minVersion: 'TLSv1.2',
57
+ maxVersion: 'TLSv1.3'
58
+ });
59
+
60
+ // Attach default routing table (defaultPort = 443 routes)
61
+ roster.attach(server);
62
+
63
+ await new Promise((resolve, reject) => {
64
+ server.listen(listenPort, '0.0.0.0', resolve);
65
+ server.on('error', reject);
66
+ });
67
+
68
+ console.log('\nβœ… HTTPS cluster-friendly demo running');
69
+ console.log(`Listening on :${listenPort} with external server ownership`);
70
+ console.log('Roster only provides SNI + vhost routing (no internal listen/bootstrap).\n');
71
+
72
+ console.log('Try:');
73
+ console.log(`curl -k --resolve example.com:${listenPort}:127.0.0.1 https://example.com:${listenPort}/`);
74
+ console.log(`curl -k --resolve www.example.com:${listenPort}:127.0.0.1 https://www.example.com:${listenPort}/foo`);
75
+ console.log('\nFor custom route table port=9443, attach another server:');
76
+ console.log(' const srv9443 = https.createServer({ SNICallback: roster.sniCallback() });');
77
+ console.log(' roster.attach(srv9443, { port: 9443 });');
78
+ console.log(' srv9443.listen(19644);');
79
+ console.log(` curl -k --resolve api.example.com:19644:127.0.0.1 https://api.example.com:19644/`);
80
+
81
+ // Sticky-session runtime pattern:
82
+ // process.on('message', (msg, socket) => {
83
+ // if (msg === 'sticky-session:connection') server.emit('connection', socket);
84
+ // });
85
+ }
86
+
87
+ main().catch((err) => {
88
+ console.error('Demo failed:', err);
89
+ process.exit(1);
90
+ });
@@ -0,0 +1,60 @@
1
+ const http = require('http');
2
+ const Roster = require('../index.js');
3
+
4
+ async function main() {
5
+ const roster = new Roster({
6
+ local: true,
7
+ minLocalPort: 19500,
8
+ maxLocalPort: 19550
9
+ });
10
+
11
+ roster.register('example.com', () => {
12
+ return (req, res) => {
13
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
14
+ res.end('[example.com] hello from external worker wiring');
15
+ };
16
+ });
17
+
18
+ roster.register('api.example.com:9000', () => {
19
+ return (req, res) => {
20
+ res.writeHead(200, { 'Content-Type': 'application/json' });
21
+ res.end(JSON.stringify({ ok: true, source: 'api.example.com:9000' }));
22
+ };
23
+ });
24
+
25
+ // init() prepares virtual-host routing, but does not create/listen sockets.
26
+ await roster.init();
27
+
28
+ // This server represents your external runtime-owned worker server.
29
+ const server443 = http.createServer();
30
+ roster.attach(server443); // defaultPort routes (443)
31
+
32
+ const server9000 = http.createServer();
33
+ roster.attach(server9000, { port: 9000 }); // custom port routes
34
+
35
+ await new Promise((resolve, reject) => {
36
+ server443.listen(19501, 'localhost', resolve);
37
+ server443.on('error', reject);
38
+ });
39
+
40
+ await new Promise((resolve, reject) => {
41
+ server9000.listen(19502, 'localhost', resolve);
42
+ server9000.on('error', reject);
43
+ });
44
+
45
+ console.log('\nβœ… Cluster-friendly worker demo running');
46
+ console.log('Roster did not bind ports itself. External servers own listen().\n');
47
+ console.log('Try requests with Host headers:');
48
+ console.log('curl -H "Host: example.com" http://localhost:19501/');
49
+ console.log('curl -H "Host: api.example.com" http://localhost:19502/\n');
50
+
51
+ // Sticky-session runtimes normally pass accepted sockets into the worker:
52
+ // process.on('message', (msg, socket) => {
53
+ // if (msg === 'sticky-session:connection') server443.emit('connection', socket);
54
+ // });
55
+ }
56
+
57
+ main().catch((err) => {
58
+ console.error('Demo failed:', err);
59
+ process.exit(1);
60
+ });
@@ -0,0 +1,30 @@
1
+ ---
2
+ id: n5edmg25e9
3
+ type: architecture
4
+ title: 'Architecture: Cluster-friendly init/attach API for RosterServer'
5
+ created: '2026-03-20 20:38:03'
6
+ ---
7
+ # Architecture: Cluster-Friendly init/attach API
8
+
9
+ **What**: Split Roster's monolithic `start()` into `init()` (routing preparation) + optional `start()` (server lifecycle). New public methods: `init()`, `requestHandler(port?)`, `upgradeHandler(port?)`, `sniCallback()`, `attach(server, opts?)`.
10
+
11
+ **Where**: `index.js` β€” Roster class. Private methods: `_initSiteHandlers()`, `_createDispatcher()`, `_createUpgradeHandler()`, `_normalizeHostInput()`, `_loadCert()`, `_resolvePemsForServername()`, `_initSniResolver()`.
12
+
13
+ **Why**: `start()` assumed full ownership of bootstrap (listen, lifecycle, dispatch), conflicting with external cluster managers (sticky-session, PM2 cluster) that already own the TCP socket and distribute connections.
14
+
15
+ **Design decisions**:
16
+ - `init()` is idempotent (guarded by `_initialized` flag)
17
+ - `start()` calls `init()` internally β€” zero breaking changes for existing users
18
+ - SNI callback in `init()` path uses disk-based cert resolution only (no Greenlock runtime) β€” safe for workers
19
+ - Greenlock-backed issuance only happens in `start()` production path β€” reserved for the process managing certs
20
+ - `_createDispatcher` uses `this.local` to choose http/https protocol for www redirects
21
+ - `startLocalMode()` was refactored to consume pre-initialized `_sitesByPort` from `init()`
22
+ - 14 new tests added covering init/handlers/attach/sni contracts
23
+
24
+ **Key pattern for users**:
25
+ ```javascript
26
+ await roster.init();
27
+ const server = https.createServer({ SNICallback: roster.sniCallback() });
28
+ roster.attach(server);
29
+ // Master passes connections β€” worker never calls listen()
30
+ ```
@@ -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.
package/index.js CHANGED
@@ -252,6 +252,9 @@ class Roster {
252
252
  this.portServers = {}; // Store servers by port
253
253
  this.domainPorts = {}; // Store domain β†’ port mapping for local mode
254
254
  this.assignedPorts = new Set(); // Track ports assigned to domains (not OS availability)
255
+ this._sitesByPort = {};
256
+ this._initialized = false;
257
+ this._sniCallback = null;
255
258
  this.hostname = options.hostname ?? '::';
256
259
  this.filename = options.filename || 'index';
257
260
  this.minLocalPort = options.minLocalPort || 4000;
@@ -662,65 +665,219 @@ class Roster {
662
665
  return null;
663
666
  }
664
667
 
665
- // Start server in local mode with HTTP - simplified version
666
- startLocalMode() {
667
- // Store mapping of domain to port for later retrieval
668
- this.domainPorts = {};
669
-
670
- // Create a simple HTTP server for each domain with CRC32-based ports
671
- for (const [hostKey, siteApp] of Object.entries(this.sites)) {
672
- const domain = hostKey.split(':')[0]; // Remove port if present
668
+ _normalizeHostInput(value) {
669
+ if (typeof value === 'string') return value;
670
+ if (!value || typeof value !== 'object') return '';
671
+ if (typeof value.servername === 'string') return value.servername;
672
+ if (typeof value.hostname === 'string') return value.hostname;
673
+ if (typeof value.subject === 'string') return value.subject;
674
+ return '';
675
+ }
673
676
 
674
- // Skip www domains in local mode
675
- if (domain.startsWith('www.')) {
676
- continue;
677
- }
677
+ _loadCert(subjectDir) {
678
+ const normalizedSubject = this._normalizeHostInput(subjectDir).trim().toLowerCase();
679
+ if (!normalizedSubject) return null;
680
+ const certPath = path.join(this.greenlockStorePath, 'live', normalizedSubject);
681
+ const keyPath = path.join(certPath, 'privkey.pem');
682
+ const certFilePath = path.join(certPath, 'cert.pem');
683
+ const chainPath = path.join(certPath, 'chain.pem');
684
+ if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
685
+ return {
686
+ key: fs.readFileSync(keyPath, 'utf8'),
687
+ cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
688
+ };
689
+ }
690
+ return null;
691
+ }
678
692
 
679
- // Calculate deterministic port based on domain CRC32, with collision detection
680
- const port = this.assignPortToDomain(domain);
693
+ _resolvePemsForServername(servername) {
694
+ const host = this._normalizeHostInput(servername).trim().toLowerCase();
695
+ if (!host) return null;
696
+ const candidates = buildCertLookupCandidates(host);
697
+ for (const candidate of candidates) {
698
+ const pems = this._loadCert(candidate);
699
+ if (pems) return pems;
700
+ }
701
+ return null;
702
+ }
681
703
 
682
- // Store domain β†’ port mapping
683
- this.domainPorts[domain] = port;
704
+ _initSiteHandlers() {
705
+ this._sitesByPort = {};
706
+ for (const [hostKey, siteApp] of Object.entries(this.sites)) {
707
+ if (hostKey.startsWith('www.')) continue;
708
+ const { domain, port } = this.parseDomainWithPort(hostKey);
709
+ if (!this._sitesByPort[port]) {
710
+ this._sitesByPort[port] = {
711
+ virtualServers: {},
712
+ appHandlers: {}
713
+ };
714
+ }
684
715
 
685
- // Create virtual server for the domain
686
716
  const virtualServer = this.createVirtualServer(domain);
717
+ this._sitesByPort[port].virtualServers[domain] = virtualServer;
687
718
  this.domainServers[domain] = virtualServer;
688
719
 
689
- // Initialize app with virtual server
690
720
  const appHandler = siteApp(virtualServer);
721
+ this._sitesByPort[port].appHandlers[domain] = appHandler;
722
+ if (!domain.startsWith('*.')) {
723
+ this._sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
724
+ }
725
+ }
726
+ }
691
727
 
692
- // Create simple dispatcher for this domain
693
- const dispatcher = (req, res) => {
694
- // Set fallback handler on virtual server for non-Socket.IO requests
728
+ _createDispatcher(portData) {
729
+ return (req, res) => {
730
+ const host = req.headers.host || '';
731
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
732
+ const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
733
+
734
+ if (hostWithoutPort.startsWith('www.')) {
735
+ const protocol = this.local ? 'http' : 'https';
736
+ res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
737
+ res.end();
738
+ return;
739
+ }
740
+
741
+ const resolved = this.getHandlerForPortData(domain, portData);
742
+ if (!resolved) {
743
+ res.writeHead(404);
744
+ res.end('Site not found');
745
+ return;
746
+ }
747
+ const { virtualServer, appHandler } = resolved;
748
+
749
+ if (virtualServer && virtualServer.requestListeners.length > 0) {
695
750
  virtualServer.fallbackHandler = appHandler;
751
+ virtualServer.processRequest(req, res);
752
+ } else if (appHandler) {
753
+ appHandler(req, res);
754
+ } else {
755
+ res.writeHead(404);
756
+ res.end('Site not found');
757
+ }
758
+ };
759
+ }
760
+
761
+ _createUpgradeHandler(portData) {
762
+ return (req, socket, head) => {
763
+ const host = req.headers.host || '';
764
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
765
+ const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
766
+
767
+ const resolved = this.getHandlerForPortData(domain, portData);
768
+ if (resolved && resolved.virtualServer) {
769
+ resolved.virtualServer.processUpgrade(req, socket, head);
770
+ } else {
771
+ socket.destroy();
772
+ }
773
+ };
774
+ }
696
775
 
697
- if (virtualServer.requestListeners.length > 0) {
698
- virtualServer.processRequest(req, res);
699
- } else if (appHandler) {
700
- appHandler(req, res);
776
+ _initSniResolver() {
777
+ this._sniCallback = (servername, callback) => {
778
+ try {
779
+ const pems = this._resolvePemsForServername(servername);
780
+ if (pems) {
781
+ callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
701
782
  } else {
702
- res.writeHead(404);
703
- res.end('Site not found');
783
+ callback(new Error(`No certificate files available for ${servername}`));
704
784
  }
785
+ } catch (error) {
786
+ callback(error);
787
+ }
788
+ };
789
+ }
790
+
791
+ async init() {
792
+ if (this._initialized) return this;
793
+ await this.loadSites();
794
+ if (!this.local) {
795
+ this.generateConfigJson();
796
+ }
797
+ this._initSiteHandlers();
798
+ if (!this.local) {
799
+ this._initSniResolver();
800
+ }
801
+ this._initialized = true;
802
+ return this;
803
+ }
804
+
805
+ requestHandler(port) {
806
+ if (!this._initialized) throw new Error('Call init() before requestHandler()');
807
+ const targetPort = port || this.defaultPort;
808
+ const portData = this._sitesByPort[targetPort];
809
+ if (!portData) {
810
+ return (req, res) => {
811
+ res.writeHead(404);
812
+ res.end('Site not found');
705
813
  };
814
+ }
815
+ return this._createDispatcher(portData);
816
+ }
706
817
 
707
- // Create HTTP server for this domain
708
- const httpServer = http.createServer(dispatcher);
709
- this.portServers[port] = httpServer;
818
+ upgradeHandler(port) {
819
+ if (!this._initialized) throw new Error('Call init() before upgradeHandler()');
820
+ const targetPort = port || this.defaultPort;
821
+ const portData = this._sitesByPort[targetPort];
822
+ if (!portData) {
823
+ return (req, socket, head) => { socket.destroy(); };
824
+ }
825
+ return this._createUpgradeHandler(portData);
826
+ }
710
827
 
711
- // Handle WebSocket upgrade events
712
- httpServer.on('upgrade', (req, socket, head) => {
713
- virtualServer.processUpgrade(req, socket, head);
714
- });
828
+ sniCallback() {
829
+ if (!this._initialized) throw new Error('Call init() before sniCallback()');
830
+ if (!this._sniCallback) throw new Error('SNI callback not available in local mode');
831
+ return this._sniCallback;
832
+ }
715
833
 
716
- httpServer.listen(port, 'localhost', () => {
717
- const cleanDomain = normalizeDomainForLocalHost(domain);
718
- log.info(`🌐 ${domain} β†’ http://${localHostForDomain(cleanDomain)}:${port}`);
719
- });
834
+ attach(server, { port } = {}) {
835
+ if (!this._initialized) throw new Error('Call init() before attach()');
836
+ server.on('request', this.requestHandler(port));
837
+ server.on('upgrade', this.upgradeHandler(port));
838
+ return this;
839
+ }
720
840
 
721
- httpServer.on('error', (error) => {
722
- log.error(`❌ Error on port ${port} for ${domain}:`, error.message);
723
- });
841
+ startLocalMode() {
842
+ this.domainPorts = {};
843
+
844
+ for (const portData of Object.values(this._sitesByPort)) {
845
+ for (const [domain, virtualServer] of Object.entries(portData.virtualServers)) {
846
+ if (domain.startsWith('www.')) continue;
847
+
848
+ const port = this.assignPortToDomain(domain);
849
+ this.domainPorts[domain] = port;
850
+
851
+ const appHandler = portData.appHandlers[domain];
852
+
853
+ const dispatcher = (req, res) => {
854
+ virtualServer.fallbackHandler = appHandler;
855
+ if (virtualServer.requestListeners.length > 0) {
856
+ virtualServer.processRequest(req, res);
857
+ } else if (appHandler) {
858
+ appHandler(req, res);
859
+ } else {
860
+ res.writeHead(404);
861
+ res.end('Site not found');
862
+ }
863
+ };
864
+
865
+ const httpServer = http.createServer(dispatcher);
866
+ this.portServers[port] = httpServer;
867
+
868
+ httpServer.on('upgrade', (req, socket, head) => {
869
+ virtualServer.processUpgrade(req, socket, head);
870
+ });
871
+
872
+ httpServer.listen(port, 'localhost', () => {
873
+ const cleanDomain = normalizeDomainForLocalHost(domain);
874
+ log.info(`🌐 ${domain} β†’ http://${localHostForDomain(cleanDomain)}:${port}`);
875
+ });
876
+
877
+ httpServer.on('error', (error) => {
878
+ log.error(`❌ Error on port ${port} for ${domain}:`, error.message);
879
+ });
880
+ }
724
881
  }
725
882
 
726
883
  log.info(`(βœ”) Started ${Object.keys(this.portServers).length} sites in local mode`);
@@ -728,14 +885,8 @@ class Roster {
728
885
  }
729
886
 
730
887
  async start() {
731
- await this.loadSites();
888
+ await this.init();
732
889
 
733
- // Skip Greenlock configuration generation in local mode
734
- if (!this.local) {
735
- this.generateConfigJson();
736
- }
737
-
738
- // Handle local mode with simple HTTP server
739
890
  if (this.local) {
740
891
  return this.startLocalMode();
741
892
  }
@@ -794,7 +945,6 @@ class Roster {
794
945
  if (eventDomain && !msg.includes(`[${eventDomain}]`)) {
795
946
  msg = `[${eventDomain}] ${msg}`;
796
947
  }
797
- // Suppress known benign warnings from ACME when using acme-dns-01-cli
798
948
  if (event === 'warning' && typeof msg === 'string') {
799
949
  if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
800
950
  if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
@@ -804,8 +954,6 @@ class Roster {
804
954
  else log.info(msg);
805
955
  }
806
956
  };
807
- // Keep a direct greenlock runtime handle so we can call get() explicitly under Bun
808
- // before binding :443, avoiding invalid non-TLS responses on startup.
809
957
  const greenlockRuntime = GreenlockShim.create(greenlockOptions);
810
958
  const greenlock = Greenlock.init({
811
959
  ...greenlockOptions,
@@ -814,130 +962,22 @@ class Roster {
814
962
 
815
963
  return greenlock.ready(async glx => {
816
964
  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
- }
841
-
842
965
  const bunTlsHotReloadHandlers = [];
843
966
 
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
967
  httpServer.listen(80, this.hostname, () => {
879
968
  log.info('HTTP server listening on port 80');
880
969
  });
881
970
 
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
- // Handle different port types
898
- for (const [port, portData] of Object.entries(sitesByPort)) {
971
+ for (const [port, portData] of Object.entries(this._sitesByPort)) {
899
972
  const portNum = parseInt(port);
900
- const dispatcher = createDispatcher(portData);
901
- const upgradeHandler = createUpgradeHandler(portData);
902
- 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
- const loadCert = (subjectDir) => {
912
- const normalizedSubject = normalizeHostInput(subjectDir).trim().toLowerCase();
913
- if (!normalizedSubject) return null;
914
- const certPath = path.join(greenlockStorePath, 'live', normalizedSubject);
915
- const keyPath = path.join(certPath, 'privkey.pem');
916
- const certFilePath = path.join(certPath, 'cert.pem');
917
- const chainPath = path.join(certPath, 'chain.pem');
918
- if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
919
- return {
920
- key: fs.readFileSync(keyPath, 'utf8'),
921
- cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
922
- };
923
- }
924
- return null;
925
- };
926
- const resolvePemsForServername = (servername) => {
927
- const host = normalizeHostInput(servername).trim().toLowerCase();
928
- if (!host) return null;
929
- const candidates = buildCertLookupCandidates(host);
930
- for (const candidate of candidates) {
931
- const pems = loadCert(candidate);
932
- if (pems) return pems;
933
- }
934
- return null;
935
- };
973
+ const dispatcher = this._createDispatcher(portData);
974
+ const upgradeHandler = this._createUpgradeHandler(portData);
975
+
936
976
  const issueAndReloadPemsForServername = async (servername) => {
937
- const host = normalizeHostInput(servername).trim().toLowerCase();
977
+ const host = this._normalizeHostInput(servername).trim().toLowerCase();
938
978
  if (!host) return null;
939
979
 
940
- let pems = resolvePemsForServername(host);
980
+ let pems = this._resolvePemsForServername(host);
941
981
  if (pems) return pems;
942
982
 
943
983
  try {
@@ -946,11 +986,9 @@ class Roster {
946
986
  log.warn(`⚠️ Greenlock issuance failed for ${host}: ${error?.message || error}`);
947
987
  }
948
988
 
949
- pems = resolvePemsForServername(host);
989
+ pems = this._resolvePemsForServername(host);
950
990
  if (pems) return pems;
951
991
 
952
- // For wildcard zones, try a valid subdomain bootstrap host so Greenlock can
953
- // resolve the wildcard site without relying on invalid "*.domain" servername input.
954
992
  const wildcardSubject = wildcardSubjectForHost(host);
955
993
  const zone = wildcardSubject ? wildcardRoot(wildcardSubject) : null;
956
994
  if (zone) {
@@ -960,11 +998,12 @@ class Roster {
960
998
  } catch (error) {
961
999
  log.warn(`⚠️ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
962
1000
  }
963
- pems = resolvePemsForServername(host);
1001
+ pems = this._resolvePemsForServername(host);
964
1002
  }
965
1003
 
966
1004
  return pems;
967
1005
  };
1006
+
968
1007
  const ensureBunDefaultPems = async (primaryDomain) => {
969
1008
  let pems = await issueAndReloadPemsForServername(primaryDomain);
970
1009
 
@@ -974,7 +1013,7 @@ class Roster {
974
1013
 
975
1014
  if (pems && needsWildcard && !certCoversName(pems.cert, `*.${primaryDomain}`)) {
976
1015
  log.warn(`⚠️ Existing cert for ${primaryDomain} lacks *.${primaryDomain} SAN β€” clearing stale cert for combined re-issuance`);
977
- const certDir = path.join(greenlockStorePath, 'live', primaryDomain);
1016
+ const certDir = path.join(this.greenlockStorePath, 'live', primaryDomain);
978
1017
  try { fs.rmSync(certDir, { recursive: true, force: true }); } catch {}
979
1018
  pems = null;
980
1019
  }
@@ -989,7 +1028,7 @@ class Roster {
989
1028
  log.error(`❌ Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
990
1029
  }
991
1030
 
992
- pems = resolvePemsForServername(primaryDomain);
1031
+ pems = this._resolvePemsForServername(primaryDomain);
993
1032
  if (pems) return pems;
994
1033
 
995
1034
  throw new Error(
@@ -999,15 +1038,11 @@ class Roster {
999
1038
  };
1000
1039
 
1001
1040
  if (portNum === this.defaultPort) {
1002
- // Bun has known gaps around SNICallback compatibility.
1003
- // Fallback to static cert loading for the primary domain on default HTTPS port.
1004
1041
  const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
1005
1042
  let httpsServer;
1006
1043
 
1007
1044
  if (isBunRuntime) {
1008
1045
  const primaryDomain = Object.keys(portData.virtualServers)[0];
1009
- // Under Bun, avoid glx.httpsServer fallback (may serve invalid TLS on :443).
1010
- // Require concrete PEM files and create native https server directly.
1011
1046
  let defaultPems = await ensureBunDefaultPems(primaryDomain);
1012
1047
  httpsServer = https.createServer({
1013
1048
  ...tlsOpts,
@@ -1043,21 +1078,18 @@ class Roster {
1043
1078
  }
1044
1079
 
1045
1080
  this.portServers[portNum] = httpsServer;
1046
-
1047
- // Handle WebSocket upgrade events
1048
1081
  httpsServer.on('upgrade', upgradeHandler);
1049
1082
 
1050
1083
  httpsServer.listen(portNum, this.hostname, () => {
1051
1084
  log.info(`HTTPS server listening on port ${portNum}`);
1052
1085
  });
1053
1086
  } else {
1054
- // Create HTTPS server for custom ports using Greenlock certificates
1055
1087
  const httpsOptions = {
1056
1088
  minVersion: this.tlsMinVersion,
1057
1089
  maxVersion: this.tlsMaxVersion,
1058
1090
  SNICallback: (servername, callback) => {
1059
1091
  try {
1060
- const pems = resolvePemsForServername(servername);
1092
+ const pems = this._resolvePemsForServername(servername);
1061
1093
  if (pems) {
1062
1094
  callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
1063
1095
  } else {
@@ -1070,8 +1102,6 @@ class Roster {
1070
1102
  };
1071
1103
 
1072
1104
  const httpsServer = https.createServer(httpsOptions, dispatcher);
1073
-
1074
- // Handle WebSocket upgrade events
1075
1105
  httpsServer.on('upgrade', upgradeHandler);
1076
1106
 
1077
1107
  httpsServer.on('error', (error) => {
@@ -1079,7 +1109,6 @@ class Roster {
1079
1109
  });
1080
1110
 
1081
1111
  httpsServer.on('tlsClientError', (error) => {
1082
- // Suppress HTTP request errors to avoid log spam
1083
1112
  if (!error.message.includes('http request')) {
1084
1113
  log.error(`TLS error on port ${portNum}:`, error.message);
1085
1114
  }
@@ -1103,7 +1132,7 @@ class Roster {
1103
1132
  : 30000;
1104
1133
  const maxAttempts = Number.isFinite(Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
1105
1134
  ? Math.max(0, Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
1106
- : 0; // 0 = retry forever
1135
+ : 0;
1107
1136
 
1108
1137
  for (const zone of this.wildcardZones) {
1109
1138
  const bootstrapHost = `bun-bootstrap.${zone}`;
@@ -1112,7 +1141,6 @@ class Roster {
1112
1141
  log.warn(`⚠️ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
1113
1142
  let reloaded = false;
1114
1143
  for (const reloadTls of bunTlsHotReloadHandlers) {
1115
- // Trigger issuance + immediately hot-reload default TLS context when ready.
1116
1144
  reloaded = (await reloadTls(bootstrapHost, `prewarm ${bootstrapHost} attempt ${attempt}`)) || reloaded;
1117
1145
  }
1118
1146
  if (!reloaded) {
@@ -1131,7 +1159,6 @@ class Roster {
1131
1159
  }
1132
1160
  };
1133
1161
 
1134
- // Background prewarm + retries so HTTPS startup is not blocked by DNS propagation timing.
1135
1162
  attemptPrewarm().catch(() => {});
1136
1163
  }
1137
1164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "2.3.16",
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": {
@@ -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,7 +177,22 @@ new Roster({
153
177
  ## Core API
154
178
 
155
179
  ### `roster.start()`
156
- Loads sites, generates SSL config, starts servers. Returns `Promise<void>`.
180
+ Loads sites, generates SSL config, starts servers. Returns `Promise<void>`. Calls `init()` internally.
181
+
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.
184
+
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.
187
+
188
+ ### `roster.upgradeHandler(port?)`
189
+ Returns `(req, socket, head) => void` for WebSocket upgrade routing. Requires `init()` first.
190
+
191
+ ### `roster.sniCallback()`
192
+ Returns `(servername, callback) => void` TLS SNI callback that resolves certs from `greenlockStorePath`. Production mode only. Requires `init()` first.
193
+
194
+ ### `roster.attach(server, { port }?)`
195
+ Convenience: wires `requestHandler` + `upgradeHandler` onto an external `http.Server` or `https.Server`. Returns `this`. Requires `init()` first.
157
196
 
158
197
  ### `roster.register(domain, handler)`
159
198
  Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
@@ -718,3 +718,276 @@ describe('Roster generateConfigJson', () => {
718
718
  }
719
719
  });
720
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
+ });