roster-server 2.4.11 β†’ 2.4.13

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
@@ -1,513 +1,305 @@
1
1
  # πŸ‘Ύ RosterServer
2
2
 
3
- **Because hosting multiple HTTPS sites has never been easier!**
3
+ Host multiple domains from one Node.js process, with HTTPS certificates managed through Greenlock, Express and Socket.IO integration, static file serving, and local HTTP development. Each site receives a virtual server for request and upgrade listeners; applications share the process.
4
4
 
5
- Welcome to **RosterServer**, the ultimate domain host router with automatic HTTPS and virtual hosting. Why juggle multiple servers when you can have one server to rule them all? πŸ˜‰
5
+ ## Installation
6
6
 
7
- ## ✨ Features
8
-
9
- - **Automatic HTTPS** with Let's Encrypt via Greenlock.
10
- - **Dynamic Site Loading**: Just drop your Node.js apps in the `www` folder.
11
- - **Static Sites**: No code? No problem. Drop a folder with `index.html` (and assets) and RosterServer serves it automaticallyβ€”modular static handler with path-traversal protection and strict 404s.
12
- - **Virtual Hosting**: Serve multiple domains from a single server.
13
- - **Automatic Redirects**: Redirect `www` subdomains to the root domain.
14
- - **Optional Request Plugins**: Run synchronous filters before site handlers, including the bundled scanner blocker.
15
- - **Zero Configuration**: Well, almost zero. Just a tiny bit of setup.
16
- - **Bun compatible**: Works with both Node.js and [Bun](https://bun.sh).
17
-
18
- ## πŸ“¦ Installation
19
-
20
- ```bash
21
- npm install roster-server
22
- ```
23
-
24
- Or with [Bun](https://bun.sh):
25
-
26
- ```bash
27
- bun add roster-server
7
+ ```sh
8
+ pnpm add roster-server
28
9
  ```
29
10
 
30
- ## πŸ€– AI Skill
31
-
32
- You can also add RosterServer as a skill for AI agentic development:
33
-
34
- ```bash
35
- npx skills add https://github.com/clasen/RosterServer --skill roster-server
36
- ```
37
-
38
- ## πŸ› οΈ Usage
39
-
40
- ### Directory Structure
41
-
42
- Your project should look something like this:
43
-
44
- ```
45
- /srv/
46
- β”œβ”€β”€ greenlock.d/
47
- β”œβ”€β”€ roster/server.js
48
- └── www/
49
- β”œβ”€β”€ example.com/
50
- β”‚ └── index.js
51
- β”œβ”€β”€ subdomain.example.com/
52
- β”‚ └── index.js
53
- β”œβ”€β”€ static-site.com/ # Static site: no index.js needed
54
- β”‚ β”œβ”€β”€ index.html
55
- β”‚ β”œβ”€β”€ css/
56
- β”‚ └── images/
57
- β”œβ”€β”€ other-domain.com/
58
- β”‚ └── index.js
59
- └── *.example.com/ # Wildcard: one handler for all subdomains (api.example.com, app.example.com, etc.)
60
- └── index.js
61
- ```
62
-
63
- Each domain folder can have either:
64
- - **Node app**: `index.js`, `index.mjs`, or `index.cjs` (exporting a request handler).
65
- - **Static site**: `index.html` (and any assets). If no JS entry exists, RosterServer serves the folder as static files. Node takes precedence when both exist.
66
-
67
- ### Wildcard DNS (*.example.com)
68
-
69
- You can serve all subdomains of a domain with a single handler in three ways:
11
+ `npm install roster-server` and `bun add roster-server` are also supported installation commands. The package exports a CommonJS constructor that can also be imported as an ESM default. Server examples below use `.mjs`; site examples use `.cjs`.
70
12
 
71
- 1. **Folder**: Create a directory named literally `*.example.com` under `www` (e.g. `www/*.example.com/index.js`). Any request to `api.example.com`, `app.example.com`, etc. will use that handler.
72
- 2. **Register (default port)**: `roster.register('*.example.com', handler)` for the default HTTPS port.
73
- 3. **Register (custom port)**: `roster.register('*.example.com:8080', handler)` for a specific port.
13
+ ## Quick start: local HTTP
74
14
 
75
- Wildcard SSL certificates require **DNS-01** validation (Let's Encrypt does not support HTTP-01 for wildcards). By default Roster uses `acme-dns-01-cli` through an internal wrapper (adds `propagationDelay` and modern plugin signatures).
76
-
77
- For fully automatic TXT records with Linode DNS, set:
78
-
79
- ```bash
80
- export ROSTER_DNS_PROVIDER=linode
81
- export LINODE_API_KEY=...
82
- ```
83
-
84
- Then Roster creates/removes `_acme-challenge` TXT records automatically via `api.linode.com`.
85
- If `LINODE_API_KEY` is present, this mode auto-enables by default for wildcard DNS-01.
86
-
87
- Override with a custom plugin:
15
+ Save as `server.mjs` and run `node server.mjs` from your application directory:
88
16
 
89
17
  ```javascript
18
+ import path from 'node:path';
90
19
  import Roster from 'roster-server';
91
20
 
92
21
  const roster = new Roster({
93
- email: 'admin@example.com',
94
- wwwPath: '/srv/www',
95
- greenlockStorePath: '/srv/greenlock.d',
96
- dnsChallenge: { module: 'acme-dns-01-route53', /* provider options */ } // optional override
22
+ local: true,
23
+ wwwPath: path.resolve('www')
97
24
  });
