roster-server 2.4.14 → 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.
@@ -203,7 +252,7 @@ During shutdown, `roster.close()` drains its routed requests, closes its routed
203
252
 
204
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.
205
254
 
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.
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.
207
256
 
208
257
  ## Certificates and DNS
209
258
 
@@ -245,6 +294,7 @@ Pass operational settings through the constructor, using the consuming applicati
245
294
  | `certificateRenewIntervalMs` | `43200000` (12h) | Roster renewal check interval; minimum `60000`. |
246
295
  | `closeTimeoutMs` | `30000` | Positive finite total shutdown deadline. |
247
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). |
248
298
  | `tlsMinVersion`, `tlsMaxVersion` | `TLSv1.2`, `TLSv1.3` | Protocol limits for created HTTPS servers. |
249
299
  | `skipLocalCheck` | `true` | Skips Greenlock dry-run/local challenge checks. |
250
300
  | `dnsChallenge` | CLI wrapper | DNS-01 options or `false`; see [certificates](#certificates-and-dns). |
@@ -255,7 +305,7 @@ Pass operational settings through the constructor, using the consuming applicati
255
305
 
256
306
  | Method | Contract |
257
307
  | --- | --- |
258
- | `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`. |
259
309
  | `use(plugin)` | Add a synchronous request plugin. Returns `Roster`. |
260
310
  | `init()` | `Promise<Roster>`; prepare routing and factories without listening. |
261
311
  | `start()` | Start standalone/local listeners; await readiness. |
@@ -270,6 +320,7 @@ Pass operational settings through the constructor, using the consuming applicati
270
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()`. |
271
321
  | `createServingHttpsServer({ servername, port?, tlsOptions? })` | Same helper with `ensureCertificate: false`; set `autoCertificates: false` for a serving-only instance. |
272
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). |
273
324
 
274
325
  ## Request plugins
275
326
 
@@ -283,7 +334,7 @@ import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
283
334
  roster.use(createScannerBlocker());
284
335
  ```
285
336
 
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.
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.
287
338
 
288
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.
289
340
 
package/index.js CHANGED
@@ -2,14 +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');
12
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
+ }
13
36
 
