roster-server 2.4.0 β†’ 2.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.greenlockrc ADDED
@@ -0,0 +1 @@
1
+ {"configDir":"/Users/martinclasen/Stuff/greenlock.d"}
package/README.md CHANGED
@@ -247,6 +247,8 @@ When creating a new `RosterServer` instance, you can pass the following options:
247
247
  - `greenlockStorePath` (string): Directory for Greenlock configuration.
248
248
  - `dnsChallenge` (object|false): Optional override for wildcard DNS-01 challenge config. Default is `acme-dns-01-cli` wrapper with `propagationDelay: 120000`, `autoContinue: false`, and `dryRunDelay: 120000`. Manual mode still works, but you can enable automatic Linode DNS API mode by setting `ROSTER_DNS_PROVIDER=linode` and `LINODE_API_KEY`. In automatic mode, Roster creates/removes TXT records itself and still polls public resolvers every 15s before continuing. Set `false` to disable DNS challenge. You can pass `{ module: '...', propagationDelay: 180000 }` to tune DNS wait time (ms). For Greenlock dry-runs (`_greenlock-dryrun-*`), delay defaults to `dryRunDelay` (same as `propagationDelay` unless overridden with `dnsChallenge.dryRunDelay` or env `ROSTER_DNS_DRYRUN_DELAY_MS`). When wildcard sites are present, Roster creates a separate wildcard certificate (`*.example.com`) that uses `dns-01`, while apex/www stay on the regular certificate flow (typically `http-01`), reducing manual TXT records.
249
249
  - `staging` (boolean): Set to `true` to use Let's Encrypt's staging environment (for testing).
250
+ - `autoCertificates` (boolean): Enables automatic certificate issuance and renewal in production lifecycle. **Default: `true`**. Set to `false` only if certificates are managed externally.
251
+ - `certificateRenewIntervalMs` (number): Renewal check interval when `autoCertificates` is enabled (minimum 60s, default 12h).
250
252
  - `local` (boolean): Set to `true` to run in local development mode.
251
253
  - `minLocalPort` (number): Minimum port for local mode (default: 4000).
252
254
  - `maxLocalPort` (number): Maximum port for local mode (default: 9999).
@@ -350,61 +352,70 @@ console.log(customRoster.getUrl('api.example.com'));
350
352
  // β†’ https://api.example.com:8443
351
353
  ```
352
354
 
353
- ## πŸ”€ External Clusters / Sticky Runtimes
355
+ ## πŸ”Œ Cluster-Friendly API (init / attach)
354
356
 
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()`.
357
+ 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
358
 
357
- ### Ownership Model
359
+ ### How It Works
358
360
 
359
- - `roster.start()` = Roster-owned HTTP/HTTPS lifecycle (Greenlock, listeners, ports)
360
- - `roster.buildRuntimeRouter()` = externally-owned server lifecycle (you call `listen`)
361
+ `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
362
 
362
- ### Minimal Worker Example
363
+ ### Quick Example: Sticky-Session Worker
363
364
 
364
365
  ```javascript
365
- import http from 'http';
366
+ import https from 'https';
366
367
  import Roster from 'roster-server';
367
368
 
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
- };
369
+ const roster = new Roster({
370
+ email: 'admin@example.com',
371
+ wwwPath: '/srv/www',
372
+ greenlockStorePath: '/srv/greenlock.d'
375
373
  });
376
374
 
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
- });
375
+ await roster.init();
384
376
 
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
377
+ // Create your own HTTPS server with Roster's SNI + routing
378
+ const server = https.createServer({ SNICallback: roster.sniCallback() });
379
+ roster.attach(server);
380
+
381
+ // Master passes connections via IPC β€” worker never calls listen()
382
+ process.on('message', (msg, connection) => {
383
+ if (msg === 'sticky-session:connection') {
384
+ server.emit('connection', connection);
385
+ }
392
386
  });
387
+ ```
393
388
 
394
- router.attach(server);
389
+ ### API Reference
395
390
 
396
- // External runtime controls binding/lifecycle:
397
- server.listen(3000);
398
- ```
391
+ #### `roster.init()` β†’ `Promise<Roster>`
399
392
 
400
- ### API Notes
393
+ Loads sites, generates SSL config (production), creates VirtualServers and initializes handlers. Idempotent β€” calling it twice is safe. Returns `this` for chaining.
401
394
 
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
395
+ #### `roster.requestHandler(port?)` β†’ `(req, res) => void`
396
+
397
+ Returns the Host-header dispatch function for a given port (defaults to `defaultPort`). Handles www→non-www redirects, wildcard matching, and VirtualServer dispatch.
398
+
399
+ #### `roster.upgradeHandler(port?)` β†’ `(req, socket, head) => void`
400
+
401
+ Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to the correct VirtualServer.
402
+
403
+ #### `roster.sniCallback()` β†’ `(servername, callback) => void`
404
+
405
+ Returns a TLS SNI callback. It resolves certificates from `greenlockStorePath` and, when `autoCertificates` is enabled (default), can issue missing certificates automatically. Not available in local mode.
406
+
407
+ #### `roster.attach(server, { port }?)` β†’ `Roster`
408
+
409
+ Convenience method. Wires `requestHandler` and `upgradeHandler` onto `server.on('request', ...)` and `server.on('upgrade', ...)`. Returns `this` for chaining.
410
+
411
+ ### Standalone Mode (unchanged)
412
+
413
+ `roster.start()` still works exactly as before β€” it calls `init()` internally, then creates and binds servers:
414
+
415
+ ```javascript
416
+ const roster = new Roster({ ... });
417
+ await roster.start(); // full standalone mode, no changes needed
418
+ ```
408
419
 