25
+
26
+ roster.register('example.com', () => (req, res) => res.end('Hello'));
27
+ await roster.start();
28
+ console.log(roster.getUrl('example.com'));
98
29
  ```
99
30
 
100
- Set `dnsChallenge: false` to disable. For other DNS providers install the plugin in your app and pass it. See [Greenlock DNS plugins](https://git.rootprojects.org/root/greenlock-express.js#dns-01-challenge-plugins).
31
+ Local mode binds to `localhost`, skips certificates, and assigns domain ports within `minLocalPort`–`maxLocalPort` (default `4000`–`9999`). Assignments use a domain hash and resolve collisions within the instance; they do not probe for available OS ports. A bind failure rejects startup. Read the assigned URL with `getUrl()` instead of hardcoding a sample port.
101
32
 
102
- ### Setting Up Your Server
33
+ For production HTTPS, use a real contact email, public DNS pointing to the host, and explicit storage paths:
103
34
 
104
35
  ```javascript
105
- // /srv/roster/server.js
106
36
  import Roster from 'roster-server';
107
37
 
108
- const options = {
38
+ const roster = new Roster({
109
39
  email: 'admin@example.com',
110
- greenlockStorePath: '/srv/greenlock.d', // Path to your Greenlock configuration directory
111
- wwwPath: '/srv/www' // Path to your 'www' directory (default: '../www')
112
- };
40
+ wwwPath: '/srv/www',
41
+ greenlockStorePath: '/srv/greenlock.d'
42
+ });
113
43
 
114
- const server = new Roster(options);
115
- server.start();
44
+ await roster.start();
116
45
  ```
117
46
 
118
- ### Blocking vulnerability scanners
47
+ Standalone HTTPS opens port `80` for ACME challenges in addition to the configured HTTPS ports. Port `80` cannot be a production site port. Use `staging: true` when testing certificate issuance; staging certificates are not publicly trusted.
119
48
 
120
- RosterServer includes an optional request plugin that rejects common PHP, WordPress, repository, and sensitive-file probes before they reach a site handler. Suspicious paths always receive a `404`; after the configured number of strikes, every request from that client is rejected until the ban expires.
49
+ ## Sites and routing
121
50
 
122
- ```javascript
123
- import Roster from 'roster-server';
124
- import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
125
-
126
- const roster = new Roster(options);
127
-
128
- roster.use(createScannerBlocker({
129
- onBlock(event) {
130
- // Send event to the application's existing logger if desired.
131
- }
132
- }));
133
-
134
- roster.start();
51
+ ```text
52
+ www/
53
+ β”œβ”€β”€ example.com/index.cjs
54
+ β”œβ”€β”€ api.example.com/index.mjs
55
+ β”œβ”€β”€ static.example.com/index.html
56
+ └── *.example.com/index.cjs
135
57
  ```
136
58
 
137
- By default, the plugin uses a 60-second strike window, 3 strikes, a 15-minute ban, tracks up to 10,000 clients, and does not trust proxy headers. Pass only the values you need to override. Keep `trustProxy: false` when RosterServer receives traffic directly. Set it to `true` only when a trusted reverse proxy overwrites `X-Forwarded-For`; otherwise clients can spoof the address used for bans.
138
-
139
- The in-memory strike and ban state is bounded by `maxTrackedClients`, belongs to one RosterServer process, and is cleared on restart. Use the optional `onBlock(event)` callback to feed a shared firewall or Fail2ban when bans must persist or span multiple workers. Routes such as `/atom` and `/articles/config` are not classified as scanner probes.
59
+ For each directory, discovery checks `index.js`, `index.mjs`, then `index.cjs`. If none exists, `index.html` enables static serving. `filename` changes the script basename; the static entry remains `index.html`. Match `.js` exports to the site's package module type, or use `.cjs`/`.mjs` explicitly.
140
60
 
141
- ### Your Site Handlers
61
+ Discovery happens during `init()`. Register manual sites before initialization. Discovered sites use HTTPS port **443**, even when the instance's `port` is different; a discovered registration replaces a manual registration for the same domain and port. Loading new sites requires a new instance or process restart.
142
62
 
143
- Each domain has its own folder under `www`. You can use:
63
+ ### Site factory contract
144
64
 
145
- - **Node app**: Put `index.js` (or `index.mjs` / `index.cjs`) that exports a request handler function.
146
- - **Static site**: Put `index.html` and your assets (CSS, JS, images). RosterServer will serve files from that folder. `GET /` serves `index.html`; other paths serve the file if it exists, or 404. Path traversal is blocked. If both an index script and `index.html` exist, the script is used.
65
+ Export a **synchronous factory** `(virtualServer) => requestHandler`. The returned `(req, res)` handler may be asynchronous. Return an Express app directly; do not call `app.listen()` inside a factory.
147
66
 
148
- ### Examples
67
+ Basic site, saved as `www/example.com/index.cjs`:
149
68
 
