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/README.md CHANGED
@@ -350,61 +350,70 @@ console.log(customRoster.getUrl('api.example.com'));
350
350
  // β†’ https://api.example.com:8443
351
351
  ```
352
352
 
353
- ## πŸ”€ External Clusters / Sticky Runtimes
353
+ ## πŸ”Œ Cluster-Friendly API (init / attach)
354
354
 
355
- If your runtime already owns `server.listen(...)` (for example sticky-session workers in Node cluster, PM2, or custom LB workers), you can still use Roster's domain routing, virtual servers, and Socket.IO-compatible upgrade flow without calling `roster.start()`.
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
356
 
357
- ### Ownership Model
357
+ ### How It Works
358
358
 
359
- - `roster.start()` = Roster-owned HTTP/HTTPS lifecycle (Greenlock, listeners, ports)
360
- - `roster.buildRuntimeRouter()` = externally-owned server lifecycle (you call `listen`)
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`.
361
360
 
362
- ### Minimal Worker Example
361
+ ### Quick Example: Sticky-Session Worker
363
362
 
364
363
  ```javascript
365
- import http from 'http';
364
+ import https from 'https';
366
365
  import Roster from 'roster-server';
367
366
 
368
- const roster = new Roster({ local: false, port: 443 });
369
-
370
- roster.register('example.com', (virtualServer) => {
371
- return (req, res) => {
372
- res.writeHead(200, { 'Content-Type': 'text/plain' });
373
- res.end('hello from example.com');
374
- };
367
+ const roster = new Roster({
368
+ email: 'admin@example.com',
369
+ wwwPath: '/srv/www',
370
+ greenlockStorePath: '/srv/greenlock.d'
375
371
  });
376
372
 
377
- roster.register('*.example.com', (virtualServer) => {
378
- // Socket.IO (or raw WS) can bind to virtualServer "upgrade" listeners here.
379
- return (req, res) => {
380
- res.writeHead(200, { 'Content-Type': 'text/plain' });
381
- res.end('hello from wildcard');
382
- };
383
- });
373
+ await roster.init();
384
374
 
385
- const server = http.createServer();
386
- const router = roster.buildRuntimeRouter({
387
- targetPort: 443, // optional, defaults to roster.defaultPort
388
- hostAliases: {
389
- localhost: 'example.com'
390
- }, // optional map, or callback: (host) => mappedHost
391
- allowWwwRedirect: true // optional, defaults to true
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
+ }
392
384
  });
385
+ ```
393
386
 
394
- router.attach(server);
387
+ ### API Reference
395
388
 
396
- // External runtime controls binding/lifecycle:
397
- server.listen(3000);
398
- ```
389
+ #### `roster.init()` β†’ `Promise<Roster>`
399
390
 
400
- ### API Notes
391
+ Loads sites, generates SSL config (production), creates VirtualServers and initializes handlers. Idempotent β€” calling it twice is safe. Returns `this` for chaining.
401
392
 
402
- - `roster.prepareSites(options?)`: builds virtual servers + app handlers without listening.
403
- - `roster.buildRuntimeRouter(options?)` returns:
404
- - `attach(server)` to bind `request` + `upgrade`
405
- - `dispatchRequest(req, res)` for manual request dispatch
406
- - `dispatchUpgrade(req, socket, head)` for manual upgrade dispatch
407
- - `portData` + `diagnostics` snapshots for debugging
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
+ ```
408
417
 
409
418
  ## πŸ§‚ A Touch of Magic
410
419
 
@@ -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
+ ```