roster-server 2.4.12 β 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 +192 -404
- package/index.js +417 -126
- package/lib/static-site-handler.js +36 -27
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +50 -338
- package/test/https-integration.test.js +149 -0
- package/test/lifecycle.test.js +469 -0
- package/test/roster-server.test.js +22 -22
- package/test/scanner-blocker.test.js +3 -6
- package/test/static-and-tls.test.js +273 -0
- package/vendor/greenlock-express/greenlock-express.js +1 -1
- package/vendor/greenlock-express/servers.js +2 -1
- package/vendor/greenlock-express/single.js +1 -1
- package/vendor/greenlock-express/worker.js +2 -2
package/README.md
CHANGED
|
@@ -1,513 +1,305 @@
|
|
|
1
1
|
# πΎ RosterServer
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
wwwPath: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
+
const roster = new Roster({
|
|
109
39
|
email: 'admin@example.com',
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
};
|
|
40
|
+
wwwPath: '/srv/www',
|
|
41
|
+
greenlockStorePath: '/srv/greenlock.d'
|
|
42
|
+
});
|
|
113
43
|
|
|
114
|
-
|
|
115
|
-
server.start();
|
|
44
|
+
await roster.start();
|
|
116
45
|
```
|
|
117
46
|
|
|
118
|
-
|
|
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
|
-
|
|
49
|
+
## Sites and routing
|
|
121
50
|
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
### Site factory contract
|
|
144
64
|
|
|
145
|
-
|
|
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
|
-
|
|
67
|
+
Basic site, saved as `www/example.com/index.cjs`:
|
|
149
68
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
const io = new Server(httpsServer);
|
|
92
|
+
### Socket.IO and resource cleanup
|
|
183
93
|
|
|
184
|
-
|
|
185
|
-
console.log('A user connected');
|
|
94
|
+
With Socket.IO installed in the consuming application:
|
|
186
95
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
io.emit('chat:message', msg);
|
|
190
|
-
});
|
|
96
|
+
```javascript
|
|
97
|
+
const { Server } = require('socket.io');
|
|
191
98
|
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
112
|
+
### Manual registration and custom ports
|
|
258
113
|
|
|
259
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
### Static sites
|
|
309
128
|
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
Filesystem operations are asynchronous, and disconnected downloads close their file handles.
|
|
323
137
|
|
|
324
|
-
|
|
138
|
+
## Startup and shutdown
|
|
325
139
|
|
|
326
|
-
|
|
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
|
-
|
|
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.
|
|
144
|
+
`await roster.close()`:
|
|
333
145
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
##
|
|
168
|
+
## External servers and workers
|
|
380
169
|
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
+
## Certificates and DNS
|
|
460
198
|
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
+
```javascript
|
|
206
|
+
const dnsChallenge = {
|
|
207
|
+
module: 'acme-dns-01-cli',
|
|
208
|
+
provider: 'linode',
|
|
209
|
+
dnsApiFallbackToManual: false
|
|
210
|
+
};
|
|
211
|
+
```
|
|
468
212
|
|
|
469
|
-
|
|
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
|
-
|
|
268
|
+
```javascript
|
|
269
|
+
import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
|
|
472
270
|
|
|
473
|
-
|
|
271
|
+
roster.use(createScannerBlocker());
|
|
272
|
+
```
|
|
474
273
|
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
+
## Troubleshooting and runtime limits
|
|
480
279
|
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
+
## Agent skill and development
|
|
486
293
|
|
|
487
|
-
|
|
294
|
+
The [RosterServer skill](skills/roster-server/SKILL.md) guides application integration:
|
|
488
295
|
|
|
489
|
-
```
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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! π
|