roster-server 3.4.1 → 3.5.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
@@ -180,7 +180,7 @@ module.exports = { createMyPlugin };
180
180
 
181
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
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.
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 cannot be combined with the legacy `cluster: true` launcher, which disables it by default.
184
184
 
185
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
186
 
@@ -204,7 +204,7 @@ export default {
204
204
  };
205
205
  ```
206
206
 
207
- In production, `socket` is the client's TCP connection and `head` holds the TLS ClientHello that Roster already read; put it back with `socket.unshift(head)` before terminating TLS, and do not `resume()` the socket after wrapping it. In local mode, the site's assigned port accepts plain TCP connections, `socket` arrives paused, and `head` is empty. `connection()` runs synchronously; if it throws, Roster destroys the socket.
207
+ In production, `socket` is the client's TCP connection and `head` holds the TLS ClientHello that Roster already read; put it back with `socket.unshift(head)` before terminating TLS, and do not `resume()` the socket after wrapping it. To terminate TLS with an HTTPS server, create it with `Roster.createHttpsServer()` and hand it the socket with `Roster.acceptTlsConnection(server, socket)`: on Node these are `https.createServer()` and `server.emit('connection', socket)`, and on Bun they also select certificates per host and adopt the socket, which Bun's own `https.Server` does not. In local mode, the site's assigned port accepts plain TCP connections, `socket` arrives paused, and `head` is empty. `connection()` runs synchronously; if it throws, Roster destroys the socket.
208
208
 
209
209
  `certificate(servername)` returns the certificate Roster would serve for that host, with the same wildcard and zone matching. A missing certificate is issued by the main process first, as for any isolated site. Roster also renews it; call `certificate()` again to pick up a renewal, for example on an interval or when a cached copy gets old. It is not available in local mode. Request plugins, `www.` redirects, and `onClose` hooks do not apply: the site owns every connection it receives. A socket site must run isolated; Roster rejects it when it would be served by the main process.
210
210
 
@@ -348,6 +348,8 @@ Pass operational settings through the constructor, using the consuming applicati
348
348
  | `createManagedHttpsServer({ servername, port?, ensureCertificate?, tlsOptions? })` | `Promise<https.Server>` with certificates and dispatchers; does not listen. Certificate assurance defaults to `true`. Requires production-mode `init()`. |
349
349
  | `createServingHttpsServer({ servername, port?, tlsOptions? })` | Same helper with `ensureCertificate: false`; set `autoCertificates: false` for a serving-only instance. |
350
350
  | `virtualServer.onClose(fn)` | Register a cleanup hook; returns the virtual server. See [shutdown](#startup-and-shutdown). |
351
+ | `Roster.createHttpsServer(options, handler?)` | `https.createServer()` with per-host `SNICallback`, `setSecureContext()`, and adopted sockets on both Node and Bun. Roster creates its HTTPS servers with it. |
352
+ | `Roster.acceptTlsConnection(server, socket)` | Hand a raw client socket, with any bytes already read put back, to a server from `createHttpsServer()` to terminate TLS. |
351
353
  | `Roster.pluginSource` | Symbol for a request plugin's `{ module, export, options }` descriptor. See [site isolation](#site-isolation). |
352
354
 
353
355
  ## Request plugins
@@ -378,7 +380,7 @@ Enable `trustProxy` only behind a trusted proxy that overwrites `X-Forwarded-For
378
380
  | 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. |
379
381
  | Shutdown times out | Inspect the aggregated errors and site hooks, active requests, and pending certificate operations. |
380
382
 
381
- 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.
383
+ Local integration checks cover Node HTTP/TLS and Socket.IO polling/WebSocket shutdown on Node and Bun, and the test suite runs on both. Bun's `node:https` server never invokes `SNICallback`, lacks `addContext()` and `setSecureContext()`, and cannot adopt a socket passed to `emit('connection')` (checked on Bun 1.3.4 and 1.4.4). On Bun, Roster therefore terminates TLS with a `tls.Server` in front of an `http.Server`, so per-host certificates, certificate reload, custom HTTPS ports and site isolation behave as on Node. The visible difference is the server's class: on Bun, `Roster.createHttpsServer()`, `createManagedHttpsServer()` and `createServingHttpsServer()` return an `http.Server` whose `listen()`, `close()`, `address()` and certificate methods act on the inner `tls.Server`, so `instanceof https.Server` is `false` there.
382
384
 
383
385
  ## Agent skill and development
384
386
 
@@ -388,7 +390,7 @@ The [RosterServer skill](skills/roster-server/SKILL.md) guides application integ
388
390
  npx skills add https://github.com/clasen/RosterServer --skill roster-server
