roster-server 2.4.13 → 3.0.0

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,6 +1,6 @@
1
1
  # 👾 RosterServer
2
2
 
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.
3
+ Host multiple domains behind one Node.js entry point, 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. By default every site directory runs in its own process, so a busy, blocked, or crashed site does not take the others down; see [site isolation](#site-isolation).
4
4
 
5
5
  ## Installation
6
6
 
@@ -85,7 +85,7 @@ module.exports = () => {
85
85
  };
86
86
  ```
87
87
 
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')`.
88
+ The virtual server supports request/upgrade listener integration; it is not a separately listening TCP server. Process isolation comes from [site isolation](#site-isolation), not from the virtual server. 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
89
 
90
90
  Thrown or rejected request-handler errors produce a `500`, or destroy the response if headers were already sent.
91
91
 
@@ -120,6 +120,12 @@ roster.register('api.example.com:8443', site);
120
120
  roster.register('*.example.com:8443', site);
121
121
  ```
122
122
 
123
+ Instead of a factory, `register()` also accepts a site directory, loaded like a discovered site. A directory registration can run in an isolated process, and every domain registered with the same directory, including the discovered one, shares that process:
124
+
125
+ ```javascript
126
+ roster.register('en.example.com', path.join(wwwPath, 'example.com'));
127
+ ```
128
+
123
129
  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).
124
130
 
125
131
  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`.
@@ -135,6 +141,49 @@ A directory with `index.html` and no entry script needs no JavaScript factory:
135
141
 
136
142
  Filesystem operations are asynchronous, and disconnected downloads close their file handles.
137
143
 
144
+ ## Site isolation
145
+
146
+ `start()` runs each site directory in its own Node.js process. This applies to discovered `www/` sites and to directories passed to `register()`. One process serves every domain and port registered with that directory, so a site with SQLite, files written by lowdb, cron jobs, or internal servers never runs twice. Disable isolation with `isolation: false`.
147
+
148
+ The main process keeps the listeners, certificate issuance and renewal (ACME, SNI, wildcard), port 80, and shutdown. For each HTTPS connection it reads only the TLS ClientHello and passes the raw socket to the process serving that SNI host. That process terminates TLS and handles requests, WebSocket upgrades, request plugins, and `onClose` hooks. The socket is the client's own connection, so `req.socket.remoteAddress` and all headers arrive unchanged. A TLS handshake does not use the main process's CPU or event loop. In local mode, each isolated process listens on its own assigned port.
149
+
150
+ These sites stay in the main process:
151
+
152
+ - A factory function passed to `register()`, because a function cannot move to another process. Register the site directory instead to isolate it.
153
+ - A site whose entry module the main process already loaded with `require()`. Isolating it would create a second instance. This check cannot see ESM modules imported by the application; do not import an isolated site's module from the server script.
154
+ - Connections without SNI, or for a host no isolated site serves.
155
+
156
+ Roster logs the sites that are not isolated at startup.
157
+
158
+ A crashed isolated process is restarted after `isolation.restartDelayMs`, and the other sites keep serving. While it is down, its connections are refused. If its startup fails, `start()` rejects, as it does for an in-process factory error. Once more than `isolation.backlog` connections are waiting for a site process, new connections to that site are dropped. A client that sends no ClientHello within `isolation.helloTimeoutMs` is disconnected.
159
+
160
+ ```javascript
161
+ const roster = new Roster({
162
+ isolation: { restartDelayMs: 1000, backlog: 511, helloTimeoutMs: 10000 }
163
+ });
164
+ ```
165
+
166
+ When at least one site is isolated, request plugins added with `use()` before `start()` are recreated in every isolated process. Plugin state, such as scanner-blocker bans, is therefore per site. A plugin can be recreated only if it carries a `Roster.pluginSource` descriptor whose options are JSON-serializable; otherwise `start()` rejects. `createScannerBlocker()` provides one unless its options include functions such as `onBlock`. For a custom plugin:
167
+
168
+ ```javascript
169
+ // my-plugin.cjs
170
+ const Roster = require('roster-server');
171
+
172
+ function createMyPlugin(options) {
173
+ const plugin = (req, res, context) => false;
174
+ plugin[Roster.pluginSource] = { module: __filename, export: 'createMyPlugin', options };
175
+ return plugin;
176
+ }
177
+
178
+ module.exports = { createMyPlugin };
179
+ ```
180
+
181
+ On `close()`, or a handled signal, each isolated process drains its requests, runs its `onClose` hooks, and exits. Hook errors from those processes appear in the same `AggregateError`. When `closeTimeoutMs` expires, Roster kills the remaining isolated processes. Isolated processes ignore `SIGINT` and `SIGTERM` and wait for the main process, so a signal sent to the whole process group by a terminal or process manager does not cut their drain short. If the main process exits without closing, each isolated process closes itself.
182
+
183
+ Isolation needs a Roster-owned listener, so it applies only when `start()` initializes the instance. Calling `init()` yourself keeps every site in the current process for `attach()` and the other external-server helpers. Isolation is not available on Bun, whose HTTPS server cannot adopt a passed socket; there, Roster logs a warning and serves every site in the main process. Isolation cannot be combined with the legacy `cluster: true` launcher, which disables it by default.
184
+
185
+ Each isolated process loads Node.js and its site, so memory use grows with the number of site directories. Code that runs when a site's module loads runs in that site's process, not in the main process.
186
+
138
187
  ## Startup and shutdown
139
188
 
140
189
  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.
@@ -149,7 +198,18 @@ Register `virtualServer.onClose(fn)` in a factory for timers, databases, queues,
149
198
 
150
199
  `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.
151
200
 
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:
201
+ Closing is **idempotent and terminal**: repeated calls return the same Promise. Create a new instance after closure or failed initialization/startup.
202
+
203
+ For automatic shutdown on `SIGINT` or `SIGTERM`, explicitly enable `handleSignals`:
204
+
205
+ ```javascript
206
+ const roster = new Roster({ handleSignals: true });
207
+ await roster.start();
208
+ ```
209
+
210
+ 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`.
211
+
212
+ 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:
153
213
 
154
214
  ```javascript
155
215
  async function shutdown() {
@@ -192,7 +252,7 @@ During shutdown, `roster.close()` drains its routed requests, closes its routed
192
252
 
193
253
  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.
194
254
 
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.
255
+ The legacy `cluster: true` launcher delegates process management to Greenlock and turns off [site isolation](#site-isolation). `close()` applies to the current Roster instance; it does not stop worker processes. External cluster managers should coordinate shutdown in each worker.
196
256
 
197
257
  ## Certificates and DNS
198
258
 
@@ -233,6 +293,8 @@ Pass operational settings through the constructor, using the consuming applicati
233
293
  | `autoCertificates` | `true` | Certificate runtime during `init()`; disable for serving-only workers. |
234
294
  | `certificateRenewIntervalMs` | `43200000` (12h) | Roster renewal check interval; minimum `60000`. |
235
295
  | `closeTimeoutMs` | `30000` | Positive finite total shutdown deadline. |
296
+ | `handleSignals` | `false` | Close this instance on `SIGINT`/`SIGTERM`; does not force process exit. |
297
+ | `isolation` | `true` (`false` with `cluster`) | One process per site directory under `start()`; `false` or `{ restartDelayMs, backlog, helloTimeoutMs }` (defaults `1000`, `511`, `10000`). See [site isolation](#site-isolation). |
236
298
  | `tlsMinVersion`, `tlsMaxVersion` | `TLSv1.2`, `TLSv1.3` | Protocol limits for created HTTPS servers. |
237
299
  | `skipLocalCheck` | `true` | Skips Greenlock dry-run/local challenge checks. |
238
300
  | `dnsChallenge` | CLI wrapper | DNS-01 options or `false`; see [certificates](#certificates-and-dns). |
@@ -243,7 +305,7 @@ Pass operational settings through the constructor, using the consuming applicati
243
305
 
244
306
  | Method | Contract |
245
307
  | --- | --- |
246
- | `register(domain, factory)` | Register before initialization; accepts `domain:port` and wildcard domains. Returns `Roster`. |
308
+ | `register(domain, factory \| siteDirectory)` | Register before initialization; accepts `domain:port` and wildcard domains. A site directory can be isolated; a factory runs in the main process. Returns `Roster`. |
247
309
  | `use(plugin)` | Add a synchronous request plugin. Returns `Roster`. |
248
310
  | `init()` | `Promise<Roster>`; prepare routing and factories without listening. |
249
311
  | `start()` | Start standalone/local listeners; await readiness. |
@@ -258,6 +320,7 @@ Pass operational settings through the constructor, using the consuming applicati
258
320
  | `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
321
  | `createServingHttpsServer({ servername, port?, tlsOptions? })` | Same helper with `ensureCertificate: false`; set `autoCertificates: false` for a serving-only instance. |
260
322
  | `virtualServer.onClose(fn)` | Register a cleanup hook; returns the virtual server. See [shutdown](#startup-and-shutdown). |
323
+ | `Roster.pluginSource` | Symbol for a request plugin's `{ module, export, options }` descriptor. See [site isolation](#site-isolation). |
261
324
 
262
325
  ## Request plugins
263
326
 
@@ -271,7 +334,7 @@ import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
271
334
  roster.use(createScannerBlocker());
272
335
  ```
273
336
 
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.
337
+ 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, so each isolated site keeps its own bans.
275
338
 
276
339
  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.
277
340
 
package/index.js CHANGED
@@ -2,13 +2,37 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const http = require('http');
4
4
  const https = require('https');
5
+ const net = require('net');
5
6
  const tls = require('tls');
6
7
  const crypto = require('crypto');
7
8
  const { EventEmitter } = require('events');
8
9
  const Greenlock = require('./vendor/greenlock-express/greenlock-express.js');
9
10
  const GreenlockShim = require('./vendor/greenlock-express/greenlock-shim.js');
10
- const { resolveSiteApp } = require('./lib/resolve-site-app.js');
11
+ const { locateSiteEntry, resolveSiteApp } = require('./lib/resolve-site-app.js');
12
+ const { IsolatedSite } = require('./lib/isolated-site.js');
13
+ const { parseClientHello } = require('./lib/client-hello.js');
11
14
  const log = require('lemonlog')('roster');
15
+ const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'];
16
+ // A request plugin carrying { module, export, options } under this key can be recreated in isolated site processes.
17
+ const PLUGIN_SOURCE = Symbol.for('roster-server.plugin-source');
18
+
19
+ function isJsonValue(value) {
20
+ if (value === null || ['string', 'number', 'boolean', 'undefined'].includes(typeof value)) return true;
21
+ if (Array.isArray(value)) return value.every(isJsonValue);
22
+ if (Object.getPrototypeOf(value) !== Object.prototype) return false;
23
+ return Object.values(value).every(isJsonValue);
24
+ }
25
+
26
+ function isolatedPluginSource(plugin) {
27
+ const source = plugin[PLUGIN_SOURCE];
28
+ if (!source || typeof source.module !== 'string' || typeof source.export !== 'string' || !isJsonValue(source.options)) {
29
+ throw new Error(
30
+ `Request plugin ${plugin.name || '(anonymous)'} cannot be recreated in isolated site processes. ` +
31
+ 'Attach a Roster.pluginSource descriptor with JSON-serializable options, or set isolation: false'
32
+ );
33
+ }
34
+ return { module: source.module, export: source.export, options: source.options };
35
+ }
12
36
 
13
37
  function requestError(error, res) {
14
38
  log.error('Request handler failed:', error?.message || error);
@@ -271,6 +295,27 @@ class Roster {
271
295
  this.greenlockStorePath = options.greenlockStorePath || path.join(basePath, 'greenlock.d');
272
296
  this.staging = options.staging || false;
273
297
  this.cluster = options.cluster || false;
298
+ const isolation = options.isolation ?? !this.cluster;
299
+ if (typeof isolation !== 'boolean' && (typeof isolation !== 'object' || isolation === null)) {
300
+ throw new TypeError('isolation must be a boolean or an options object');
301
+ }
302
+ if (isolation && this.cluster) {
303
+ throw new Error('isolation cannot be combined with cluster: true');
304
+ }
305
+ this.isolation = isolation ? {
306
+ restartDelayMs: 1000,
307
+ backlog: 511,
308
+ helloTimeoutMs: 10000,
309
+ ...(isolation === true ? {} : isolation)
310
+ } : null;
311
+ for (const [name, value] of Object.entries(this.isolation || {})) {
312
+ if (!Number.isFinite(value) || value <= 0) {
313
+ throw new TypeError(`isolation.${name} must be a positive finite number`);
314
+ }
315
+ }
316
+ this._isolate = false;
317
+ this._isolatedSites = [];
318
+ this._isolatedRoutes = {};
274
319
  this.local = options.local || false;
275
320
  this.domains = [];
276
321
  this.sites = {};
@@ -287,6 +332,8 @@ class Roster {
287
332
  this._initPromise = null;
288
333
  this._startPromise = null;
289
334
  this._closePromise = null;
335
+ this.handleSignals = parseBooleanFlag(options.handleSignals, false);
336
+ this._signalHandler = null;
290
337
  this._ownedServers = new Set();
291
338
  this._sockets = new Set();
292
339
  this._upgradedSockets = new Set();
@@ -379,7 +426,9 @@ class Roster {
379
426
 
380
427
  let resolved;
381
428
  try {
382
- resolved = await resolveSiteApp(domainPath, { filename: this.filename });
429
+ resolved = this._isolate
430
+ ? this._locateSite(domainPath)
431
+ : await resolveSiteApp(domainPath, { filename: this.filename });
383
432
  } catch (err) {
384
433
  log.warn(`⚠️ Error loading site in ${domainPath}:`, err);
385
434
  continue;
@@ -415,6 +464,42 @@ class Roster {
415
464
  }
416
465
  }
417
466
 
467
+ // An isolated site is recorded by its directory and loaded only by its own process.
468
+ _locateSite(domainPath) {
469
+ const entry = locateSiteEntry(domainPath, { filename: this.filename });
470
+ return entry ? { siteApp: domainPath, type: entry.type } : null;
471
+ }
472
+
473
+ _isLoadedInProcess(sitePath) {
474
+ const entry = locateSiteEntry(sitePath, { filename: this.filename });
475
+ if (!entry || entry.type !== 'js') return false;
476
+ return Boolean(require.cache[entry.path] || require.cache[fs.realpathSync(entry.path)]);
477
+ }
478
+
479
+ // Load site directories that stay in this process; the rest keep their path for an isolated process.
480
+ async _resolveSiteSources() {
481
+ const sitePaths = new Set(Object.values(this.sites).filter(source => typeof source === 'string'));
482
+ for (const sitePath of sitePaths) {
483
+ this._assertOpen();
484
+ if (this._isolate) {
485
+ if (!this._isLoadedInProcess(sitePath)) continue;
486
+ log.warn(`⚠️ ${sitePath} is already loaded by the main process; serving it there so it never runs twice`);
487
+ }
488
+ let resolved = null;
489
+ try {
490
+ resolved = await resolveSiteApp(sitePath, { filename: this.filename });
491
+ } catch (err) {
492
+ log.warn(`⚠️ Error loading site in ${sitePath}:`, err);
493
+ }
494
+ this._assertOpen();
495
+ for (const [key, source] of Object.entries(this.sites)) {
496
+ if (source !== sitePath) continue;
497
+ if (resolved) this.sites[key] = resolved.siteApp;
498
+ else delete this.sites[key];
499
+ }
500
+ }
501
+ }
502
+
418
503
  generateConfigJson() {
419
504
  const configDir = this.greenlockStorePath;
420
505
  const configPath = path.join(configDir, 'config.json');
@@ -619,8 +704,13 @@ class Roster {
619
704
  if (!domainString) {
620
705
  throw new Error('Domain is required');
621
706
  }
622
- if (typeof requestHandler !== 'function') {
623
- throw new Error('requestHandler must be a function');
707
+ if (typeof requestHandler === 'string') {
708
+ requestHandler = path.resolve(requestHandler);
709
+ if (!locateSiteEntry(requestHandler, { filename: this.filename })) {
710
+ throw new Error(`No index file (js/mjs/cjs or index.html) found in ${requestHandler}`);
711
+ }
712
+ } else if (typeof requestHandler !== 'function') {
713
+ throw new Error('requestHandler must be a function or a site directory path');
624
714
  }
625
715
 
626
716
  const { domain, port } = this.parseDomainWithPort(domainString);
@@ -797,7 +887,7 @@ class Roster {
797
887
  this._sitesByPort = {};
798
888
  for (const [hostKey, siteApp] of Object.entries(this.sites)) {
799
889
  this._assertOpen();
800
- if (hostKey.startsWith('www.')) continue;
890
+ if (hostKey.startsWith('www.') || typeof siteApp !== 'function') continue;
801
891
  const { domain, port } = this.parseDomainWithPort(hostKey);
802
892
  if (!this._sitesByPort[port]) {
803
893
  this._sitesByPort[port] = {
@@ -824,6 +914,104 @@ class Roster {
824
914
  }
825
915
  }
826
916
 
917
+ // Sites left with a directory source after _resolveSiteSources get one process per directory.
918
+ _planIsolatedSites() {
919
+ if (!this._isolate) return;
920
+ const keysBySitePath = new Map();
921
+ for (const [key, source] of Object.entries(this.sites)) {
922
+ if (typeof source !== 'string') continue;
923
+ if (!keysBySitePath.has(source)) keysBySitePath.set(source, []);
924
+ keysBySitePath.get(source).push(key);
925
+ }
926
+ const shared = Object.keys(this.sites).filter(key => !key.startsWith('www.') && typeof this.sites[key] === 'function');
927
+ if (shared.length > 0) {
928
+ log.warn(`⚠️ Not isolated, served by the main process: ${shared.join(', ')}`);
929
+ }
930
+ if (keysBySitePath.size === 0) return;
931
+
932
+ const plugins = this.plugins.map(isolatedPluginSource);
933
+ const options = {
934
+ local: this.local,
935
+ port: this.defaultPort,
936
+ filename: this.filename,
937
+ greenlockStorePath: this.greenlockStorePath,
938
+ tlsMinVersion: this.tlsMinVersion,
939
+ tlsMaxVersion: this.tlsMaxVersion,
940
+ closeTimeoutMs: this.closeTimeoutMs
941
+ };
942
+ const sitesByPath = new Map();
943
+ for (const [sitePath, keys] of keysBySitePath) {
944
+ const site = new IsolatedSite({
945
+ name: path.basename(sitePath),
946
+ config: { path: sitePath, keys, options, plugins },
947
+ isolation: this.isolation,
948
+ issueCertificate: servername => this._checkCertificate(servername)
949
+ });
950
+ sitesByPath.set(sitePath, site);
951
+ this._isolatedSites.push(site);
952
+ }
953
+
954
+ const isolatedPorts = new Set([...keysBySitePath.values()].flat().map(key => this.parseDomainWithPort(key).port));
955
+ for (const [key, source] of Object.entries(this.sites)) {
956
+ if (key.startsWith('www.')) continue;
957
+ const { domain, port } = this.parseDomainWithPort(key);
958
+ if (!isolatedPorts.has(port)) continue;
959
+ const routes = this._isolatedRoutes[port] ??= { exact: new Map(), wildcards: [] };
960
+ const site = sitesByPath.get(source) || null;
961
+ if (domain.startsWith('*.')) routes.wildcards.push([domain, site]);
962
+ else routes.exact.set(domain, site);
963
+ }
964
+ }
965
+
966
+ // Same precedence as getHandlerForPortData: exact host, then wildcards in registration order.
967
+ _isolatedSiteFor(routes, servername) {
968
+ const domain = servername.startsWith('www.') ? servername.slice(4) : servername;
969
+ if (routes.exact.has(domain)) return routes.exact.get(domain);
970
+ const wildcard = routes.wildcards.find(([pattern]) => hostMatchesWildcard(domain, pattern));
971
+ return wildcard ? wildcard[1] : null;
972
+ }
973
+
974
+ // Read the TLS ClientHello and hand the raw socket to the process serving its SNI host.
975
+ _route(socket, port, routes, server) {
976
+ let head = Buffer.alloc(0);
977
+ socket.setTimeout(this.isolation.helloTimeoutMs, () => socket.destroy());
978
+ socket.on('error', () => socket.destroy());
979
+ const read = chunk => {
980
+ head = Buffer.concat([head, chunk]);
981
+ const servername = parseClientHello(head);
982
+ if (servername === undefined) return;
983
+ socket.removeListener('data', read);
984
+ socket.pause();
985
+ socket.setTimeout(0);
986
+ const site = servername && this._isolatedSiteFor(routes, servername);
987
+ if (site) {
988
+ site.handoff(port, head, socket);
989
+ return;
990
+ }
991
+ socket.unshift(head);
992
+ server.emit('connection', socket);
993
+ };
994
+ socket.on('data', read);
995
+ }
996
+
997
+ _listenSite(server, port) {
998
+ const routes = this._isolatedRoutes[port];
999
+ if (!routes) {
1000
+ this.portServers[port] = server;
1001
+ return this._listen(server, port, this.hostname);
1002
+ }
1003
+ this._ownedServers.add(server);
1004
+ const router = net.createServer(socket => this._route(socket, port, routes, server));
1005
+ this.portServers[port] = router;
1006
+ return this._listen(router, port, this.hostname);
1007
+ }
1008
+
1009
+ async _startIsolatedSites() {
1010
+ this._assertOpen();
1011
+ await Promise.all(this._isolatedSites.map(site => site.start()));
1012
+ this._assertOpen();
1013
+ }
1014
+
827
1015
  _createDispatcher(portData) {
828
1016
  return (req, res) => {
829
1017
  if (!this._beginRequest(res)) return;
@@ -1097,6 +1285,16 @@ class Roster {
1097
1285
  init() {
1098
1286
  if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
1099
1287
  if (this._initPromise) return this._initPromise;
1288
+ if (this.handleSignals) {
1289
+ this._signalHandler = () => {
1290
+ if (this._closing) return;
1291
+ this.close().catch(error => {
1292
+ log.error('Signal shutdown failed:', error.message);
1293
+ process.exitCode = 1;
1294
+ });
1295
+ };
1296
+ for (const signal of SHUTDOWN_SIGNALS) process.on(signal, this._signalHandler);
1297
+ }
1100
1298
  this._initTask = this._initialize();
1101
1299
  this._initPromise = this._initTask.catch(async error => {
1102
1300
  if (!this._closing) {
@@ -1112,6 +1310,7 @@ class Roster {
1112
1310
  async _initialize() {
1113
1311
  await this.loadSites();
1114
1312
  this._assertOpen();
1313
+ await this._resolveSiteSources();
1115
1314
  if (!this.local) {
1116
1315
  this.generateConfigJson();
1117
1316
  if (this.autoCertificates) {
@@ -1119,6 +1318,7 @@ class Roster {
1119
1318
  }
1120
1319
  }
1121
1320
  this._initSiteHandlers();
1321
+ this._planIsolatedSites();
1122
1322
  this._assertOpen();
1123
1323
  if (!this.local) {
1124
1324
  this._initSniResolver();
@@ -1195,7 +1395,9 @@ class Roster {
1195
1395
  timer = setTimeout(() => {
1196
1396
  for (const res of this._responses) res.destroy();
1197
1397
  for (const socket of this._sockets) socket.destroy();
1198
- for (const server of this._ownedServers) server.closeAllConnections();
1398
+ // Isolation routers are plain TCP servers; their sockets are in _sockets.
1399
+ for (const server of this._ownedServers) server.closeAllConnections?.();
1400
+ for (const site of this._isolatedSites) site.kill();
1199
1401
  reject(timeoutError);
1200
1402
  }, this.closeTimeoutMs);
1201
1403
  });
@@ -1227,7 +1429,7 @@ class Roster {
1227
1429
  if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') errors.push(error);
1228
1430
  resolve();
1229
1431
  });
1230
- server.closeIdleConnections();
1432
+ server.closeIdleConnections?.();
1231
1433
  } catch (error) {
1232
1434
  errors.push(error);
1233
1435
  resolve();
@@ -1241,8 +1443,11 @@ class Roster {
1241
1443
  try { server.close(); } catch (error) { errors.push(error); }
1242
1444
  }
1243
1445
  for (const socket of this._upgradedSockets) socket.destroy();
1446
+ const isolated = this._isolatedSites.map(site => site.close().then(siteErrors => {
1447
+ errors.push(...siteErrors);
1448
+ }));
1244
1449
  await wait(Promise.all([
1245
- ...closing, ...draining,
1450
+ ...closing, ...draining, ...isolated,
1246
1451
  Promise.allSettled([...this._certificateChecks.values(), ...this._contextLoads.values()])
1247
1452
  ]));
1248
1453
  if (isBunRuntime) {
@@ -1260,6 +1465,10 @@ class Roster {
1260
1465
  }));
1261
1466
  } finally {
1262
1467
  clearTimeout(timer);
1468
+ if (this._signalHandler) {
1469
+ for (const signal of SHUTDOWN_SIGNALS) process.removeListener(signal, this._signalHandler);
1470
+ this._signalHandler = null;
1471
+ }
1263
1472
  for (const [server, handlers] of this._attachments) {
1264
1473
  server.removeListener('request', handlers.request);
1265
1474
  server.removeListener('upgrade', handlers.upgrade);
@@ -1359,11 +1568,11 @@ class Roster {
1359
1568
  async startLocalMode() {
1360
1569
  this.domainPorts = {};
1361
1570
 
1362
- for (const portData of Object.values(this._sitesByPort)) {
1571
+ for (const [sitePort, portData] of Object.entries(this._sitesByPort)) {
1363
1572
  for (const [domain, virtualServer] of Object.entries(portData.virtualServers)) {
1364
1573
  if (domain.startsWith('www.')) continue;
1365
1574
 
1366
- const port = this.assignPortToDomain(domain);
1575
+ const port = this.assignPortToDomain(domain, Number(sitePort));
1367
1576
  this.domainPorts[domain] = port;
1368
1577
 
1369
1578
  const appHandler = portData.appHandlers[domain];
@@ -1397,13 +1606,32 @@ class Roster {
1397
1606
  }
1398
1607
  }
1399
1608
 
1400
- log.info(`(✔) Started ${Object.keys(this.portServers).length} sites in local mode`);
1609
+ for (const site of this._isolatedSites) {
1610
+ for (const key of site.config.keys) {
1611
+ if (key.startsWith('www.')) continue;
1612
+ const { domain, port: sitePort } = this.parseDomainWithPort(key);
1613
+ const port = this.assignPortToDomain(domain, sitePort);
1614
+ site.localPorts[`${domain}:${sitePort}`] = port;
1615
+ this.domainPorts[domain] = port;
1616
+ }
1617
+ }
1618
+ await this._startIsolatedSites();
1619
+
1620
+ log.info(`(✔) Started ${Object.keys(this.domainPorts).length} sites in local mode`);
1401
1621
  return Promise.resolve();
1402
1622
  }
1403
1623
 
1404
1624
  start() {
1405
1625
  if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
1406
1626
  if (!this._startPromise) {
1627
+ // Isolation requires Roster to own the listeners, so only start() initializes for it.
1628
+ if (this.isolation && !this._initPromise) {
1629
+ if (isBunRuntime) {
1630
+ log.warn('⚠️ Bun runtime detected: site isolation is not supported; all sites run in the main process');
1631
+ } else {
1632
+ this._isolate = true;
1633
+ }
1634
+ }
1407
1635
  this._startPromise = this._start().catch(async error => {
1408
1636
  if (!this._closing) {
1409
1637
  try { await this.close(); } catch (cleanupError) {
@@ -1445,9 +1673,12 @@ class Roster {
1445
1673
 
1446
1674
  await this._listen(httpServer, 80, this.hostname);
1447
1675
  log.info('HTTP server listening on port 80');
1676
+ await this._startIsolatedSites();
1448
1677
 
1449
- for (const [port, portData] of Object.entries(this._sitesByPort)) {
1678
+ const ports = new Set([...Object.keys(this._sitesByPort), ...Object.keys(this._isolatedRoutes)]);
1679
+ for (const port of ports) {
1450
1680
  const portNum = parseInt(port);
1681
+ const portData = this._sitesByPort[port] || { virtualServers: {}, appHandlers: {} };
1451
1682
  const dispatcher = this._createDispatcher(portData);
1452
1683
  const upgradeHandler = this._createUpgradeHandler(portData);
1453
1684
 
@@ -1560,10 +1791,9 @@ class Roster {
1560
1791
  httpsServer = glx.httpsServer(tlsOpts, dispatcher);
1561
1792
  }
1562
1793
 
1563
- this.portServers[portNum] = httpsServer;
1564
1794
  httpsServer.on('upgrade', upgradeHandler);
1565
1795
 
1566
- await this._listen(httpsServer, portNum, this.hostname);
1796
+ await this._listenSite(httpsServer, portNum);
1567
1797
  log.info(`HTTPS server listening on port ${portNum}`);
1568
1798
  } else {
1569
1799
  const httpsOptions = {
@@ -1583,9 +1813,7 @@ class Roster {
1583
1813
  }
1584
1814
  });
1585
1815
 
1586
- this.portServers[portNum] = httpsServer;
1587
-
1588
- await this._listen(httpsServer, portNum, this.hostname);
1816
+ await this._listenSite(httpsServer, portNum);
1589
1817
  log.info(`HTTPS server listening on port ${portNum}`);
1590
1818
  }
1591
1819
  }
@@ -1637,6 +1865,7 @@ class Roster {
1637
1865
 
1638
1866
  module.exports = Roster;
1639
1867
  module.exports.isBunRuntime = isBunRuntime;
1868
+ module.exports.pluginSource = PLUGIN_SOURCE;
1640
1869
  module.exports.wildcardRoot = wildcardRoot;
1641
1870
  module.exports.hostMatchesWildcard = hostMatchesWildcard;
1642
1871
  module.exports.wildcardSubjectForHost = wildcardSubjectForHost;
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const RECORD_HEADER_BYTES = 5;
4
+ const HANDSHAKE_RECORD = 0x16;
5
+ const CLIENT_HELLO = 0x01;
6
+ const SERVER_NAME_EXTENSION = 0x0000;
7
+ const HOST_NAME = 0x00;
8
+ // TLS limits a record to 16 KiB; a ClientHello spanning more than four records is not a browser handshake.
9
+ const MAX_CLIENT_HELLO_BYTES = 4 * (RECORD_HEADER_BYTES + 16384);
10
+
11
+ function readServerName(hello) {
12
+ let offset = 4 + 2 + 32;
13
+ offset += 1 + hello[offset];
14
+ offset += 2 + hello.readUInt16BE(offset);
15
+ offset += 1 + hello[offset];
16
+ const extensionsEnd = offset + 2 + hello.readUInt16BE(offset);
17
+ offset += 2;
18
+ while (offset + 4 <= extensionsEnd) {
19
+ const type = hello.readUInt16BE(offset);
20
+ const length = hello.readUInt16BE(offset + 2);
21
+ offset += 4;
22
+ if (type === SERVER_NAME_EXTENSION) {
23
+ const listEnd = offset + 2 + hello.readUInt16BE(offset);
24
+ let entry = offset + 2;
25
+ while (entry + 3 <= listEnd) {
26
+ const nameLength = hello.readUInt16BE(entry + 1);
27
+ if (hello[entry] === HOST_NAME) {
28
+ return hello.toString('ascii', entry + 3, entry + 3 + nameLength).toLowerCase().replace(/\.$/, '') || null;
29
+ }
30
+ entry += 3 + nameLength;
31
+ }
32
+ return null;
33
+ }
34
+ offset += length;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Extract the SNI host name from the first bytes a TLS client sends.
41
+ * @param {Buffer} buffer - Bytes received so far on the connection.
42
+ * @returns {string|null|undefined} The lowercase server name, `null` when the bytes are not a
43
+ * ClientHello carrying one, or `undefined` when more bytes are needed to decide.
44
+ */
45
+ function parseClientHello(buffer) {
46
+ const fragments = [];
47
+ let offset = 0;
48
+ while (true) {
49
+ if (buffer.length >= MAX_CLIENT_HELLO_BYTES) return null;
50
+ if (buffer.length < offset + RECORD_HEADER_BYTES) return undefined;
51
+ if (buffer[offset] !== HANDSHAKE_RECORD) return null;
52
+ const recordEnd = offset + RECORD_HEADER_BYTES + buffer.readUInt16BE(offset + 3);
53
+ if (buffer.length < recordEnd) return undefined;
54
+ fragments.push(buffer.subarray(offset + RECORD_HEADER_BYTES, recordEnd));
55
+ offset = recordEnd;
56
+ const hello = Buffer.concat(fragments);
57
+ if (hello.length < 4) continue;
58
+ if (hello[0] !== CLIENT_HELLO) return null;
59
+ if (hello.length < 4 + hello.readUIntBE(1, 3)) continue;
60
+ try {
61
+ return readServerName(hello);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ }
67
+
68
+ module.exports = { parseClientHello };