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 +48 -39
- package/demo/cluster-sticky-https-sni.js +90 -0
- package/demo/cluster-sticky-worker.js +60 -0
- package/docs/architecture/decision-roster-cluster-friendly-api.md +30 -0
- package/index.js +204 -269
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +39 -59
- package/test/roster-server.test.js +273 -150
- package/docs/decisions/external-runtime-router-api.md +0 -12
- package/docs/patterns/roster-dispatch-reuse.md +0 -12
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
|
-
##
|
|
353
|
+
## π Cluster-Friendly API (init / attach)
|
|
354
354
|
|
|
355
|
-
|
|
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
|
-
###
|
|
357
|
+
### How It Works
|
|
358
358
|
|
|
359
|
-
|
|
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
|
-
###
|
|
361
|
+
### Quick Example: Sticky-Session Worker
|
|
363
362
|
|
|
364
363
|
```javascript
|
|
365
|
-
import
|
|
364
|
+
import https from 'https';
|
|
366
365
|
import Roster from 'roster-server';
|
|
367
366
|
|
|
368
|
-
const roster = new Roster({
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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.
|
|
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
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
387
|
+
### API Reference
|
|
395
388
|
|
|
396
|
-
|
|
397
|
-
server.listen(3000);
|
|
398
|
-
```
|
|
389
|
+
#### `roster.init()` β `Promise<Roster>`
|
|
399
390
|
|
|
400
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
+
```
|