389
391
  ```
390
392
 
391
- Contributors can run `pnpm test` for the Node test suite. Examples live in [demo](demo); they are separate from the published API reference above.
393
+ Contributors can run `pnpm test` for the Node test suite and `pnpm test:bun` to run the same suite under Bun. Examples live in [demo](demo); they are separate from the published API reference above.
392
394
 
393
395
  ## License
394
396
 
package/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const http = require('http');
4
- const https = require('https');
5
4
  const net = require('net');
6
5
  const tls = require('tls');
7
6
  const crypto = require('crypto');
8
7
  const { EventEmitter } = require('events');
9
8
  const Greenlock = require('./vendor/greenlock-express/greenlock-express.js');
10
9
  const GreenlockShim = require('./vendor/greenlock-express/greenlock-shim.js');
10
+ const { createHttpsServer, acceptTlsConnection, isBunRuntime } = require('./lib/https-server.js');
11
11
  const { locateSiteEntry, resolveSiteApp, isSocketSite } = require('./lib/resolve-site-app.js');
12
12
  const { IsolatedSite } = require('./lib/isolated-site.js');
13
13
  const { parseClientHello } = require('./lib/client-hello.js');
@@ -58,8 +58,6 @@ function invokeRequest(handler, req, res) {
58
58
  }
59
59
  }
60
60
 
61
- const isBunRuntime = typeof Bun !== 'undefined' || (typeof process !== 'undefined' && process.release?.name === 'bun');
62
-
63
61
  // CRC32 implementation for deterministic port assignment
64
62
  function crc32(str) {
65
63
  const crcTable = [];
@@ -372,9 +370,6 @@ class Roster {
372
370
  }
373
371
  this.disableWildcard = parseBooleanFlag(options.disableWildcard, false);
374
372
  this.combineWildcardCerts = parseBooleanFlag(options.combineWildcardCerts, false);
375
- if (isBunRuntime && this.combineWildcardCerts) {
376
- log.info('Bun runtime detected: combined wildcard certificates enabled (SNI bypass)');
377
- }
378
373
 
379
374
  this.skipLocalCheck = parseBooleanFlag(options.skipLocalCheck, true);
380
375
  this.autoCertificates = parseBooleanFlag(options.autoCertificates, true);
@@ -785,6 +780,12 @@ class Roster {
785
780
  }
786
781
  }
787
782
 
783
+ // Names the production site and the local URL that serves it, e.g. `example.com:3334 → tcp://localhost:6702`.
784
+ _describeLocalSite(domain, sitePort, localPort, scheme) {
785
+ const site = `${domain}${sitePort !== this.defaultPort ? ':' + sitePort : ''}`;
786
+ return `${site} → ${scheme}://${localHostForDomain(normalizeDomainForLocalHost(domain))}:${localPort}`;
787
+ }
788
+
788
789
  parseDomainWithPort(domainString) {
789
790
  const parts = domainString.split(':');
790
791
  if (parts.length === 2) {
@@ -1009,7 +1010,7 @@ class Roster {
1009
1010
  return;
1010
1011
  }
1011
1012
  socket.unshift(head);
1012
- server.emit('connection', socket);
1013
+ acceptTlsConnection(server, socket);
1013
1014
  };
1014
1015
  socket.on('data', read);
1015
1016
  }
@@ -1482,7 +1483,8 @@ class Roster {
1482
1483
  Promise.allSettled([...this._certificateChecks.values(), ...this._contextLoads.values()])
1483
1484
  ]));
1484
1485
  if (isBunRuntime) {
1485
- for (const server of this._ownedServers) server.closeAllConnections();
1486
+ // Isolation routers are plain TCP servers; their sockets are in _sockets.
1487
+ for (const server of this._ownedServers) server.closeAllConnections?.();
1486
1488
  await wait(Promise.all([...this._ownedServers].map(closeServer)));
1487
1489
  }
1488
1490
 