409
420
  ## πŸ§‚ A Touch of Magic
410
421
 
@@ -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,84 @@
1
+ const cluster = require('cluster');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const https = require('https');
5
+ const Roster = require('../index.js');
6
+
7
+ // Change these values to your target domain and HTTPS port.
8
+ const CONFIG = {
9
+ domain: 'chinchon.blyts.com',
10
+ httpsPort: 4336,
11
+ workers: Math.max(1, Math.min(2, os.cpus().length)),
12
+ wwwPath: path.join(__dirname, 'www'),
13
+ greenlockStorePath: path.join(__dirname, '..', 'greenlock.d'),
14
+ email: 'mclasen@blyts.com',
15
+ staging: false,
16
+ autoCertificates: true
17
+ };
18
+
19
+ async function startWorker() {
20
+ const roster = new Roster({
21
+ local: false,
22
+ email: CONFIG.email,
23
+ staging: CONFIG.staging,
24
+ wwwPath: CONFIG.wwwPath,
25
+ greenlockStorePath: CONFIG.greenlockStorePath,
26
+ autoCertificates: CONFIG.autoCertificates
27
+ });
28
+
29
+ // Domain is configured from CONFIG (single source of truth).
30
+ roster.register(CONFIG.domain, () => {
31
+ return (req, res) => {
32
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
33
+ res.end(`HTTPS cluster response from worker ${process.pid} for ${CONFIG.domain}`);
34
+ };
35
+ });
36
+
37
+ await roster.init();
38
+ // Automatic cert issuance by roster-server (and renewal loop) in cluster-friendly mode.
39
+ const defaultPems = await roster.ensureCertificate(CONFIG.domain);
40
+
41
+ const server = https.createServer({
42
+ key: defaultPems.key,
43
+ cert: defaultPems.cert,
44
+ SNICallback: roster.sniCallback(),
45
+ minVersion: 'TLSv1.2',
46
+ maxVersion: 'TLSv1.3'
47
+ });
48
+
49
+ roster.attach(server);
50
+
51
+ server.listen(CONFIG.httpsPort, () => {
52
+ console.log(`[worker ${process.pid}] listening on https://0.0.0.0:${CONFIG.httpsPort} for domain ${CONFIG.domain}`);
53
+ });
54
+ }
55
+
56
+ async function startPrimary() {
57
+ console.log(`\n[primary ${process.pid}] starting ${CONFIG.workers} workers`);
58
+ console.log(`domain=${CONFIG.domain} port=${CONFIG.httpsPort}`);
59
+ console.log(`wwwPath=${CONFIG.wwwPath}`);
60
+ console.log(`greenlockStorePath=${CONFIG.greenlockStorePath}\n`);
61
+ console.log(`[primary] cert lifecycle managed by roster-server (autoCertificates=${CONFIG.autoCertificates})\n`);
62
+
63
+ for (let i = 0; i < CONFIG.workers; i++) {
64
+ cluster.fork();
65
+ }
66
+
67
+ cluster.on('exit', (worker) => {
68
+ console.log(`[primary] worker ${worker.process.pid} exited, restarting...`);
69
+ cluster.fork();
70
+ });
71
+ }
72
+
73
+ if (cluster.isPrimary) {
74
+ startPrimary().catch((err) => {
75
+ console.error(`[primary ${process.pid}] startup failed`, err);
76
+ process.exit(1);
77
+ });
78
+ } else {
79
+ startWorker().catch((err) => {
80
+ console.error(`[worker ${process.pid}] startup failed`, err);
81
+ process.exit(1);
82
+ });
83
+ }
84
+
@@ -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,20 @@
1
+ ---
2
+ id: eyap8usul0
3
+ type: decision
4
+ title: 'Decision update: autoCertificates default true'
5
+ created: '2026-03-20 23:53:16'
6
+ ---
7
+ # Decision update: autoCertificates default true
8
+
9
+ **What changed**: `autoCertificates` now defaults to `true` instead of `false`.
10
+
11
+ **Why**: User expectation and product value are that SSL lifecycle should be automatic and robust by default in RosterServer.
12
+
13
+ **Implementation**:
14
+ - Constructor default changed in `index.js` (`parseBooleanFlag(options.autoCertificates, true)`).
15
+ - Added constructor tests for default true and explicit opt-out (`autoCertificates: false`).
16
+ - Updated docs (`README.md`, `skills/roster-server/SKILL.md`) to reflect default-on behavior.
17
+
18
+ **Guardrails**:
19
+ - Users can still opt out with `autoCertificates: false` when certs are externally managed.
20
+ - `ensureCertificate()` keeps explicit error message when autoCertificates is disabled.