14
37
  function requestError(error, res) {
15
38
  log.error('Request handler failed:', error?.message || error);
@@ -272,6 +295,27 @@ class Roster {
272
295
  this.greenlockStorePath = options.greenlockStorePath || path.join(basePath, 'greenlock.d');
273
296
  this.staging = options.staging || false;
274
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 = {};
275
319
  this.local = options.local || false;
276
320
  this.domains = [];
277
321
  this.sites = {};
@@ -382,7 +426,9 @@ class Roster {
382
426
 
383
427
  let resolved;
384
428
  try {
385
- resolved = await resolveSiteApp(domainPath, { filename: this.filename });
429
+ resolved = this._isolate
430
+ ? this._locateSite(domainPath)
431
+ : await resolveSiteApp(domainPath, { filename: this.filename });
386
432
  } catch (err) {
387
433
  log.warn(`⚠️ Error loading site in ${domainPath}:`, err);
388
434
  continue;
@@ -418,6 +464,42 @@ class Roster {
418
464
  }
419
465
  }
420
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
+
421
503
  generateConfigJson() {
422
504
  const configDir = this.greenlockStorePath;
423
505
  const configPath = path.join(configDir, 'config.json');
@@ -622,8 +704,13 @@ class Roster {
622
704
  if (!domainString) {
623
705
  throw new Error('Domain is required');
624
706
  }
625
- if (typeof requestHandler !== 'function') {
626
- 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');
627
714
  }
628
715
 
629
716
  const { domain, port } = this.parseDomainWithPort(domainString);
@@ -800,7 +887,7 @@ class Roster {
800
887
  this._sitesByPort = {};
801
888
  for (const [hostKey, siteApp] of Object.entries(this.sites)) {
802
889
  this._assertOpen();
803
- if (hostKey.startsWith('www.')) continue;
890
+ if (hostKey.startsWith('www.') || typeof siteApp !== 'function') continue;
804
891
  const { domain, port } = this.parseDomainWithPort(hostKey);
805
892
  if (!this._sitesByPort[port]) {
806
893
  this._sitesByPort[port] = {
@@ -827,6 +914,104 @@ class Roster {
827
914
  }
828
915
  }
829
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
+
830
1015
  _createDispatcher(portData) {
831
1016
  return (req, res) => {
832
1017
  if (!this._beginRequest(res)) return;
@@ -1125,6 +1310,7 @@ class Roster {
1125
1310
  async _initialize() {
1126
1311
  await this.loadSites();
1127
1312
  this._assertOpen();
1313
+ await this._resolveSiteSources();
1128
1314
  if (!this.local) {
1129
1315
  this.generateConfigJson();
1130
1316
  if (this.autoCertificates) {
@@ -1132,6 +1318,7 @@ class Roster {
1132
1318
  }
1133
1319
  }
1134
1320
  this._initSiteHandlers();
1321
+ this._planIsolatedSites();
1135
1322
  this._assertOpen();
1136
1323
  if (!this.local) {
1137
1324
  this._initSniResolver();
@@ -1208,7 +1395,9 @@ class Roster {
1208
1395
  timer = setTimeout(() => {
1209
1396
  for (const res of this._responses) res.destroy();
1210
1397
  for (const socket of this._sockets) socket.destroy();
1211
- 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();
1212
1401
  reject(timeoutError);
1213
1402
  }, this.closeTimeoutMs);
1214
1403
  });
@@ -1240,7 +1429,7 @@ class Roster {
1240
1429
  if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') errors.push(error);
1241
1430
  resolve();
1242
1431
  });
1243
- server.closeIdleConnections();
1432
+ server.closeIdleConnections?.();
1244
1433
  } catch (error) {
1245
1434
  errors.push(error);
1246
1435
  resolve();
@@ -1254,8 +1443,11 @@ class Roster {
1254
1443
  try { server.close(); } catch (error) { errors.push(error); }
1255
1444
  }
1256
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
+ }));
1257
1449
  await wait(Promise.all([
1258
- ...closing, ...draining,
1450
+ ...closing, ...draining, ...isolated,
1259
1451
  Promise.allSettled([...this._certificateChecks.values(), ...this._contextLoads.values()])
1260
1452
  ]));
1261
1453
  if (isBunRuntime) {
@@ -1376,11 +1568,11 @@ class Roster {
1376
1568
  async startLocalMode() {
1377
1569
  this.domainPorts = {};
1378
1570
 
1379
- for (const portData of Object.values(this._sitesByPort)) {
1571
+ for (const [sitePort, portData] of Object.entries(this._sitesByPort)) {
1380
1572
  for (const [domain, virtualServer] of Object.entries(portData.virtualServers)) {
1381
1573
  if (domain.startsWith('www.')) continue;
1382
1574
 
1383
- const port = this.assignPortToDomain(domain);
1575
+ const port = this.assignPortToDomain(domain, Number(sitePort));
1384
1576
  this.domainPorts[domain] = port;
1385
1577
 
1386
1578
  const appHandler = portData.appHandlers[domain];
@@ -1414,13 +1606,32 @@ class Roster {
1414
1606
  }
1415
1607
  }
1416
1608
 
1417
- 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`);
1418
1621
  return Promise.resolve();
1419
1622
  }
1420
1623
 
1421
1624
  start() {
1422
1625
  if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
1423
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
+ }
1424
1635
  this._startPromise = this._start().catch(async error => {
1425
1636
  if (!this._closing) {
1426
1637
  try { await this.close(); } catch (cleanupError) {
@@ -1462,9 +1673,12 @@ class Roster {
1462
1673
 
1463
1674
  await this._listen(httpServer, 80, this.hostname);
1464
1675
  log.info('HTTP server listening on port 80');
1676
+ await this._startIsolatedSites();
1465
1677
 
1466
- 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) {
1467
1680
  const portNum = parseInt(port);
1681
+ const portData = this._sitesByPort[port] || { virtualServers: {}, appHandlers: {} };
1468
1682
  const dispatcher = this._createDispatcher(portData);
1469
1683
  const upgradeHandler = this._createUpgradeHandler(portData);
1470
1684
 
@@ -1577,10 +1791,9 @@ class Roster {
1577
1791
  httpsServer = glx.httpsServer(tlsOpts, dispatcher);
1578
1792
  }
1579
1793
 
1580
- this.portServers[portNum] = httpsServer;
1581
1794
  httpsServer.on('upgrade', upgradeHandler);
1582
1795
 
1583
- await this._listen(httpsServer, portNum, this.hostname);
1796
+ await this._listenSite(httpsServer, portNum);
1584
1797
  log.info(`HTTPS server listening on port ${portNum}`);
1585
1798
  } else {
1586
1799
  const httpsOptions = {
@@ -1600,9 +1813,7 @@ class Roster {
1600
1813
  }
1601
1814
  });
1602
1815
 
1603
- this.portServers[portNum] = httpsServer;
1604
-
1605
- await this._listen(httpsServer, portNum, this.hostname);
1816
+ await this._listenSite(httpsServer, portNum);
1606
1817
  log.info(`HTTPS server listening on port ${portNum}`);
1607
1818
  }
1608
1819
  }
@@ -1654,6 +1865,7 @@ class Roster {
1654
1865
 
1655
1866
  module.exports = Roster;
1656
1867
  module.exports.isBunRuntime = isBunRuntime;
1868
+ module.exports.pluginSource = PLUGIN_SOURCE;
1657
1869
  module.exports.wildcardRoot = wildcardRoot;
1658
1870
  module.exports.hostMatchesWildcard = hostMatchesWildcard;
1659
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 };