roster-server 2.4.12 β†’ 2.4.14

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,317 @@
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
28
- ```
29
-
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
7
+ ```sh
8
+ pnpm add roster-server
36
9
  ```
37
10
 
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)
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`.
68
12
 
69
- You can serve all subdomains of a domain with a single handler in three ways:
13
+ ## Quick start: local HTTP
70
14
 
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.
74
-
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
119
-
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.
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.
121
48
 
122
- ```javascript
123
- import Roster from 'roster-server';
124
- import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
49
+ ## Sites and routing
125
50
 
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')`.
180
89
 
181
- export default (httpsServer) => {
182
- const io = new Server(httpsServer);
90
+ Thrown or rejected request-handler errors produce a `500`, or destroy the response if headers were already sent.
183
91
 
184
- io.on('connection', (socket) => {
185
- console.log('A user connected');
92
+ ### Socket.IO and resource cleanup
186
93
 
187
- socket.on('chat:message', (msg) => {
188
- console.log('Message received:', msg);
189
- io.emit('chat:message', msg);
190
- });
94
+ With Socket.IO installed in the consuming application:
191
95
 
192
- socket.on('disconnect', () => {
193
- console.log('User disconnected');
194
- });
96
+ ```javascript
97
+ const { Server } = require('socket.io');
98
+
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
- ```
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.
214
111
 
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
- ```
112
+ ### Manual registration and custom ports
224
113
 
225
- ### Running the Server
114
+ After creating `roster`, register factories before `init()` or `start()`:
226
115
 
227
- ```bash
228
- # With Node.js
229
- node server.js
230
- ```
231
-
232
- Or with Bun:
233
-
234
- ```bash
235
- bun server.js
116
+ ```javascript
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);
236
121
  ```
237
122
 
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. 🧐
256
-
257
- ### Redirects from `www`
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).
258
124
 
259
- All requests to `www.yourdomain.com` are automatically redirected to `yourdomain.com`. Because who needs the extra three characters? 😏
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`.
260
126
 
261
- ### Dynamic Site Loading
127
+ ### Static sites
262
128
 
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. πŸ˜…
129
+ A directory with `index.html` and no entry script needs no JavaScript factory:
264
130
 
265
- ## βš™οΈ Configuration Options
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.
266
135
 
267
- When creating a new `RosterServer` instance, you can pass the following options:
136
+ Filesystem operations are asynchronous, and disconnected downloads close their file handles.
268
137
 
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).
138
+ ## Startup and shutdown
279
139
 
280
- ## 🏠 Local Development Mode
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.
281
141
 
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.
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.
283
143
 
284
- When `{ local: true }` is enabled, RosterServer **Skips SSL/HTTPS**: Runs pure HTTP servers instead of HTTPS.
144
+ `await roster.close()`:
285
145
 
286
- ### Setting Up Local Mode
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`.
287
149
 
288
- ```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();
298
- ```
299
-
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`):
303
-
304
- - `example.com` β†’ `http://localhost:9465`
305
- - `api.example.com` β†’ `http://localhost:9388`
306
- - And so on...
307
-
308
- You can customize the port range:
309
-
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
- ```
319
-
320
- ### Getting URLs
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.
321
151
 
322
- RosterServer provides a method to get the URL for a domain that adapts automatically to your environment:
152
+ Closing is **idempotent and terminal**: repeated calls return the same Promise. Create a new instance after closure or failed initialization/startup.
323
153
 
324
- **Instance Method: `roster.getUrl(domain)`**
154
+ For automatic shutdown on `SIGINT` or `SIGTERM`, explicitly enable `handleSignals`:
325
155
 
326
156
  ```javascript
327
- import Roster from 'roster-server';
328
-
329
- const roster = new Roster({ local: true });
330
- roster.register('example.com', handler);
331
-
157
+ const roster = new Roster({ handleSignals: true });
332
158
  await roster.start();
333
-
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
159
  ```
341
160
 
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
161
+ The option defaults to `false`. Listeners are installed once when `init()` or `start()` begins and removed when closure completes, including failed startup or cleanup. Signals call `roster.close()`; repeated signals during closure do not repeat cleanup. Existing application signal listeners remain installed. Shutdown failures are logged and set `process.exitCode = 1`.
348
162
 
349
- **Example Usage:**
163
+ Roster does not force process termination or close caller-owned servers. The process exits naturally when its remaining work finishes; other resources or cluster workers still require application coordination. If the application already coordinates shutdown, leave `handleSignals` disabled and connect Roster to that flow:
350
164
 
351
165
  ```javascript
352
- import Roster from 'roster-server';
166
+ async function shutdown() {
167
+ try {
168
+ await roster.close();
169
+ } catch (error) {
170
+ console.error(error);
171
+ process.exitCode = 1;
172
+ }
173
+ }
353
174
 
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
175
+ process.once('SIGTERM', shutdown);
176
+ process.once('SIGINT', shutdown);
377
177
  ```
378
178
 
379
- ## πŸ”Œ Cluster-Friendly API (init / attach)
380
-
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.
382
-
383
- ### How It Works
179
+ ## External servers and workers
384
180
 
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`.
181
+ 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.
386
182
 
387
- ### Quick Example: Sticky-Session Worker
183
+ A serving-only worker with existing certificates:
388
184
 
389
185
  ```javascript
390
- import https from 'https';
391
186
  import Roster from 'roster-server';
392
187
 
393
188
  const roster = new Roster({
394
189
  email: 'admin@example.com',
395
190
  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
191
  greenlockStorePath: '/srv/greenlock.d',
432
- wwwPath: '/srv/www',
433
192
  autoCertificates: false
434
193
  });
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
-
443
- ### API Reference
444
194
 
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`
195
+ await roster.init();
196
+ const server = await roster.createServingHttpsServer({ servername: 'example.com' });
197
+ server.listen(8443);
198
+ ```
450
199
 
451
- Returns the Host-header dispatch function for a given port (defaults to `defaultPort`). Handles www→non-www redirects, wildcard matching, and VirtualServer dispatch.
200
+ 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
201
 
453
- #### `roster.use(plugin)` β†’ `Roster`
202
+ 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
203
 
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.
204
+ 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
205
 
457
- #### `roster.upgradeHandler(port?)` β†’ `(req, socket, head) => void`
206
+ 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
207
 
459
- Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to the correct VirtualServer.
208
+ ## Certificates and DNS
460
209
 
461
- #### `roster.sniCallback()` β†’ `(servername, callback) => void`
210
+ 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
211
 
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.
212
+ 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
213
 
465
- #### `roster.ensureCertificate(servername)` β†’ `Promise<{ key, cert }>`
214
+ 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
215
 
467
- Ensures a certificate exists for `servername`. With `autoCertificates` enabled (default), it issues missing certificates automatically and returns PEMs.
216
+ ```javascript
217
+ const dnsChallenge = {
218
+ module: 'acme-dns-01-cli',
219
+ provider: 'linode',
220
+ dnsApiFallbackToManual: false
221
+ };
222
+ ```
468
223
 
469
- #### `roster.loadCertificate(servername)` β†’ `{ key, cert }`
224
+ 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.
225
+
226
+ 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.
227
+
228
+ ## Configuration reference
229
+
230
+ 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.
231
+
232
+ | Option | Default | Meaning |
233
+ | --- | --- | --- |
234
+ | `email` | `admin@example.com` | Replace with the real certificate contact. |
235
+ | `basePath` | Derived from package location | Default parent for `www` and `greenlock.d`; set explicitly when relying on it. |
236
+ | `wwwPath` | `basePath/www` | Discovered sites. |
237
+ | `greenlockStorePath` | `basePath/greenlock.d` | Certificate files and generated configuration. |
238
+ | `filename` | `index` | Script entry basename, without extension. |
239
+ | `local` | `false` | Local HTTP mode with assigned ports and no certificates. |
240
+ | `port` | `443` | Default port for manual registrations and routing helpers. |
241
+ | `hostname` | `::` | Production bind address; local mode uses `localhost`. |
242
+ | `minLocalPort`, `maxLocalPort` | `4000`, `9999` | Inclusive local assignment range. |
243
+ | `staging` | `false` | ACME staging environment. |
244
+ | `autoCertificates` | `true` | Certificate runtime during `init()`; disable for serving-only workers. |
245
+ | `certificateRenewIntervalMs` | `43200000` (12h) | Roster renewal check interval; minimum `60000`. |
246
+ | `closeTimeoutMs` | `30000` | Positive finite total shutdown deadline. |
247
+ | `handleSignals` | `false` | Close this instance on `SIGINT`/`SIGTERM`; does not force process exit. |
248
+ | `tlsMinVersion`, `tlsMaxVersion` | `TLSv1.2`, `TLSv1.3` | Protocol limits for created HTTPS servers. |
249
+ | `skipLocalCheck` | `true` | Skips Greenlock dry-run/local challenge checks. |
250
+ | `dnsChallenge` | CLI wrapper | DNS-01 options or `false`; see [certificates](#certificates-and-dns). |
251
+ | `disableWildcard`, `combineWildcardCerts` | `false`, `false` | Wildcard registration and certificate behavior. |
252
+ | `cluster` | `false` | Legacy Greenlock-managed cluster launcher. |
253
+
254
+ ## API reference
255
+
256
+ | Method | Contract |
257
+ | --- | --- |
258
+ | `register(domain, factory)` | Register before initialization; accepts `domain:port` and wildcard domains. Returns `Roster`. |
259
+ | `use(plugin)` | Add a synchronous request plugin. Returns `Roster`. |
260
+ | `init()` | `Promise<Roster>`; prepare routing and factories without listening. |
261
+ | `start()` | Start standalone/local listeners; await readiness. |
262
+ | `close()` | `Promise<void>`; drain and clean up the instance. |
263
+ | `getUrl(domain)` | URL or `null`; local assignment is available after startup. See [routing](#manual-registration-and-custom-ports) for port limitations. |
264
+ | `requestHandler(port?)` | HTTP dispatcher for the selected routing port. Requires `init()`. |
265
+ | `upgradeHandler(port?)` | WebSocket upgrade dispatcher. Requires `init()`. |
266
+ | `attach(server, { port }?)` | Add both dispatchers to a caller-owned server. Requires `init()`; returns `Roster`. |
267
+ | `sniCallback()` | TLS SNI callback. Requires production-mode `init()`. |
268
+ | `ensureCertificate(servername)` | `Promise<{ key, cert }>`; load existing PEMs or issue missing ones when enabled. Requires production-mode `init()`. |
269
+ | `loadCertificate(servername)` | Synchronously load `{ key, cert }` without issuance. Requires production-mode `init()`. |
270
+ | `createManagedHttpsServer({ servername, port?, ensureCertificate?, tlsOptions? })` | `Promise<https.Server>` with certificates and dispatchers; does not listen. Certificate assurance defaults to `true`. Requires production-mode `init()`. |
271
+ | `createServingHttpsServer({ servername, port?, tlsOptions? })` | Same helper with `ensureCertificate: false`; set `autoCertificates: false` for a serving-only instance. |
272
+ | `virtualServer.onClose(fn)` | Register a cleanup hook; returns the virtual server. See [shutdown](#startup-and-shutdown). |
273
+
274
+ ## Request plugins
275
+
276
+ 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.
277
+
278
+ Enable the optional scanner blocker before startup:
470
279
 
471
- Loads an existing certificate from `greenlockStorePath` without issuing new certificates. Useful for serving-only workers.
280
+ ```javascript
281
+ import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
472
282
 
473
- #### `roster.createManagedHttpsServer({ servername, port?, ensureCertificate?, tlsOptions? })` β†’ `Promise<https.Server>`
283
+ roster.use(createScannerBlocker());
284
+ ```
474
285
 
475
- Creates an HTTPS server prewired with default cert, SNI callback, and request/upgrade routing. By default it ensures certificate issuance before returning.
286
+ 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
287
 
477
- #### `roster.createServingHttpsServer({ servername, port?, tlsOptions? })` β†’ `Promise<https.Server>`
288
+ 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
289
 
479
- Convenience alias for serving-only workers. Equivalent to `createManagedHttpsServer(..., ensureCertificate: false)`.
290
+ ## Troubleshooting and runtime limits
480
291
 
481
- #### `roster.attach(server, { port }?)` β†’ `Roster`
292
+ | Symptom | Check |
293
+ | --- | --- |
294
+ | 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. |
295
+ | A site returns `404` | Check the domain, routing port, entry filename, module type, and factory export. Discovered sites stay on 443. |
296
+ | A site import fails with a relative path | Pass an absolute `wwwPath`; explicit paths are not rebased onto `basePath`. |
297
+ | Static deep links return `404` | There is no SPA fallback; use a site handler when that behavior is required. |
298
+ | 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. |
299
+ | 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. |
300
+ | Shutdown times out | Inspect the aggregated errors and site hooks, active requests, and pending certificate operations. |
482
301
 
483
- Convenience method. Wires `requestHandler` and `upgradeHandler` onto `server.on('request', ...)` and `server.on('upgrade', ...)`. Returns `this` for chaining.
302
+ 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
303
 
485
- ### Standalone Mode (unchanged)
304
+ ## Agent skill and development
486
305
 
487
- `roster.start()` still works exactly as before β€” it calls `init()` internally, then creates and binds servers:
306
+ The [RosterServer skill](skills/roster-server/SKILL.md) guides application integration:
488
307
 
489
- ```javascript
490
- const roster = new Roster({ ... });
491
- await roster.start(); // full standalone mode, no changes needed
308
+ ```sh
309
+ npx skills add https://github.com/clasen/RosterServer --skill roster-server
492
310
  ```
493
311
 
494
- ## πŸ§‚ A Touch of Magic
495
-
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! πŸ‘ΎπŸ‘ΎπŸ‘Ύ
312
+ Contributors can run `pnpm test` for the Node test suite. Examples live in [demo](demo); they are separate from the published API reference above.
497
313
 
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
314
+ ## License
511
315
 
512
316
  The MIT License (MIT)
513
317
 
@@ -518,7 +322,3 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
518
322
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
519
323
 
520
324
  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! 🎈