@@ -1576,7 +1578,7 @@ class Roster {
1576
1578
  ? await this.ensureCertificate(normalizedServername)
1577
1579
  : this.loadCertificate(normalizedServername);
1578
1580
 
1579
- const server = this._configureSiteServer(https.createServer({
1581
+ const server = this._configureSiteServer(createHttpsServer({
1580
1582
  minVersion: this.tlsMinVersion,
1581
1583
  maxVersion: this.tlsMaxVersion,
1582
1584
  ...tlsOptions,
@@ -1632,8 +1634,7 @@ class Roster {
1632
1634
  });
1633
1635
 
1634
1636
  await this._listen(httpServer, port, this.hostname);
1635
- const cleanDomain = normalizeDomainForLocalHost(domain);
1636
- log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
1637
+ log.info(`🌐 ${this._describeLocalSite(domain, Number(sitePort), port, 'http')}`);
1637
1638
  }
1638
1639
  }
1639
1640
 
@@ -1656,13 +1657,7 @@ class Roster {
1656
1657
  if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
1657
1658
  if (!this._startPromise) {
1658
1659
  // Isolation requires Roster to own the listeners, so only start() initializes for it.
1659
- if (this.isolation && !this._initPromise) {
1660
- if (isBunRuntime) {
1661
- log.warn('⚠️ Bun runtime detected: site isolation is not supported; all sites run in the main process');
1662
- } else {
1663
- this._isolate = true;
1664
- }
1665
- }
1660
+ if (this.isolation && !this._initPromise) this._isolate = true;
1666
1661
  this._startPromise = this._start().catch(async error => {
1667
1662
  if (!this._closing) {
1668
1663
  try { await this.close(); } catch (cleanupError) {
@@ -1700,7 +1695,6 @@ class Roster {
1700
1695
  this._assertOpen();
1701
1696
  {
1702
1697
  const httpServer = glx.httpServer();
1703
- const bunTlsHotReloadHandlers = [];
1704
1698
 
1705
1699
  await this._listen(httpServer, 80, this.hostname);
1706
1700
  log.info('HTTP server listening on port 80');
@@ -1712,182 +1706,28 @@ class Roster {
1712
1706
  const portData = this._sitesByPort[port] || { virtualServers: {}, appHandlers: {} };
1713
1707
  const dispatcher = this._createDispatcher(portData);
1714
1708
  const upgradeHandler = this._createUpgradeHandler(portData);
1709
+ const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
1715
1710
 
1716
- const issueAndReloadPemsForServername = async (servername) => {
1717
- const host = this._normalizeHostInput(servername).trim().toLowerCase();
1718
- if (!host) return null;
1719
-
1720
- let pems = this._resolvePemsForServername(host);
1721
- if (pems) return pems;
1722
-
1723
- try {
1724
- await this._checkCertificate(host);
1725
- } catch (error) {
1726
- log.warn(`⚠️ Greenlock issuance failed for ${host}: ${error?.message || error}`);
1727
- }
1728
-
1729
- pems = this._resolvePemsForServername(host);
1730
- if (pems) return pems;
1731
-
1732
- const wildcardSubject = wildcardSubjectForHost(host);
1733
- const zone = wildcardSubject ? wildcardRoot(wildcardSubject) : null;
1734
- if (zone) {
1735
- const bootstrapHost = `bun-bootstrap.${zone}`;
1736
- try {
1737
- await this._checkCertificate(bootstrapHost);
1738
- } catch (error) {
1739
- log.warn(`⚠️ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
1740
- }
1741
- pems = this._resolvePemsForServername(host);
1742
- }
1743
-
1744
- return pems;
1745
- };
1746
-
1747
- const ensureBunDefaultPems = async (primaryDomain) => {
1748
- let pems = await issueAndReloadPemsForServername(primaryDomain);
1749
-
1750
- const needsWildcard = this.combineWildcardCerts
1751
- && this.wildcardZones.has(primaryDomain)
1752
- && this.dnsChallenge;
1753
-
1754
- if (pems && needsWildcard && !certCoversName(pems.cert, `*.${primaryDomain}`)) {
1755
- log.warn(`⚠️ Existing cert for ${primaryDomain} lacks *.${primaryDomain} SAN — clearing stale cert for combined re-issuance`);
1756
- const certDir = path.join(this.greenlockStorePath, 'live', primaryDomain);
1757
- try { fs.rmSync(certDir, { recursive: true, force: true }); } catch {}
1758
- pems = null;
1759
- }
1760
-
1761
- if (pems) return pems;
1762
-
1763
- const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
1764
- log.warn(`⚠️ Bun: requesting ${needsWildcard ? 'combined wildcard' : ''} certificate for ${certSubject} via Greenlock before HTTPS bind`);
1765
- try {
1766
- await this._checkCertificate(certSubject);
1767
- } catch (error) {
1768
- log.error(`❌ Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
1769
- }
1770
-
1771
- pems = this._resolvePemsForServername(primaryDomain);
1772
- if (pems) return pems;
1773
-
1774
- throw new Error(
1775
- `Bun runtime could not load TLS certificate files for ${primaryDomain}. ` +
1776
- `Refusing to start HTTPS on port ${portNum} to avoid serving invalid TLS.`
1777
- );
1778
- };
1779
-
1711
+ let httpsServer;
1780
1712
  if (portNum === this.defaultPort) {
1781
- const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
1782
- let httpsServer;
1783
-
1784
- if (isBunRuntime) {
1785
- const primaryDomain = Object.keys(portData.virtualServers)[0];
1786
- let defaultPems = await ensureBunDefaultPems(primaryDomain);
1787
- let defaultContext = tls.createSecureContext(defaultPems);
1788
- httpsServer = https.createServer({
1789
- ...tlsOpts,
1790
- key: defaultPems.key,
1791
- cert: defaultPems.cert,
1792
- SNICallback: (servername, callback) => {
1793
- const resolveContext = async () => {
1794
- const cached = await this._resolveSecureContext(servername);
1795
- if (cached) return cached;
1796
- const pems = await issueAndReloadPemsForServername(servername);
1797
- this._assertOpen();
1798
- return pems ? this._getSecureContext(servername, false) : defaultContext;
1799
- };
1800
- resolveContext().then(context => callback(null, context), callback);
1801
- }
1802
- }, dispatcher);
1803
- const reloadBunDefaultTls = async (servername, reason) => {
1804
- const nextPems = await issueAndReloadPemsForServername(servername);
1805
- if (!nextPems) return false;
1806
- this._assertOpen();
1807
- defaultPems = nextPems;
1808
- defaultContext = tls.createSecureContext(defaultPems);
1809
- if (typeof httpsServer.setSecureContext === 'function') {
1810
- try {
1811
- httpsServer.setSecureContext({ key: defaultPems.key, cert: defaultPems.cert });
1812
- log.info(`🔄 Bun TLS default certificate reloaded on port ${portNum} (${reason})`);
1813
- } catch (error) {
1814
- log.warn(`⚠️ Failed to hot-reload Bun TLS context on port ${portNum}: ${error?.message || error}`);
1815
- }
1816
- }
1817
- return true;
1818
- };
1819
- bunTlsHotReloadHandlers.push(reloadBunDefaultTls);
1820
- log.warn(`⚠️ Bun runtime detected: using file-based TLS with SNI for ${primaryDomain} on port ${portNum}`);
1821
- } else {
1822
- httpsServer = glx.httpsServer(tlsOpts, dispatcher);
1823
- }
1824
-
1825
- httpsServer.on('upgrade', upgradeHandler);
1826
-
1827
- await this._listenSite(httpsServer, portNum);
1828
- log.info(`HTTPS server listening on port ${portNum}`);
1713
+ httpsServer = glx.httpsServer(tlsOpts, dispatcher);
1829
1714
  } else {
1830
- const httpsOptions = {
1831
- minVersion: this.tlsMinVersion,
1832
- maxVersion: this.tlsMaxVersion,
1715
+ httpsServer = createHttpsServer({
1716
+ ...tlsOpts,
1833
1717
  SNICallback: (servername, callback) => {
1834
1718
  this._getSecureContext(servername, false).then(context => callback(null, context), callback);
1835
1719
  }
1836
- };
1837
-
1838
- const httpsServer = https.createServer(httpsOptions, dispatcher);
1839
- httpsServer.on('upgrade', upgradeHandler);
1840
-
1720
+ }, dispatcher);
1841
1721
  httpsServer.on('tlsClientError', (error) => {
1842
1722
  if (!error.message.includes('http request')) {
1843
1723
  log.error(`TLS error on port ${portNum}:`, error.message);
1844
1724
  }
1845
1725
  });
1846
-
1847
- await this._listenSite(httpsServer, portNum);
1848
- log.info(`HTTPS server listening on port ${portNum}`);
1849
1726
  }
1850
- }
1727
+ httpsServer.on('upgrade', upgradeHandler);
1851
1728
 
1852
- if (isBunRuntime && !this.combineWildcardCerts && this.wildcardZones.size > 0 && bunTlsHotReloadHandlers.length > 0) {
1853
- const retryDelayMs = Number.isFinite(Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_RETRY_MS))
1854
- ? Math.max(1000, Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_RETRY_MS))
1855
- : 30000;
1856
- const maxAttempts = Number.isFinite(Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
1857
- ? Math.max(0, Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
1858
- : 0;
1859
-
1860
- for (const zone of this.wildcardZones) {
1861
- const bootstrapHost = `bun-bootstrap.${zone}`;
1862
- const attemptPrewarm = async (attempt = 1) => {
1863
- if (this._closing) return;
1864
- try {
1865
- log.warn(`⚠️ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
1866
- let reloaded = false;
1867
- for (const reloadTls of bunTlsHotReloadHandlers) {
1868
- reloaded = (await reloadTls(bootstrapHost, `prewarm ${bootstrapHost} attempt ${attempt}`)) || reloaded;
1869
- }
1870
- if (!reloaded) {
1871
- throw new Error(`No certificate could be loaded for ${bootstrapHost}`);
1872
- }
1873
- log.info(`✅ Bun wildcard prewarm succeeded for ${zone} on attempt ${attempt}`);
1874
- } catch (error) {
1875
- log.warn(`⚠️ Bun wildcard prewarm failed for ${zone} (attempt ${attempt}): ${error?.message || error}`);
1876
- if (maxAttempts > 0 && attempt >= maxAttempts) {
1877
- log.warn(`⚠️ Bun wildcard prewarm stopped for ${zone} after ${attempt} attempts`);
1878
- return;
1879
- }
1880
- if (this._closing) return;
1881
- const timer = setTimeout(() => {
1882
- this._retryTimers.delete(timer);
1883
- attemptPrewarm(attempt + 1).catch(() => {});
1884
- }, retryDelayMs);
1885
- this._retryTimers.add(timer);
1886
- }
1887
- };
1888
-
1889
- attemptPrewarm().catch(() => {});
1890
- }
1729
+ await this._listenSite(httpsServer, portNum);
1730
+ log.info(`HTTPS server listening on port ${portNum}`);
1891
1731
  }
1892
1732
  }
1893
1733
  return this;
@@ -1896,6 +1736,8 @@ class Roster {
1896
1736
 
1897
1737
  module.exports = Roster;
1898
1738
  module.exports.isBunRuntime = isBunRuntime;
1739
+ module.exports.createHttpsServer = createHttpsServer;
1740
+ module.exports.acceptTlsConnection = acceptTlsConnection;
1899
1741
  module.exports.pluginSource = PLUGIN_SOURCE;
1900
1742
  module.exports.wildcardRoot = wildcardRoot;
1901
1743
  module.exports.hostMatchesWildcard = hostMatchesWildcard;
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ // HTTPS servers that select certificates per host and adopt handed-off sockets on Node and Bun.
4
+ // Bun's https.Server never calls SNICallback and treats a socket passed to emit('connection') as plain
5
+ // text, but its tls.Server does both. On Bun, a tls.Server therefore terminates TLS in front of an
6
+ // http.Server, which is returned so request, upgrade and connection handling stay those of Node.
7
+ const http = require('http');
8
+ const https = require('https');
9
+ const tls = require('tls');
10
+
11
+ const isBunRuntime = typeof Bun !== 'undefined' || process.release?.name === 'bun';
12
+ const kTlsServer = Symbol('roster.tlsServer');
13
+
14
+ function createHttpsServer(options = {}, handler) {
15
+ if (!isBunRuntime) return https.createServer(options, handler);
16
+
17
+ const server = http.createServer(handler);
18
+ const tlsServer = tls.createServer({ ALPNProtocols: ['http/1.1'], ...options });
19
+ server[kTlsServer] = tlsServer;
20
+
21
+ trackConnections(server);
22
+ tlsServer.on('secureConnection', socket => server.emit('connection', socket));
23
+ tlsServer.on('tlsClientError', (error, socket) => {
24
+ if (server.listenerCount('tlsClientError')) server.emit('tlsClientError', error, socket);
25
+ else socket.destroy();
26
+ });
27
+ tlsServer.on('listening', () => server.emit('listening'));
28
+ tlsServer.on('error', error => server.emit('error', error));
29
+ tlsServer.on('close', () => server.emit('close'));
30
+
31
+ server.listen = (...args) => {
32
+ tlsServer.listen(...args);
33
+ return server;
34
+ };
35
+ server.close = callback => {
36
+ tlsServer.close(callback);
37
+ server.closeIdleConnections();
38
+ return server;
39
+ };
40
+ server.address = () => tlsServer.address();
41
+ server.ref = () => { tlsServer.ref(); return server; };
42
+ server.unref = () => { tlsServer.unref(); return server; };
43
+ Object.defineProperty(server, 'listening', { get: () => tlsServer.listening });
44
+ server.setSecureContext = options => tlsServer.setSecureContext(options);
45
+ server.addContext = (hostname, context) => tlsServer.addContext(hostname, context);
46
+ return server;
47
+ }
48
+
49
+ // Bun's http.Server neither applies keepAliveTimeout to connections passed to emit('connection') nor
50
+ // closes them in closeIdleConnections(), so the connection lifecycle is kept here.
51
+ function trackConnections(server) {
52
+ const active = new Set();
53
+ const idleTimers = new Map();
54
+ const setIdle = socket => {
55
+ active.delete(socket);
56
+ clearTimeout(idleTimers.get(socket));
57
+ const timeout = server.keepAliveTimeout;
58
+ idleTimers.set(socket, timeout > 0 ? setTimeout(() => socket.destroy(), timeout).unref() : null);
59
+ };
60
+ const forget = socket => {
61
+ active.delete(socket);
62
+ clearTimeout(idleTimers.get(socket));
63
+ idleTimers.delete(socket);
64
+ };
65
+ const onRequest = (req, res) => {
66
+ const socket = req.socket;
67
+ if (!idleTimers.has(socket)) return;
68
+ clearTimeout(idleTimers.get(socket));
69
+ idleTimers.set(socket, null);
70
+ active.add(socket);
71
+ res.once('finish', () => { if (idleTimers.has(socket) && !socket.destroyed) setIdle(socket); });
72
+ };
73
+ // Observed from emit() so the server's listeners stay exactly those its users added.
74
+ const emit = server.emit;
75
+ server.emit = function (event, ...args) {
76
+ if (event === 'connection') {
77
+ const socket = args[0];
78
+ idleTimers.set(socket, null);
79
+ socket.once('close', () => forget(socket));
80
+ } else if (event === 'request') {
81
+ onRequest(...args);
82
+ } else if (event === 'upgrade') {
83
+ // An upgraded socket belongs to its upgrade handler.
84
+ forget(args[0].socket);
85
+ }
86
+ return emit.call(this, event, ...args);
87
+ };
88
+ server.closeIdleConnections = () => {
89
+ for (const socket of idleTimers.keys()) if (!active.has(socket)) socket.destroy();
90
+ };
91
+ server.closeAllConnections = () => {
92
+ for (const socket of idleTimers.keys()) socket.destroy();
93
+ };
94
+ }
95
+
96
+ // Hands a raw client socket, with any bytes already read pushed back, to an HTTPS server to terminate TLS.
97
+ function acceptTlsConnection(server, socket) {
98
+ (server[kTlsServer] || server).emit('connection', socket);
99
+ }
100
+
101
+ module.exports = { createHttpsServer, acceptTlsConnection, isBunRuntime };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // Entry point of an isolated site process, forked by IsolatedSite.
4
- const https = require('https');
4
+ const { createHttpsServer, acceptTlsConnection } = require('./https-server.js');
5
5
  const net = require('net');
6
6
  const Roster = require('../index.js');
7
7
  const { resolveSiteApp, isSocketSite } = require('./resolve-site-app.js');
@@ -56,15 +56,16 @@ class SiteWorkerRoster extends Roster {
56
56
  for (const key of this._site.keys) {
57
57
  if (key.startsWith('www.')) continue;
58
58
  const { domain, port } = this.parseDomainWithPort(key);
59
+ const localPort = this._site.localPorts[`${domain}:${port}`];
59
60
  const server = net.createServer({ pauseOnConnect: true });
60
- const listening = this._listen(server, this._site.localPorts[`${domain}:${port}`], this.hostname);
61
+ const listening = this._listen(server, localPort, this.hostname);
61
62
  server.on('connection', socket => {
62
63
  // The site owns the connection from here; it may pass it to another process.
63
64
  this._sockets.delete(socket);
64
65
  this._handOff(head, socket);
65
66
  });
66
67
  await listening;
67
- log.info(`🌐 ${domain} → tcp://localhost:${this._site.localPorts[`${domain}:${port}`]} (socket site)`);
68
+ log.info(`🌐 ${this._describeLocalSite(domain, port, localPort, 'tcp')} (socket site)`);
68
69
  }
69
70
  }
70
71
 
@@ -130,7 +131,7 @@ class SiteWorkerRoster extends Roster {
130
131
  for (const port of ports) {
131
132
  // Only the default port issues missing certificates, as in the main process.
132
133
  const issue = port === this.defaultPort;
133
- const server = this._configureSiteServer(https.createServer({
134
+ const server = this._configureSiteServer(createHttpsServer({
134
135
  minVersion: this.tlsMinVersion,
135
136
  maxVersion: this.tlsMaxVersion,
136
137
  SNICallback: (servername, callback) => {
@@ -156,7 +157,7 @@ class SiteWorkerRoster extends Roster {
156
157
  this._sockets.add(socket);
157
158
  socket.once('close', () => this._sockets.delete(socket));
158
159
  socket.unshift(head);
159
- server.emit('connection', socket);
160
+ acceptTlsConnection(server, socket);
160
161
  }
161
162
  }
162
163
 
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "node --test 'test/**/*.test.js'"
7
+ "test": "node --test 'test/**/*.test.js'",
8
+ "test:bun": "bun test ./test"
8
9
  },
9
10
  "repository": {
10
11
  "type": "git",
@@ -41,14 +42,12 @@
41
42
  "@root/csr": "^0.8.1",
42
43
  "@root/encoding": "^1.0.1",
43
44
  "@root/keypairs": "^0.10.0",
44
- "@root/mkdirp": "^1.0.0",
45
45
  "@root/request": "^1.6.1",
46
46
  "acme-dns-01-cli": "^3.0.7",
47
47
  "acme-http-01-standalone": "^3.0.5",
48
48
  "cert-info": "^1.5.1",
49
49
  "greenlock-store-fs": "^3.2.2",
50
50
  "lemonlog": "^1.2.0",
51
- "redirect-https": "^1.3.1",
52
- "safe-replace": "^1.1.0"
51
+ "redirect-https": "^1.3.1"
53
52
  }
54
53
  }
@@ -72,7 +72,7 @@ A virtual request listener owns its request even before it ends the response. Ro
72
72
  - For unattended Linode operation that must fail rather than fall back to manual DNS, set `dnsChallenge.dnsApiFallbackToManual: false`. Keep other provider choices explicit.
73
73
  - `combineWildcardCerts` combines apex/www/wildcard issuance using DNS-01; `disableWildcard` ignores wildcard sites. Do not enable either as a generic troubleshooting step.
74
74
  - `ensureCertificate(name)` loads existing PEMs or issues missing ones; `loadCertificate(name)` only reads files. Neither means “force renewal.” File-based SNI caches detect certificate changes on supported runtimes.
75
- - In local testing, Bun 1.3.4's `node:https` serving helper did not invoke `SNICallback`, including with the previous synchronous resolver. Do not promise SNI certificate reload merely because files changed; Node and Bun require separate runtime verification.
75
+ - Bun's own `https.Server` ignores `SNICallback` and cannot adopt passed sockets; Roster works around it with `Roster.createHttpsServer()` (a `tls.Server` in front of an `http.Server` on Bun), so per-host certificates, reload, custom ports and isolation match Node. Socket sites that terminate TLS should use `Roster.createHttpsServer()` and `Roster.acceptTlsConnection(server, socket)` rather than `https.createServer()` to work on Bun. Verify both runtimes with `pnpm test` and `pnpm test:bun`.
76
76
  - Request plugins registered with `roster.use(fn)` are synchronous and run before redirects/dispatch. Return `true` only after handling the response. Promise returns produce 500.
77
77
  - The optional `createScannerBlocker` comes from `roster-server/plugins/scanner-blocker.js`. Its ban state is per process and in memory. Enable `trustProxy` only when a trusted proxy overwrites `X-Forwarded-For`; `onBlock` does not itself provide shared bans.
78
78
 
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ // The HTTPS server factory must behave the same on Node and Bun: run with `pnpm test` and `pnpm test:bun`.
4
+ const { it, before } = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const net = require('node:net');
10
+ const tls = require('node:tls');
11
+ const https = require('node:https');
12
+ const { execFileSync } = require('node:child_process');
13
+ const Roster = require('../index.js');
14
+ const { createHttpsServer, acceptTlsConnection } = Roster;
15
+ const { parseClientHello } = require('../lib/client-hello.js');
16
+
17
+ const pems = {};
18
+ before(() => {
19
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-https-server-'));
20
+ for (const [name, serial] of [['one.test', 1], ['two.test', 2]]) {
21
+ const key = path.join(directory, `${name}.key`);
22
+ const cert = path.join(directory, `${name}.crt`);
23
+ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', key, '-out', cert,
24
+ '-subj', `/CN=${name}`, '-days', '1', '-set_serial', String(serial)], { stdio: 'ignore' });
25
+ pems[name] = { key: fs.readFileSync(key), cert: fs.readFileSync(cert) };
26
+ }
27
+ fs.rmSync(directory, { recursive: true, force: true });
28
+ });
29
+
30
+ const sni = (servername, callback) => callback(null, tls.createSecureContext(pems[servername] || pems['one.test']));
31
+
32
+ async function listen(t, server) {
33
+ t.after(() => new Promise(resolve => {
34
+ server.closeAllConnections?.();
35
+ server.close(() => resolve());
36
+ }));
37
+ await new Promise((resolve, reject) => {
38
+ server.once('error', reject);
39
+ server.listen(0, '127.0.0.1', resolve);
40
+ });
41
+ return server.address().port;
42
+ }
43
+
44
+ function get(port, servername, agent = false) {
45
+ return new Promise((resolve, reject) => {
46
+ https.get({ hostname: '127.0.0.1', port, servername, rejectUnauthorized: false, agent }, res => {
47
+ const serial = res.socket.getPeerCertificate().serialNumber;
48
+ let body = '';
49
+ res.on('data', chunk => { body += chunk; });
50
+ res.on('end', () => resolve({ serial, body }));
51
+ }).on('error', reject);
52
+ });
53
+ }
54
+
55
+ it('reports the runtime it is loaded in', () => {
56
+ assert.equal(Roster.isBunRuntime, typeof process.versions.bun === 'string');
57
+ });
58
+
59
+ it('selects the certificate of each host through SNICallback', async t => {
60
+ const server = createHttpsServer({ ...pems['one.test'], SNICallback: sni },
61
+ (req, res) => res.end(`${req.socket.encrypted} ${req.socket.servername}`));
62
+ const port = await listen(t, server);
63
+ assert.equal(server.listening, true);
64
+ assert.deepEqual(await get(port, 'one.test'), { serial: '01', body: 'true one.test' });
65
+ assert.deepEqual(await get(port, 'two.test'), { serial: '02', body: 'true two.test' });
66
+ });
67
+
68
+ it('replaces the default certificate with setSecureContext', async t => {
69
+ const server = createHttpsServer(pems['one.test'], (req, res) => res.end('ok'));
70
+ const port = await listen(t, server);
71
+ assert.equal((await get(port, '')).serial, '01');
72
+ server.setSecureContext(pems['two.test']);
73
+ assert.equal((await get(port, '')).serial, '02');
74
+ });
75
+
76
+ it('terminates TLS on a raw socket handed over after its ClientHello was read', async t => {
77
+ const server = createHttpsServer({ SNICallback: sni }, (req, res) => res.end('adopted'));
78
+ const front = net.createServer(socket => {
79
+ let head = Buffer.alloc(0);
80
+ const read = chunk => {
81
+ head = Buffer.concat([head, chunk]);
82
+ if (parseClientHello(head) === undefined) return;
83
+ socket.removeListener('data', read);
84
+ socket.pause();
85
+ socket.unshift(head);
86
+ acceptTlsConnection(server, socket);
87
+ };
88
+ socket.on('data', read);
89
+ });
90
+ const port = await listen(t, front);
91
+ assert.deepEqual(await get(port, 'two.test'), { serial: '02', body: 'adopted' });
92
+ });
93
+
94
+ it('keeps connections alive between requests and closes them once idle', async t => {
95
+ const server = createHttpsServer({ SNICallback: sni }, (req, res) => res.end(req.url));
96
+ server.keepAliveTimeout = 300;
97
+ let connections = 0;
98
+ server.on('connection', () => connections++);
99
+ const port = await listen(t, server);
100
+ const { bodies, idleMs } = await new Promise((resolve, reject) => {
101
+ const socket = tls.connect({ host: '127.0.0.1', port, servername: 'one.test', rejectUnauthorized: false });
102
+ const send = pathname => socket.write(`GET ${pathname} HTTP/1.1\r\nHost: one.test\r\nConnection: keep-alive\r\n\r\n`);
103
+ const bodies = [];
104
+ let buffer = '';
105
+ let answeredAt = null;
106
+ socket.once('secureConnect', () => send('/first'));
107
+ socket.on('data', data => {
108
+ buffer += data;
109
+ const responses = buffer.split('HTTP/1.1 ').slice(1).map(response => response.split('\r\n\r\n')[1]).filter(Boolean);
110
+ if (responses.length === 1 && bodies.length === 0) {
111
+ bodies.push(responses[0]);
112
+ send('/second');
113
+ } else if (responses.length === 2 && bodies.length === 1) {
114
+ bodies.push(responses[1]);
115
+ answeredAt = Date.now();
116
+ }
117
+ });
118
+ socket.on('close', () => resolve({ bodies, idleMs: Date.now() - answeredAt }));
119
+ socket.on('error', reject);
120
+ });
121
+ assert.deepEqual(bodies, ['/first', '/second']);
122
+ assert.equal(connections, 1);
123
+ assert.ok(idleMs >= 250 && idleMs < 2500, `connection stayed open ${idleMs} ms`);
124
+ });
125
+
126
+ it('closes idle connections but lets an active request finish', async t => {
127
+ let release;
128
+ const server = createHttpsServer({ SNICallback: sni }, (req, res) => {
129
+ if (req.url === '/slow') release = () => res.end('slow');
130
+ else res.end('fast');
131
+ });
132
+ const port = await listen(t, server);
133
+ const agent = new https.Agent({ keepAlive: true, rejectUnauthorized: false });
134
+ t.after(() => agent.destroy());
135
+ await get(port, 'one.test', agent);
136
+ const idleSocket = Object.values(agent.freeSockets).flat()[0];
137
+ const idleClosed = new Promise(resolve => idleSocket.once('close', resolve));
138
+ const slow = new Promise((resolve, reject) => {
139
+ https.get({ hostname: '127.0.0.1', port, path: '/slow', servername: 'one.test', rejectUnauthorized: false, agent: false },
140
+ res => { let body = ''; res.on('data', chunk => { body += chunk; }); res.on('end', () => resolve(body)); })
141
+ .on('error', reject);
142
+ });
143
+ while (!release) await new Promise(resolve => setTimeout(resolve, 10));
144
+ server.closeIdleConnections();
145
+ await idleClosed;
146
+ release();
147
+ assert.equal(await slow, 'slow');
148
+ });
149
+
150
+ it('delivers upgrade requests with their socket', async t => {
151
+ const server = createHttpsServer({ SNICallback: sni }, (req, res) => res.end('not upgraded'));
152
+ server.on('upgrade', (req, socket) => {
153
+ socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: echo\r\nConnection: Upgrade\r\n\r\n');
154
+ socket.on('data', data => socket.write(`echo:${data}`));
155
+ });
156
+ server.keepAliveTimeout = 50;
157
+ const port = await listen(t, server);
158
+ const echoed = await new Promise((resolve, reject) => {
159
+ const socket = tls.connect({ host: '127.0.0.1', port, servername: 'one.test', rejectUnauthorized: false }, () => {
160
+ socket.write('GET / HTTP/1.1\r\nHost: one.test\r\nConnection: Upgrade\r\nUpgrade: echo\r\n\r\n');
161
+ });
162
+ let buffer = '';
163
+ socket.on('data', data => {
164
+ buffer += data;
165
+ if (buffer.includes('\r\n\r\n') && !buffer.includes('echo:')) setTimeout(() => socket.write('ping'), 200);
166
+ if (buffer.includes('echo:ping')) {
167
+ socket.destroy();
168
+ resolve(buffer.split('\r\n\r\n')[1]);
169
+ }
170
+ });
171
+ socket.on('error', reject);
172
+ });
173
+ assert.equal(echoed, 'echo:ping');
174
+ });
175
+
176
+ it('reports listening, address and close like a Node server', async t => {
177
+ const server = createHttpsServer({ SNICallback: sni }, (req, res) => res.end('ok'));
178
+ const events = [];
179
+ server.on('listening', () => events.push('listening'));
180
+ server.on('close', () => events.push('close'));
181
+ assert.equal(server.listening, false);
182
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
183
+ assert.equal(server.address().address, '127.0.0.1');
184
+ await new Promise(resolve => server.close(resolve));
185
+ assert.equal(server.listening, false);
186
+ assert.deepEqual(events, ['listening', 'close']);
187
+ const error = await new Promise(resolve => server.close(resolve));
188
+ assert.equal(error?.code, 'ERR_SERVER_NOT_RUNNING');
189
+ });
@@ -13,6 +13,7 @@ const { once } = require('node:events');
13
13
  const { execFileSync, spawn } = require('node:child_process');
14
14
  const Roster = require('../index.js');
15
15
  const { parseClientHello } = require('../lib/client-hello.js');
16
+ const { createHttpsServer } = require('../lib/https-server.js');
16
17
  const { createScannerBlocker } = require('../plugins/scanner-blocker.js');
17
18
  const shim = require('../vendor/greenlock-express/greenlock-shim.js');
18
19
  const greenlock = require('../vendor/greenlock-express/greenlock-express.js');
@@ -64,7 +65,7 @@ async function startProduction(t, sites, { certificates = [], issue = async () =
64
65
  ready(callback) {
65
66
  setImmediate(() => callback({
66
67
  httpServer: () => http.createServer(),
67
- httpsServer: (options, handler) => https.createServer({ ...options, SNICallback: roster.sniCallback() }, handler)
68
+ httpsServer: (options, handler) => createHttpsServer({ ...options, SNICallback: roster.sniCallback() }, handler)
68
69
  }));
69
70
  return this;
70
71
  }
@@ -410,6 +411,7 @@ it('closes isolated sites when the main process dies without closing', { timeout
410
411
 
411
412
  function socketSite(server, marker, start = 'async start() {}') {
412
413
  return `const fs = require('fs');
414
+ const { createHttpsServer, acceptTlsConnection } = require(${JSON.stringify(path.join(__dirname, '..', 'index.js'))});
413
415
  let server = ${server};
414
416
  module.exports = {
415
417
  ${start},
@@ -417,7 +419,7 @@ module.exports = {
417
419
  fs.writeFileSync(${JSON.stringify(marker)} + '.head', String(head.length));
418
420
  // A ClientHello read by Roster goes back on the stream; a local connection arrives paused.
419
421
  if (head.length > 0) socket.unshift(head);
420
- server.emit('connection', socket);
422
+ acceptTlsConnection(server, socket);
421
423
  if (head.length === 0) socket.resume();
422
424
  },
423
425
  reply(req, res) {
@@ -435,7 +437,7 @@ it('hands raw client sockets with their ClientHello to a socket site that termin
435
437
  // The site gets its certificate from Roster's store through start(), not from files it reads.
436
438
  const start = `async start({ certificate }) {
437
439
  const pems = await certificate('raw.example.com');
438
- server = require('https').createServer(pems, (req, res) => module.exports.reply(req, res));
440
+ server = createHttpsServer(pems, (req, res) => module.exports.reply(req, res));
439
441
  }`;
440
442
  const { roster, port } = await startProduction(t, {
441
443
  'raw.example.com': socketSite('null', marker, start),
@@ -458,7 +460,7 @@ it('issues a missing certificate through the main process when a socket site ask
458
460
  const store = path.join(path.dirname(marker), 'greenlock.d');
459
461
  const issued = [];
460
462
  const start = `async start({ certificate }) {
461
- server = require('https').createServer(await certificate('fresh.example.com'), (req, res) => module.exports.reply(req, res));
463
+ server = createHttpsServer(await certificate('fresh.example.com'), (req, res) => module.exports.reply(req, res));
462
464
  }`;
463
465
  const { port } = await startProduction(t, { 'fresh.example.com': socketSite('null', marker, start) }, {
464
466
  options: { greenlockStorePath: store },
@@ -337,7 +337,11 @@ it('preserves initialization and cleanup errors when both fail', async t => {
337
337
  });
338
338
  });
339
339
 
340
- it('cancels a pending listen without opening a port when DNS resolves after shutdown', { timeout: 5000 }, async t => {
340
+ it('cancels a pending listen without opening a port when DNS resolves after shutdown', {
341
+ timeout: 5000,
342
+ // Bun resolves listen() hostnames natively, so mocking node:dns cannot hold the lookup open.
343
+ skip: Roster.isBunRuntime && 'Bun listen() does not use node:dns.lookup'
344
+ }, async t => {
341
345
  const dns = require('node:dns');
342
346
  let resolveLookup;
343
347
  t.mock.method(dns, 'lookup', (hostname, options, callback) => { resolveLookup = callback; });
@@ -372,6 +372,14 @@ describe('Roster', () => {
372
372
  });
373
373
  });
374
374
 
375
+ describe('local site description', () => {
376
+ it('names the production port only when it differs from the default', () => {
377
+ const roster = new Roster({ local: true });
378
+ assert.strictEqual(roster._describeLocalSite('truco.example.com', 443, 6701, 'http'), 'truco.example.com → http://truco.localhost:6701');
379
+ assert.strictEqual(roster._describeLocalSite('truco.example.com', 3334, 6702, 'tcp'), 'truco.example.com:3334 → tcp://truco.localhost:6702');
380
+ });
381
+ });
382
+
375
383
  describe('getUrl (exact domain)', () => {
376
384
  it('returns http://localhost:PORT in local mode for registered domain', () => {
377
385
  const roster = new Roster({ local: true });
@@ -52,7 +52,8 @@ Servers.create = function(greenlock, onError) {
52
52
  };
53
53
  servers.httpsServer = function(secureOpts, defaultApp) {
54
54
  return servers._httpsServer(secureOpts, defaultApp, function(secureOpts, fn) {
55
- return require("https").createServer(secureOpts, fn);
55
+ // Roster's factory keeps per-host SNI working on Bun, whose https.Server ignores SNICallback.
56
+ return require("../../lib/https-server.js").createHttpsServer(secureOpts, fn);
56
57
  });
57
58
  };
58
59
  servers._httpsServer = function(secureOpts, defaultApp, createSecureServer) {