150
- I'll help analyze the example files shown. You have 3 different implementations demonstrating various ways to handle requests in RosterServer:
151
-
152
- 1. **Basic HTTP Handler**:
153
- ```javascript:demo/www/example.com/index.js
154
- export default (httpsServer) => {
155
- return (req, res) => {
156
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
157
- res.end('"Loco de pensar, queriendo entrar en razΓ³n, y el corazΓ³n tiene razones que la propia razΓ³n nunca entenderΓ‘."');
158
- };
69
+ ```javascript
70
+ module.exports = () => async (req, res) => {
71
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
72
+ res.end('Hello');
159
73
  };
160
74
  ```
161
75
 
162
- 2. **Express App**:
163
- ```javascript:demo/www/express.example.com/index.js
164
- import express from 'express';
76
+ Express site, with Express installed in the consuming application:
165
77
 
166
- export default (httpsServer) => {
167
- const app = express();
168
- app.get('/', (req, res) => {
169
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
170
- res.send('"Loco de pensar, queriendo entrar en razΓ³n, y el corazΓ³n tiene razones que la propia razΓ³n nunca entenderΓ‘."');
171
- });
78
+ ```javascript
79
+ const express = require('express');
172
80
 
81
+ module.exports = () => {
82
+ const app = express();
83
+ app.get('/', (req, res) => res.json({ ok: true }));
173
84
  return app;
174
- }
85
+ };
175
86
  ```
176
87
 
177
- 3. **Socket.IO Server**:
178
- ```javascript:demo/www/sio.example.com/index.js
179
- import { Server } from 'socket.io';
88
+ The virtual server supports request/upgrade listener integration; it is not a separately listening TCP server or a process isolation boundary. Request listeners own their requests, including asynchronous responses. Roster does not invoke the returned handler just because a listener has not finished. Wrappers can capture the site's HTTP fallback through `server.listeners('request')`.
89
+
90
+ Thrown or rejected request-handler errors produce a `500`, or destroy the response if headers were already sent.
180
91
 
181
- export default (httpsServer) => {
182
- const io = new Server(httpsServer);
92
+ ### Socket.IO and resource cleanup
183
93
 
184
- io.on('connection', (socket) => {
185
- console.log('A user connected');
94
+ With Socket.IO installed in the consuming application:
186
95
 
187
- socket.on('chat:message', (msg) => {
188
- console.log('Message received:', msg);
189
- io.emit('chat:message', msg);
190
- });
96
+ ```javascript
97
+ const { Server } = require('socket.io');
191
98
 
192
- socket.on('disconnect', () => {
193
- console.log('User disconnected');
194
- });
99
+ module.exports = (server) => {
100
+ const io = new Server(server);
101
+ io.on('connection', socket => {
102
+ socket.on('chat:message', message => io.emit('chat:message', message));
195
103
  });
104
+ server.onClose(() => new Promise(resolve => io.close(resolve)));
196
105
 
197
- return (req, res) => {
198
- if (req.url && req.url.startsWith(io.opts.path)) return;
199
- res.writeHead(200);
200
- res.end('Socket.IO server running');
201
- };
106
+ return (req, res) => res.end('Socket.IO site');
202
107
  };
203
108
  ```
204
109
 
205
- 4. **Manual**:
206
- ```javascript:demo/www/manual.js
207
- roster.register('example.com', (httpsServer) => {
208
- return (req, res) => {
209
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
210
- res.end('"Loco de pensar, queriendo entrar en razΓ³n, y el corazΓ³n tiene razones que la propia razΓ³n nunca entenderΓ‘."');
211
- };
212
- });
213
- ```
214
-
215
- 5. **Manual: Custom port**:
216
- ```javascript:demo/www/manual.js
217
- roster.register('example.com:8080', (httpsServer) => {
218
- return (req, res) => {
219
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
220
- res.end('"Mad with thought, striving to embrace reason, yet the heart holds reasons that reason itself shall never comprehend."');
221
- };
222
- });
223
- ```
224
-
225
- ### Running the Server
226
-
227
- ```bash
228
- # With Node.js
229
- node server.js
230
- ```
231
-
232
- Or with Bun:
233
-
234
- ```bash
235
- bun server.js
236
- ```
237
-
238
- And that's it! Your server is now hosting multiple HTTPS-enabled sites. πŸŽ‰
239
-
240
- ## 🀯 But Wait, There's More!
241
-
242
- ### Static Sites (index.html)
243
-
244
- Domains under `www` that have no `index.js`/`index.mjs`/`index.cjs` but do have `index.html` are served as static sites. The logic lives in `lib/static-site-handler.js` and `lib/resolve-site-app.js`:
245
-
246
- - **`GET /`** and **`GET /index.html`** serve `index.html`.
247
- - Any other path serves the file under the domain folder if it exists; otherwise **404** (strict, no SPA fallback).
248
- - Path traversal (e.g. `/../`) is rejected with **403**.
249
- - Content-Type is set from extension (html, css, js, images, fonts, etc.).
250
-
251
- No Express or extra dependenciesβ€”plain Node. At startup you’ll see `(βœ”) Loaded site: https://example.com (static)` for these domains.
252
-
253
- ### Automatic SSL Certificate Management
254
-
255
- RosterServer uses [greenlock-express](https://www.npmjs.com/package/greenlock-express) to automatically obtain and renew SSL certificates from Let's Encrypt. No need to manually manage certificates ever again. Unless you enjoy that sort of thing. 🧐
110
+ Socket.IO handles its own endpoint and delegates other requests to the returned handler. No manual path exclusion is required. Use the public `io.path()` method if application code needs its configured path.
256
111
 
257
- ### Redirects from `www`
112
+ ### Manual registration and custom ports
258
113
 
259
- All requests to `www.yourdomain.com` are automatically redirected to `yourdomain.com`. Because who needs the extra three characters? 😏
260
-
261
- ### Dynamic Site Loading
262
-
263
- Add a new site? Drop it into the `www` folder: either an `index.js` (or `.mjs`/`.cjs`) for a Node app, or an `index.html` (plus assets) for a static site. RosterServer picks the right handler automatically. Restart the server to load new sitesβ€”nodemon has your back. πŸ˜…
264
-
265
- ## βš™οΈ Configuration Options
266
-
267
- When creating a new `RosterServer` instance, you can pass the following options:
268
-
269
- - `email` (string): Your email for Let's Encrypt notifications.
270
- - `wwwPath` (string): Path to your `www` directory containing your sites.
271
- - `greenlockStorePath` (string): Directory for Greenlock configuration.
272
- - `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.
273
- - `staging` (boolean): Set to `true` to use Let's Encrypt's staging environment (for testing).
274
- - `autoCertificates` (boolean): Enables automatic certificate issuance and renewal in production lifecycle. **Default: `true`**. Set to `false` only if certificates are managed externally.
275
- - `certificateRenewIntervalMs` (number): Renewal check interval when `autoCertificates` is enabled (minimum 60s, default 12h).
276
- - `local` (boolean): Set to `true` to run in local development mode.
277
- - `minLocalPort` (number): Minimum port for local mode (default: 4000).
278
- - `maxLocalPort` (number): Maximum port for local mode (default: 9999).
279
-
280
- ## 🏠 Local Development Mode
281
-
282
- For local development and testing, you can run RosterServer in local mode by setting `local: true`. This mode is perfect for development environments where you don't need SSL certificates or production features.
283
-
284
- When `{ local: true }` is enabled, RosterServer **Skips SSL/HTTPS**: Runs pure HTTP servers instead of HTTPS.
285
-
286
- ### Setting Up Local Mode
114
+ After creating `roster`, register factories before `init()` or `start()`:
287
115
 
288
116
  ```javascript
289
- import Roster from 'roster-server';
290
-
291
- const server = new Roster({
292
- wwwPath: '/srv/www',
293
- local: true, // Enable local development mode
294
- minLocalPort: 4000, // Optional: minimum port (default: 4000)
295
- maxLocalPort: 9999 // Optional: maximum port (default: 9999)
296
- });
297
- server.start();
117
+ const site = () => (req, res) => res.end('API');
118
+ roster.register('api.example.com', site);
119
+ roster.register('api.example.com:8443', site);
120
+ roster.register('*.example.com:8443', site);
298
121
  ```
299
122
 
300
- ### Port Assignment
301
-
302
- In local mode, domains are automatically assigned ports based on a CRC32 hash of the domain name (default range 4000-9999, configurable via `minLocalPort` and `maxLocalPort`):
123
+ A registration without an explicit port uses the instance's `port`. Exact hosts take precedence over wildcard matches. `*.example.com` does not match the apex `example.com`; register the apex separately if needed. Wildcard certificates require [DNS-01 configuration](#certificates-and-dns).
303
124
 
304
- - `example.com` β†’ `http://localhost:9465`
305
- - `api.example.com` β†’ `http://localhost:9388`
306
- - And so on...
125
+ Requests to `www` hosts redirect to the host without `www`, preserving the explicit port in the Host header. `getUrl(domain)` normalizes `www` and supports wildcard lookups. In local mode it returns `localhost` or a subdomain such as `api.localhost`, with the assigned port. In production it uses the instance's default port; it is not a per-port URL lookup for registrations made only on non-default ports, which can return `null`.
307
126
 
308
- You can customize the port range:
127
+ ### Static sites
309
128
 
310
- ```javascript
311
- import Roster from 'roster-server';
312
-
313
- const roster = new Roster({
314
- local: true,
315
- minLocalPort: 5000, // Start from port 5000
316
- maxLocalPort: 6000 // Up to port 6000
317
- });
318
- ```
129
+ A directory with `index.html` and no entry script needs no JavaScript factory:
319
130
 
320
- ### Getting URLs
131
+ - `GET` streams the requested file; `HEAD` reads metadata without reading the body.
132
+ - `/` and directory requests serve the corresponding `index.html`.
133
+ - Missing files return `404`; there is no SPA fallback. Unsupported methods return `405`.
134
+ - Paths that resolve outside the site root are rejected. Content type is inferred from the file extension.
321
135
 
322
- RosterServer provides a method to get the URL for a domain that adapts automatically to your environment:
136
+ Filesystem operations are asynchronous, and disconnected downloads close their file handles.
323
137
 
324
- **Instance Method: `roster.getUrl(domain)`**
138
+ ## Startup and shutdown
325
139
 
326
- ```javascript
327
- import Roster from 'roster-server';
140
+ Concurrent `init()` calls share initialization, so each factory runs once per registered domain/port. Concurrent `start()` calls share startup. In standalone and local modes, `await roster.start()` waits for listeners to bind. Initialization or bind failures reject and clean up resources already initialized by the instance.
328
141
 
329
- const roster = new Roster({ local: true });
330
- roster.register('example.com', handler);
142
+ Register `virtualServer.onClose(fn)` in a factory for timers, databases, queues, or other site-owned resources. Hooks may return Promises, run once, and run concurrently after active HTTP requests drain. Put dependent cleanup operations in the same async hook. This is separate from the virtual server's `close` event, which is emitted when shutdown begins so integrations can release long polling requests.
331
143
 
332
- await roster.start();
144
+ `await roster.close()`:
333
145
 
334
- // Get the URL - automatically adapts to environment
335
- const url = roster.getUrl('example.com');
336
- console.log(url);
337
- // Local mode: http://localhost:9465
338
- // Local subdomain: http://api.localhost:9465
339
- // Production mode: https://example.com
340
- ```
146
+ 1. Stops accepting work and cancels Roster's renewal/retry timers.
147
+ 2. Closes listeners opened by `start()`, including ACME HTTP, and routed WebSockets; drains active HTTP requests and waits for pending certificate work.
148
+ 3. Runs site cleanup hooks. Individual failures do not prevent other hooks from running; failures are reported in an `AggregateError`.
341
149
 
342
- This method:
343
- - Returns the correct URL based on your environment (`local: true/false`)
344
- - In **local mode**: Returns `http://localhost:{port}` for apex domains and `http://{subdomain}.localhost:{port}` for subdomains
345
- - In **production mode**: Returns `https://{domain}` (or with custom port if configured)
346
- - Handles `www.` prefix automatically (returns same URL)
347
- - Returns `null` for domains that aren't registered
150
+ `closeTimeoutMs` (default `30000`) bounds the whole operation. At the deadline Roster destroys remaining owned connections and active routed responses, attempts cleanup hooks, and rejects with a timeout. It cannot forcibly interrupt an application Promise or an ACME operation already in progress.
348
151
 
349
- **Example Usage:**
152
+ Closing is **idempotent and terminal**: repeated calls return the same Promise. Create a new instance after closure or failed initialization/startup. Roster does not install process signal handlers; connect shutdown to the application's lifecycle:
350
153
 
351
154
  ```javascript
352
- import Roster from 'roster-server';
155
+ async function shutdown() {
156
+ try {
157
+ await roster.close();
158
+ } catch (error) {
159
+ console.error(error);
160
+ process.exitCode = 1;
161
+ }
162
+ }
353
163
 
354
- // Local development
355
- const localRoster = new Roster({ local: true });
356
- localRoster.register('example.com', handler);
357
- localRoster.register('api.example.com', handler);
358
- await localRoster.start();
359
- console.log(localRoster.getUrl('example.com'));
360
- // β†’ http://localhost:9465
361
- console.log(localRoster.getUrl('api.example.com'));
362
- // β†’ http://api.localhost:7342
363
-
364
- // Production
365
- const prodRoster = new Roster({ local: false });
366
- prodRoster.register('example.com', handler);
367
- await prodRoster.start();
368
- console.log(prodRoster.getUrl('example.com'));
369
- // β†’ https://example.com
370
-
371
- // Production with custom port
372
- const customRoster = new Roster({ local: false, port: 8443 });
373
- customRoster.register('api.example.com', handler);
374
- await customRoster.start();
375
- console.log(customRoster.getUrl('api.example.com'));
376
- // β†’ https://api.example.com:8443
164
+ process.once('SIGTERM', shutdown);
165
+ process.once('SIGINT', shutdown);
377
166
  ```
378
167
 
379
- ## πŸ”Œ Cluster-Friendly API (init / attach)
168
+ ## External servers and workers
380
169
 
381
- 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.
170
+ Use `init()` when your application or cluster manager owns the listener. It loads sites and invokes factories without binding ports. In production, it also generates certificate configuration and, unless `autoCertificates: false`, creates the certificate runtime and renewal timer.
382
171
 
383
- ### How It Works
384
-
385
- `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`.
386
-
387
- ### Quick Example: Sticky-Session Worker
172
+ A serving-only worker with existing certificates:
388
173
 
389
174
  ```javascript
390
- import https from 'https';
391
175
  import Roster from 'roster-server';
392
176
 
393
177
  const roster = new Roster({
394
178
  email: 'admin@example.com',
395
179
  wwwPath: '/srv/www',
396
- greenlockStorePath: '/srv/greenlock.d'
397
- });
398
-
399
- await roster.init();
400
-
401
- // Create your own HTTPS server with Roster's SNI + routing
402
- const server = https.createServer({ SNICallback: roster.sniCallback() });
403
- roster.attach(server);
404
-
405
- // Master passes connections via IPC β€” worker never calls listen()
406
- process.on('message', (msg, connection) => {
407
- if (msg === 'sticky-session:connection') {
408
- server.emit('connection', connection);
409
- }
410
- });
411
- ```
412
-
413
- ### Production Pattern: Single Certificate Manager + Workers
414
-
415
- For robust ACME behavior with cluster runtimes, run a single certificate manager process (primary) and keep workers in serving-only mode. This avoids challenge race conditions while keeping certificate lifecycle automatic.
416
-
417
- ```javascript
418
- // primary
419
- const certManager = new Roster({
420
- email: 'admin@example.com',
421
- greenlockStorePath: '/srv/greenlock.d',
422
- wwwPath: '/srv/www'
423
- });
424
- certManager.register('example.com', () => (req, res) => res.end('manager'));
425
- await certManager.start(); // enables ACME challenge lifecycle
426
- await certManager.ensureCertificate('example.com');
427
-
428
- // worker
429
- const workerRoster = new Roster({
430
- email: 'admin@example.com',
431
180
  greenlockStorePath: '/srv/greenlock.d',
432
- wwwPath: '/srv/www',
433
181
  autoCertificates: false
434
182
  });
435
- workerRoster.register('example.com', () => (req, res) => res.end('worker'));
436
- await workerRoster.init();
437
- const server = await workerRoster.createServingHttpsServer({ servername: 'example.com' });
438
- server.listen(4336);
439
- ```
440
-
441
- Reference implementation: `demo/https-cluster-configurable.js`.
442
183
 
443
- ### API Reference
444
-
445
- #### `roster.init()` β†’ `Promise<Roster>`
446
-
447
- Loads sites, generates SSL config (production), creates VirtualServers and initializes handlers. Idempotent β€” calling it twice is safe. Returns `this` for chaining.
448
-
449
- #### `roster.requestHandler(port?)` β†’ `(req, res) => void`
184
+ await roster.init();
185
+ const server = await roster.createServingHttpsServer({ servername: 'example.com' });
186
+ server.listen(8443);
187
+ ```
450
188
 
451
- Returns the Host-header dispatch function for a given port (defaults to `defaultPort`). Handles www→non-www redirects, wildcard matching, and VirtualServer dispatch.
189
+ The caller owns this server's listen/error/close lifecycle. To wire an existing HTTP(S) server instead, call `roster.attach(server, { port: 443 })`. The `port` selects a routing table; it does not change the server's listening port. Attach request handling only where Roster should own dispatch. Repeating the same attachment is a no-op; attaching that server to a different routing port throws.
452
190
 
453
- #### `roster.use(plugin)` β†’ `Roster`
191
+ During shutdown, `roster.close()` drains its routed requests, closes its routed upgraded sockets, and removes only listeners added by `attach()`. It **does not close the external server**, including servers returned by the HTTPS helpers. Coordinate both closures in the owning application. Manually wired `requestHandler()`/`upgradeHandler()` listeners must also be removed by their owner.
454
192
 
455
- Registers a synchronous request plugin and returns `this`. Plugins receive `(req, res, { host, domain })`; return `true` after sending a response to stop dispatch, or `false`/`undefined` to continue. Plugins run in registration order before redirects and site handlers.
193
+ For multiple workers, keep certificate issuance in one manager process and use `autoCertificates: false` with `init()` in serving workers. The manager can use `start()` to provide ACME HTTP and `ensureCertificate()` before starting workers. Keep domain/certificate configuration consistent: `init()` still writes configuration even in serving-only mode. `start()` retains the standalone ACME lifecycle even if `autoCertificates` is false.
456
194
 
457
- #### `roster.upgradeHandler(port?)` β†’ `(req, socket, head) => void`
195
+ The legacy `cluster: true` launcher delegates process management to Greenlock. `close()` applies to the current Roster instance; it does not stop worker processes. External cluster managers should coordinate shutdown in each worker.
458
196
 
459
- Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to the correct VirtualServer.
197
+ ## Certificates and DNS
460
198
 
461
- #### `roster.sniCallback()` β†’ `(servername, callback) => void`
199
+ The default DNS-01 integration is a wrapper around `acme-dns-01-cli`, with `propagationDelay: 120000`, `autoContinue: false`, and `dryRunDelay` matching propagation delay. Without an API provider, DNS challenges require manual TXT records. `dnsChallenge: false` disables this integration, not HTTPS itself.
462
200
 
463
- 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.
201
+ Wildcard sites normally receive a separate wildcard certificate using DNS-01; apex/www use their regular certificate flow. `combineWildcardCerts: true` puts apex, www, and wildcard names on the primary certificate and requires DNS-01. `disableWildcard: true` ignores wildcard site registrations and discovery.
464
202
 
465
- #### `roster.ensureCertificate(servername)` β†’ `Promise<{ key, cert }>`
203
+ For Linode, supply `LINODE_API_KEY` through the application's secret configuration and set `ROSTER_DNS_PROVIDER=linode`. A configured Linode key also selects that provider when no provider is specified. The wrapper creates/removes TXT records and checks DNS propagation. API failures can fall back to manual mode by default; for unattended operation that must fail instead, configure:
466
204
 
467
- Ensures a certificate exists for `servername`. With `autoCertificates` enabled (default), it issues missing certificates automatically and returns PEMs.
205
+ ```javascript
206
+ const dnsChallenge = {
207
+ module: 'acme-dns-01-cli',
208
+ provider: 'linode',
209
+ dnsApiFallbackToManual: false
210
+ };
211
+ ```
468
212
 
469
- #### `roster.loadCertificate(servername)` β†’ `{ key, cert }`
213
+ Pass this as the constructor's `dnsChallenge` option. To use another provider, install its Greenlock DNS plugin in the consuming application and supply its module name and provider options. Configure DNS timing on that object with `propagationDelay`, `dryRunDelay`, `dnsPollIntervalMs`, and `dnsPollTimeoutMs`; the wrapper's polling interval defaults to `15000` ms.
214
+
215
+ Roster's file-based SNI resolvers cache TLS contexts and detect changed certificate files through metadata, including updates from another process. Concurrent requests for the same certificate name share loads and checks. Standalone startup reuses the initialized certificate runtime and Roster's cancellable renewal loop. Loading an existing certificate does not itself force renewal.
216
+
217
+ ## Configuration reference
218
+
219
+ Pass operational settings through the constructor, using the consuming application's configuration source. Prefer absolute `wwwPath` and `greenlockStorePath`. `basePath` supplies their defaults; it does not rebase explicitly supplied relative paths.
220
+
221
+ | Option | Default | Meaning |
222
+ | --- | --- | --- |
223
+ | `email` | `admin@example.com` | Replace with the real certificate contact. |
224
+ | `basePath` | Derived from package location | Default parent for `www` and `greenlock.d`; set explicitly when relying on it. |
225
+ | `wwwPath` | `basePath/www` | Discovered sites. |
226
+ | `greenlockStorePath` | `basePath/greenlock.d` | Certificate files and generated configuration. |
227
+ | `filename` | `index` | Script entry basename, without extension. |
228
+ | `local` | `false` | Local HTTP mode with assigned ports and no certificates. |
229
+ | `port` | `443` | Default port for manual registrations and routing helpers. |
230
+ | `hostname` | `::` | Production bind address; local mode uses `localhost`. |
231
+ | `minLocalPort`, `maxLocalPort` | `4000`, `9999` | Inclusive local assignment range. |
232
+ | `staging` | `false` | ACME staging environment. |
233
+ | `autoCertificates` | `true` | Certificate runtime during `init()`; disable for serving-only workers. |
234
+ | `certificateRenewIntervalMs` | `43200000` (12h) | Roster renewal check interval; minimum `60000`. |
235
+ | `closeTimeoutMs` | `30000` | Positive finite total shutdown deadline. |
236
+ | `tlsMinVersion`, `tlsMaxVersion` | `TLSv1.2`, `TLSv1.3` | Protocol limits for created HTTPS servers. |
237
+ | `skipLocalCheck` | `true` | Skips Greenlock dry-run/local challenge checks. |
238
+ | `dnsChallenge` | CLI wrapper | DNS-01 options or `false`; see [certificates](#certificates-and-dns). |
239
+ | `disableWildcard`, `combineWildcardCerts` | `false`, `false` | Wildcard registration and certificate behavior. |
240
+ | `cluster` | `false` | Legacy Greenlock-managed cluster launcher. |
241
+
242
+ ## API reference
243
+
244
+ | Method | Contract |
245
+ | --- | --- |
246
+ | `register(domain, factory)` | Register before initialization; accepts `domain:port` and wildcard domains. Returns `Roster`. |
247
+ | `use(plugin)` | Add a synchronous request plugin. Returns `Roster`. |
248
+ | `init()` | `Promise<Roster>`; prepare routing and factories without listening. |
249
+ | `start()` | Start standalone/local listeners; await readiness. |
250
+ | `close()` | `Promise<void>`; drain and clean up the instance. |
251
+ | `getUrl(domain)` | URL or `null`; local assignment is available after startup. See [routing](#manual-registration-and-custom-ports) for port limitations. |
252
+ | `requestHandler(port?)` | HTTP dispatcher for the selected routing port. Requires `init()`. |
253
+ | `upgradeHandler(port?)` | WebSocket upgrade dispatcher. Requires `init()`. |
254
+ | `attach(server, { port }?)` | Add both dispatchers to a caller-owned server. Requires `init()`; returns `Roster`. |
255
+ | `sniCallback()` | TLS SNI callback. Requires production-mode `init()`. |
256
+ | `ensureCertificate(servername)` | `Promise<{ key, cert }>`; load existing PEMs or issue missing ones when enabled. Requires production-mode `init()`. |
257
+ | `loadCertificate(servername)` | Synchronously load `{ key, cert }` without issuance. Requires production-mode `init()`. |
258
+ | `createManagedHttpsServer({ servername, port?, ensureCertificate?, tlsOptions? })` | `Promise<https.Server>` with certificates and dispatchers; does not listen. Certificate assurance defaults to `true`. Requires production-mode `init()`. |
259
+ | `createServingHttpsServer({ servername, port?, tlsOptions? })` | Same helper with `ensureCertificate: false`; set `autoCertificates: false` for a serving-only instance. |
260
+ | `virtualServer.onClose(fn)` | Register a cleanup hook; returns the virtual server. See [shutdown](#startup-and-shutdown). |
261
+
262
+ ## Request plugins
263
+
264
+ Plugins run before redirects and site dispatch. They receive `(req, res, { host, domain })`; return `true` after handling the response, or `false`/`undefined` to continue. Plugins must be synchronous. Thrown errors or Promise returns produce a `500` response.
265
+
266
+ Enable the optional scanner blocker before startup:
470
267
 
471
- Loads an existing certificate from `greenlockStorePath` without issuing new certificates. Useful for serving-only workers.
268
+ ```javascript
269
+ import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
472
270
 
473
- #### `roster.createManagedHttpsServer({ servername, port?, ensureCertificate?, tlsOptions? })` β†’ `Promise<https.Server>`
271
+ roster.use(createScannerBlocker());
272
+ ```
474
273
 
475
- Creates an HTTPS server prewired with default cert, SNI callback, and request/upgrade routing. By default it ensures certificate issuance before returning.
274
+ The blocker rejects common PHP, WordPress, repository, and sensitive-file probes with `404`. Defaults: a `60000` ms strike window, `3` strikes, a `900000` ms ban, `10000` tracked clients, and `trustProxy: false`. Configure `windowMs`, `strikeThreshold`, `banDurationMs`, and `maxTrackedClients` on the plugin. State is bounded, in memory, and per process.
476
275
 
477
- #### `roster.createServingHttpsServer({ servername, port?, tlsOptions? })` β†’ `Promise<https.Server>`
276
+ Enable `trustProxy` only behind a trusted proxy that overwrites `X-Forwarded-For`. The optional synchronous `onBlock(event)` callback can integrate the application's logging or enforcement; it does not itself provide shared or persistent bans.
478
277
 
479
- Convenience alias for serving-only workers. Equivalent to `createManagedHttpsServer(..., ensureCertificate: false)`.
278
+ ## Troubleshooting and runtime limits
480
279
 
481
- #### `roster.attach(server, { port }?)` β†’ `Roster`
280
+ | Symptom | Check |
281
+ | --- | --- |
282
+ | Startup rejects with a port error | Free the conflicting port or change the configured range/port, then create a new instance. Standalone HTTPS also needs port 80. |
283
+ | A site returns `404` | Check the domain, routing port, entry filename, module type, and factory export. Discovered sites stay on 443. |
284
+ | A site import fails with a relative path | Pass an absolute `wwwPath`; explicit paths are not rebased onto `basePath`. |
285
+ | Static deep links return `404` | There is no SPA fallback; use a site handler when that behavior is required. |
286
+ | Socket.IO falls through or double-responds | Attach it to the supplied virtual server, return the ordinary HTTP handler, and let Socket.IO own its endpoint. |
287
+ | Certificates are missing in a worker | Ensure the manager has issued them into the same store before worker startup. `init()` alone does not bind ACME HTTP. |
288
+ | Shutdown times out | Inspect the aggregated errors and site hooks, active requests, and pending certificate operations. |
482
289
 
483
- Convenience method. Wires `requestHandler` and `upgradeHandler` onto `server.on('request', ...)` and `server.on('upgrade', ...)`. Returns `this` for chaining.
290
+ Local integration checks cover Node HTTP/TLS and Socket.IO polling/WebSocket shutdown on Node and Bun. In **Bun 1.3.4**, the tested `node:https` serving helper did not invoke `SNICallback`, and changing certificate files did not replace the served default certificate. This also occurred with the previous synchronous resolver; do not assume Node's SNI reload behavior on that runtime.
484
291
 
485
- ### Standalone Mode (unchanged)
292
+ ## Agent skill and development
486
293
 
487
- `roster.start()` still works exactly as before β€” it calls `init()` internally, then creates and binds servers:
294
+ The [RosterServer skill](skills/roster-server/SKILL.md) guides application integration:
488
295
 
489
- ```javascript
490
- const roster = new Roster({ ... });
491
- await roster.start(); // full standalone mode, no changes needed
296
+ ```sh
297
+ npx skills add https://github.com/clasen/RosterServer --skill roster-server
492
298
  ```
493
299
 
494
- ## πŸ§‚ A Touch of Magic
300
+ Contributors can run `pnpm test` for the Node test suite. Examples live in [demo](demo); they are separate from the published API reference above.
495
301
 
496
- 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! πŸ‘ΎπŸ‘ΎπŸ‘Ύ
497
-
498
-
499
- ## 🀝 Contributing
500
-
501
- Feel free to submit issues or pull requests. Or don't. I'm not your boss. 😜
502
-
503
- If you find any issues or have suggestions for improvement, please open an issue or submit a pull request on the [GitHub repository](https://github.com/clasen/RosterServer).
504
-
505
- ## πŸ™ Acknowledgments
506
-
507
- - [Node.js](https://nodejs.org/) - JavaScript runtime
508
- - [Greenlock](https://git.coolaj86.com/coolaj86/greenlock.js) - Fully-featured ACME client
509
-
510
- ## πŸ“„ License
302
+ ## License
511
303
 
512
304
  The MIT License (MIT)
513
305
 
@@ -518,7 +310,3 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
518
310
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
519
311
 
520
312
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
521
-
522
- ---
523
-
524
- Happy hosting! 🎈