@johnhenry/wsh 0.22.0 → 0.23.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
@@ -155,7 +155,7 @@ hosts do). Pass `primer: false` to never send it.
155
155
  ## Node Server
156
156
 
157
157
  `@johnhenry/wsh/server` is a Node host for the same protocol, over WebSocket
158
- (QMux). It is a separate subpath -- the package root stays browser-safe and
158
+ (QMux) and, optionally, [WebTransport](#webtransport-listener). It is a separate subpath -- the package root stays browser-safe and
159
159
  never imports it -- and needs the optional peer dependency `ws`
160
160
  (`npm install ws`).
161
161
 
@@ -185,7 +185,50 @@ restricted host with no shell. `fs.root` confines every path: `..`, absolute
185
185
  paths and symlinks pointing out of it are refused -- for `write` and `rename`
186
186
  (both paths) exactly as for `read`.
187
187
 
188
- Not implemented (tracking issue): WebTransport.
188
+ ### WebTransport listener
189
+
190
+ The client's ladder prefers WebTransport (`https://`) and falls back to `wss://`;
191
+ `webTransport` gives the Node host the first rung, with real independent QUIC
192
+ streams (no head-of-line blocking across channels):
193
+
194
+ ```js
195
+ const server = createWshServer({
196
+ auth, exec: true, fs: { root: '/srv/share' },
197
+ webTransport: { port: 4433, selfSigned: true }, // or { cert, privKey } PEM
198
+ });
199
+ await server.listen(); // the WebSocket listener still runs
200
+ const { url, certificateHash } = server.webTransport(); // https://127.0.0.1:4433/wsh, sha-256 of the cert
201
+
202
+ // client (a browser, or Node with a WebTransport implementation)
203
+ await client.connect(url, {
204
+ username, keyPair, transport: 'wt',
205
+ webTransport: { serverCertificateHashes: [{ algorithm: 'sha-256', value: certificateHash }] },
206
+ });
207
+ ```
208
+
209
+ - **Optional peers**, imported only when `webTransport` is set:
210
+ `npm install @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche`
211
+ (a native HTTP/3 build, prebuilt for Linux/macOS/Windows on x64 and arm).
212
+ Without them `listen()` rejects with that instruction, and a server with no `webTransport`
213
+ never loads them.
214
+ - **Same host, other wire.** Auth (keys, password, host key), `exec`, `pty`, `fs`, `mcp`,
215
+ `sessions` and `relay` behave identically; only the transport differs. The control stream is the
216
+ session's first bidirectional stream (length-prefixed CBOR frames); every bidirectional stream the client
217
+ opens afterwards is a data stream, bound to the next exec channel in `OpenOk` order. A client-created
218
+ stream is visible to the host as soon as it exists, so the host advertises `stream-announce` and the
219
+ client writes no primer.
220
+ - **`selfSigned: true`** generates (no dependency) an ECDSA P-256 certificate valid for 13 days -- a browser refuses a
221
+ pinned certificate valid for more than 14 -- and exposes the SHA-256 for `serverCertificateHashes`
222
+ (`{ selfSigned: { hosts, validityDays } }` to change the SANs or the lifetime). **It is not renewed:**
223
+ restart before `notAfter`, and re-pin. A certificate you pass as `cert`/`privKey` is used as is
224
+ (`certificateHash` is then `null`). `generateSelfSignedCertificate()` is exported for the same
225
+ certificate without a server. The web platform's other pinning rules apply (`https:` URL, HTTP/3 only; see
226
+ [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)).
227
+ - Node has no `WebTransport` global: a Node *client* needs `globalThis.WebTransport = (await import(
228
+ '@fails-components/webtransport')).WebTransport` first. UDP, so a firewall must allow `port`, and there
229
+ is no `wss://`-style TLS terminator in front: the certificate is the one this process serves.
230
+ - Tests (`test/server-webtransport.test.mjs`) run the stock client against it over a real HTTP/3
231
+ connection and are **skipped, with the reason, where the native binary cannot load**.
189
232
 
190
233
  ### Relay / reverse mode
191
234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -24,9 +24,17 @@
24
24
  "@noble/post-quantum": "^0.7.0"
25
25
  },
26
26
  "peerDependencies": {
27
+ "@fails-components/webtransport": "^1.6.4",
28
+ "@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
27
29
  "ws": "^8.0.0"
28
30
  },
29
31
  "peerDependenciesMeta": {
32
+ "@fails-components/webtransport": {
33
+ "optional": true
34
+ },
35
+ "@fails-components/webtransport-transport-http3-quiche": {
36
+ "optional": true
37
+ },
30
38
  "ws": {
31
39
  "optional": true
32
40
  }
@@ -200,6 +200,55 @@ export interface WshServerRelayOptions {
200
200
  connectTimeoutMs?: number;
201
201
  }
202
202
 
203
+ export interface WshServerWebTransportOptions {
204
+ /** UDP port for HTTP/3 (default 0 = pick a free one; read it back from `webTransport()`). */
205
+ port?: number;
206
+ /** Bind address (default: the server's `host`). */
207
+ host?: string;
208
+ /** URL path sessions are accepted on (default `/wsh`). */
209
+ path?: string;
210
+ /** PEM certificate chain. With `privKey`. Not together with `selfSigned`. */
211
+ cert?: string;
212
+ /** PEM private key for `cert`. */
213
+ privKey?: string;
214
+ /**
215
+ * Generate a short-lived ECDSA P-256 certificate (13 days; browsers refuse a pinned one valid for more than 14) and
216
+ * expose its SHA-256 as `webTransport().certificateHash`, for the client's `serverCertificateHashes`. It is not
217
+ * renewed: restart the server before `notAfter`. An object takes `{ hosts, validityDays }`.
218
+ */
219
+ selfSigned?: boolean | { hosts?: string[]; validityDays?: number };
220
+ /** QUIC stateless-reset secret (default: random). */
221
+ secret?: string;
222
+ }
223
+
224
+ export interface WshServerWebTransport {
225
+ port: number;
226
+ host: string;
227
+ path: string;
228
+ /** `https://host:port/path`, what a client connects to. */
229
+ url: string;
230
+ /** SHA-256 of the DER certificate, when `selfSigned` made it; `null` for a certificate you supplied. */
231
+ certificateHash: Uint8Array | null;
232
+ certificateHashHex: string | null;
233
+ /** When the `selfSigned` certificate expires; `null` otherwise. */
234
+ notAfter: Date | null;
235
+ }
236
+
237
+ export interface WshSelfSignedCertificate {
238
+ /** PEM certificate. */
239
+ cert: string;
240
+ /** PEM (PKCS#8) private key. */
241
+ privKey: string;
242
+ /** SHA-256 of the DER certificate: the value to pin. */
243
+ hash: Uint8Array;
244
+ hashHex: string;
245
+ notBefore: Date;
246
+ notAfter: Date;
247
+ }
248
+
249
+ /** A dependency-free self-signed ECDSA P-256 certificate fit for WebTransport's `serverCertificateHashes`. */
250
+ export function generateSelfSignedCertificate(opts?: { hosts?: string[]; validityDays?: number; now?: Date }): WshSelfSignedCertificate;
251
+
203
252
  export interface WshServerOptions {
204
253
  /** Bind address (default `127.0.0.1`). */
205
254
  host?: string;
@@ -233,6 +282,12 @@ export interface WshServerOptions {
233
282
  * Default deny -- give both `canRegister` and `canConnect`. Off by default.
234
283
  */
235
284
  relay?: WshServerRelayOptions;
285
+ /**
286
+ * Also listen for WebTransport (HTTP/3 over UDP) clients -- the same auth, exec, pty, fs, mcp, sessions and relay as the
287
+ * WebSocket listener, with real independent streams. Needs the optional peers `@fails-components/webtransport` and
288
+ * `@fails-components/webtransport-transport-http3-quiche`, imported only when this is set. Off by default.
289
+ */
290
+ webTransport?: WshServerWebTransportOptions;
236
291
  /** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
237
292
  mcp?: WshServerMcpOptions;
238
293
  /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
@@ -252,6 +307,8 @@ export interface WshServer {
252
307
  close(): Promise<void>;
253
308
  /** The bound address, or `null` when not listening. */
254
309
  address(): WshServerAddress | null;
310
+ /** The WebTransport listener; `null` when not configured or before `listen()` resolves. */
311
+ webTransport(): WshServerWebTransport | null;
255
312
  /** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
256
313
  peerFingerprints(): string[];
257
314
  /** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
@@ -13,10 +13,12 @@ import { loadHostKey } from './host-key.mjs';
13
13
  import { buildBackends } from './backends.mjs';
14
14
  import { SessionRegistry } from './sessions.mjs';
15
15
  import { RelayHub } from './relay.mjs';
16
+ import { startWebTransport } from './webtransport.mjs';
16
17
  import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
17
18
 
18
19
  export { parseAuthorizedKeys, STREAM_ANNOUNCE };
19
20
  export { createReverseHost } from './reverse-host.mjs';
21
+ export { generateSelfSignedCertificate } from './self-signed.mjs';
20
22
 
21
23
  /**
22
24
  * @param {object} [options]
@@ -30,6 +32,11 @@ export { createReverseHost } from './reverse-host.mjs';
30
32
  * @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
31
33
  * MCP tools served over `McpDiscover` / `McpCall`: `tools` (`{ name, description, inputSchema, call(args, { user, signal }) }`),
32
34
  * and/or `client` (an `@modelcontextprotocol/sdk` Client to proxy). Off unless given.
35
+ * @param {{ port?: number, host?: string, path?: string, cert?: string, privKey?: string, selfSigned?: boolean | object, secret?: string }} [options.webTransport]
36
+ * Also listen for WebTransport (HTTP/3 over UDP) clients, with the same auth and features as the WebSocket
37
+ * listener. `cert` / `privKey` are PEM; `selfSigned: true` makes a 13-day ECDSA P-256 certificate and exposes its
38
+ * SHA-256 through `webTransport()` for the client's `serverCertificateHashes`. Needs the optional peers
39
+ * `@fails-components/webtransport` and `@fails-components/webtransport-transport-http3-quiche`. Off unless given.
33
40
  * @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, connectTimeoutMs?: number }} [options.relay]
34
41
  * Act as a relay: peers register (`ReverseRegister`, a signed record), operators list and connect to them
35
42
  * (`ReverseList` / `ReverseConnect`) and traffic is carried between them as `RelayForward`. Default deny:
@@ -48,7 +55,7 @@ export { createReverseHost } from './reverse-host.mjs';
48
55
  * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
49
56
  */
50
57
  export function createWshServer({
51
- host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, relay, bindTimeoutMs = 3000, onLog = () => {},
58
+ host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
52
59
  } = {}) {
53
60
  let wss = null;
54
61
  let bound = null;
@@ -64,6 +71,7 @@ export function createWshServer({
64
71
  let host_ = null;
65
72
  let registry = null;
66
73
  let hub = null;
74
+ let wt = null;
67
75
  const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
68
76
 
69
77
  return {
@@ -113,6 +121,14 @@ export function createWshServer({
113
121
  });
114
122
  const a = wss.address();
115
123
  bound = { address: a.address, port: a.port };
124
+ if (webTransport) {
125
+ try {
126
+ wt = await startWebTransport({ options: webTransport, host, attach, log: onLog });
127
+ } catch (err) {
128
+ await this.close();
129
+ throw err;
130
+ }
131
+ }
116
132
  return bound;
117
133
  },
118
134
 
@@ -122,6 +138,8 @@ export function createWshServer({
122
138
  wss = null;
123
139
  bound = null;
124
140
  for (const client of server.clients) client.terminate();
141
+ await wt?.close();
142
+ wt = null;
125
143
  await new Promise((resolve) => server.close(resolve));
126
144
  registry?.closeAll();
127
145
  registry = null;
@@ -133,6 +151,18 @@ export function createWshServer({
133
151
  return bound;
134
152
  },
135
153
 
154
+ /**
155
+ * The WebTransport listener, or `null` (not configured, or `listen()` has not resolved). `certificateHash` is
156
+ * the SHA-256 of a `selfSigned` certificate, for the client's `serverCertificateHashes`; `null` for your own.
157
+ */
158
+ webTransport() {
159
+ if (!wt) return null;
160
+ return {
161
+ port: wt.port, host: wt.host, path: wt.path, url: `https://${wt.host.includes(':') ? `[${wt.host}]` : wt.host}:${wt.port}${wt.path}`,
162
+ certificateHash: wt.certificateHash, certificateHashHex: wt.certificateHashHex, notAfter: wt.notAfter,
163
+ };
164
+ },
165
+
136
166
  /** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
137
167
  peerFingerprints() {
138
168
  return hub ? hub.peerFingerprints() : [];
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A short-lived self-signed certificate for WebTransport's
3
+ * `serverCertificateHashes` pinning, with no dependency: the X.509 structure
4
+ * is small enough to assemble as DER by hand, and `node:crypto` signs it.
5
+ *
6
+ * The web platform's rules for a pinned certificate (what a browser enforces):
7
+ * an ECDSA P-256 key, a validity period of at most 14 days, and the page pins
8
+ * the SHA-256 of the DER certificate. Nothing else about it is checked (no
9
+ * chain, and the name is not matched), but the subject alternative names are
10
+ * filled in anyway so the same certificate also works for a client that does.
11
+ */
12
+
13
+ import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
14
+ import net from 'node:net';
15
+
16
+ /** What a browser allows for `serverCertificateHashes`. */
17
+ export const MAX_PINNED_CERT_DAYS = 14;
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+
20
+ const OID = {
21
+ ecdsaWithSha256: '2a8648ce3d040302',
22
+ commonName: '550403',
23
+ subjectAltName: '551d11',
24
+ extKeyUsage: '551d25',
25
+ serverAuth: '2b06010505070301',
26
+ keyUsage: '551d0f',
27
+ };
28
+
29
+ // ── DER ──────────────────────────────────────────────────────────────
30
+
31
+ const concat = (...parts) => Buffer.concat(parts.map((p) => (Buffer.isBuffer(p) ? p : Buffer.from(p))));
32
+ function der(tag, ...content) {
33
+ const body = concat(...content);
34
+ const n = body.length;
35
+ const len = n < 0x80 ? Buffer.from([n]) : n < 0x100 ? Buffer.from([0x81, n]) : Buffer.from([0x82, n >> 8, n & 0xff]);
36
+ return concat(Buffer.from([tag]), len, body);
37
+ }
38
+ const seq = (...c) => der(0x30, ...c);
39
+ const set = (...c) => der(0x31, ...c);
40
+ const oid = (hex) => der(0x06, Buffer.from(hex, 'hex'));
41
+ const utf8 = (s) => der(0x0c, Buffer.from(s, 'utf8'));
42
+ const octets = (b) => der(0x04, b);
43
+ const bitString = (b) => der(0x03, Buffer.from([0]), b);
44
+ /** Positive INTEGER from big-endian bytes. */
45
+ function integer(bytes) {
46
+ let b = Buffer.from(bytes);
47
+ while (b.length > 1 && b[0] === 0) b = b.subarray(1);
48
+ if (b[0] & 0x80) b = concat(Buffer.from([0]), b);
49
+ return der(0x02, b);
50
+ }
51
+ function utcTime(date) {
52
+ const p = (n) => String(n).padStart(2, '0');
53
+ return der(0x17, Buffer.from(`${p(date.getUTCFullYear() % 100)}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z`, 'ascii'));
54
+ }
55
+
56
+ function ipBytes(addr) {
57
+ if (net.isIPv4(addr)) return Buffer.from(addr.split('.').map(Number));
58
+ // IPv6, possibly with "::" compression
59
+ const [head, tail = ''] = addr.split('::');
60
+ const h = head ? head.split(':') : [];
61
+ const t = tail ? tail.split(':') : [];
62
+ const groups = [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
63
+ return Buffer.from(groups.flatMap((g) => { const v = parseInt(g || '0', 16); return [v >> 8, v & 0xff]; }));
64
+ }
65
+
66
+ function pem(label, derBytes) {
67
+ const b64 = derBytes.toString('base64').match(/.{1,64}/g).join('\n');
68
+ return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`;
69
+ }
70
+
71
+ /**
72
+ * @param {object} [opts]
73
+ * @param {string[]} [opts.hosts=['localhost', '127.0.0.1', '::1']] - DNS names and IP addresses for the SAN.
74
+ * @param {number} [opts.validityDays=13] - At most 14 (a browser refuses a pinned certificate valid for longer).
75
+ * @param {Date} [opts.now]
76
+ * @returns {{ cert: string, privKey: string, hash: Uint8Array, hashHex: string, notBefore: Date, notAfter: Date }}
77
+ * `cert` / `privKey` are PEM; `hash` is the SHA-256 of the DER certificate, the value to pin.
78
+ */
79
+ export function generateSelfSignedCertificate({ hosts = ['localhost', '127.0.0.1', '::1'], validityDays = 13, now = new Date() } = {}) {
80
+ if (!(validityDays > 0) || validityDays > MAX_PINNED_CERT_DAYS) {
81
+ throw new RangeError(`selfSigned: validityDays must be in (0, ${MAX_PINNED_CERT_DAYS}] -- browsers refuse a pinned certificate valid for longer`);
82
+ }
83
+ const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
84
+ // An hour of back-dating tolerates clock skew; the whole window stays under 14 days for validityDays <= 13.
85
+ const skewMs = Math.min(60 * 60 * 1000, (MAX_PINNED_CERT_DAYS - validityDays) * DAY_MS);
86
+ const notBefore = new Date(Math.floor((now.getTime() - skewMs) / 1000) * 1000);
87
+ const notAfter = new Date(Math.floor((now.getTime() + validityDays * DAY_MS - skewMs) / 1000) * 1000);
88
+
89
+ const name = seq(set(seq(oid(OID.commonName), utf8('wsh self-signed'))));
90
+ const san = seq(...hosts.map((h) => (net.isIP(h) ? der(0x87, ipBytes(h)) : der(0x82, Buffer.from(h, 'ascii')))));
91
+ const extensions = der(0xa3, seq(
92
+ seq(oid(OID.keyUsage), der(0x01, Buffer.from([0xff])), octets(bitString(Buffer.from([0x80])))), // digitalSignature
93
+ seq(oid(OID.extKeyUsage), octets(seq(oid(OID.serverAuth)))),
94
+ seq(oid(OID.subjectAltName), octets(san)),
95
+ ));
96
+ const serial = randomBytes(16);
97
+ serial[0] &= 0x7f;
98
+ serial[0] |= 0x01;
99
+
100
+ const tbs = seq(
101
+ der(0xa0, integer([2])), // v3
102
+ integer(serial),
103
+ seq(oid(OID.ecdsaWithSha256)),
104
+ name,
105
+ seq(utcTime(notBefore), utcTime(notAfter)),
106
+ name,
107
+ publicKey.export({ type: 'spki', format: 'der' }),
108
+ extensions,
109
+ );
110
+ const signature = sign('sha256', tbs, privateKey); // DER-encoded ECDSA-Sig-Value
111
+ const certDer = seq(tbs, seq(oid(OID.ecdsaWithSha256)), bitString(signature));
112
+ const hash = new Uint8Array(createHash('sha256').update(certDer).digest());
113
+ return {
114
+ cert: pem('CERTIFICATE', certDer),
115
+ privKey: privateKey.export({ type: 'pkcs8', format: 'pem' }),
116
+ hash,
117
+ hashHex: Buffer.from(hash).toString('hex'),
118
+ notBefore,
119
+ notAfter,
120
+ };
121
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * WebTransport listener for `@johnhenry/wsh/server` (`createWshServer({ webTransport })`).
3
+ *
4
+ * QUIC already multiplexes, so the QMux layer the WebSocket path needs is not
5
+ * used. A session's FIRST bidirectional stream is the control stream (the
6
+ * client's `WebTransportTransport` opens it first): length-prefixed CBOR
7
+ * frames, exactly what `frameEncode` / `FrameDecoder` speak. Every later
8
+ * bidirectional stream the client opens is a data stream, bound to the next
9
+ * exec channel in `OpenOk` order like a QMux one.
10
+ *
11
+ * Streams a client creates are visible to the host the moment they are
12
+ * created (HTTP/3 sends the stream's WebTransport header with it), so the
13
+ * host's `stream-announce` feature holds here as it does over QMux: the
14
+ * client writes no primer.
15
+ *
16
+ * `@fails-components/webtransport` (and its native HTTP/3 transport package)
17
+ * are optional peers, imported only when `webTransport` is given.
18
+ */
19
+
20
+ import { randomBytes } from 'node:crypto';
21
+ import { FrameDecoder, frameEncode } from '../cbor.mjs';
22
+ import { generateSelfSignedCertificate } from './self-signed.mjs';
23
+
24
+ const DEFAULT_PATH = '/wsh';
25
+
26
+ /** What a QMux stream looks like to the connection code, over a WebTransport bidirectional stream. */
27
+ function adaptStream({ readable, writable }, onGone) {
28
+ const writer = writable.getWriter();
29
+ const reader = readable.getReader();
30
+ const stream = {
31
+ onData: null,
32
+ onEnd: null,
33
+ onReset: null,
34
+ write: (bytes) => writer.write(bytes),
35
+ close: async () => { try { await writer.close(); } catch { /* peer gone or already closed */ } },
36
+ /** Start delivering: after the consumer has had the chance to set its handlers. */
37
+ start() {
38
+ (async () => {
39
+ try {
40
+ for (;;) {
41
+ const { done, value } = await reader.read();
42
+ if (done) { stream.onEnd?.(); return; }
43
+ stream.onData?.(value);
44
+ }
45
+ } catch {
46
+ stream.onReset?.();
47
+ } finally {
48
+ onGone?.();
49
+ }
50
+ })();
51
+ },
52
+ };
53
+ return stream;
54
+ }
55
+
56
+ /**
57
+ * @param {object} opts
58
+ * @param {object} opts.options - `createWshServer`'s `webTransport` option
59
+ * @param {string} opts.host
60
+ * @param {(opts: object) => { receiveMessage: Function, bindStream: Function, close: Function }} opts.attach
61
+ * @param {(line: string) => void} opts.log
62
+ * @returns {Promise<{ port: number, host: string, path: string, certificateHash: Uint8Array | null, certificateHashHex: string | null, notAfter: Date | null, close(): Promise<void> }>}
63
+ */
64
+ export async function startWebTransport({ options, host, attach, log }) {
65
+ const { port = 0, path = DEFAULT_PATH, selfSigned = false } = options;
66
+ let { cert, privKey } = options;
67
+ let generated = null;
68
+ if (selfSigned) {
69
+ if (cert || privKey) throw new TypeError('createWshServer: webTransport takes either selfSigned or cert/privKey, not both');
70
+ generated = generateSelfSignedCertificate(typeof selfSigned === 'object' ? selfSigned : {});
71
+ ({ cert, privKey } = generated);
72
+ }
73
+ if (!cert || !privKey) throw new TypeError('createWshServer: webTransport needs cert and privKey (PEM), or selfSigned: true');
74
+ if (typeof path !== 'string' || !path.startsWith('/')) throw new TypeError('createWshServer: webTransport.path must start with "/"');
75
+
76
+ let wtLib;
77
+ try {
78
+ wtLib = await import('@fails-components/webtransport');
79
+ await wtLib.quicheLoaded;
80
+ } catch (err) {
81
+ throw new Error(
82
+ 'WebTransport needs the optional packages "@fails-components/webtransport" and '
83
+ + '"@fails-components/webtransport-transport-http3-quiche": npm install @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche',
84
+ { cause: err },
85
+ );
86
+ }
87
+
88
+ const server = new wtLib.Http3Server({
89
+ port, host: options.host ?? host, secret: options.secret ?? randomBytes(16).toString('hex'), cert, privKey,
90
+ });
91
+ const sessions = server.sessionStream(path);
92
+ server.startServer();
93
+ try {
94
+ await server.ready;
95
+ } catch (err) {
96
+ try { server.stopServer(); } catch { /* never started */ }
97
+ throw err;
98
+ }
99
+ const addr = server.address();
100
+
101
+ const live = new Set();
102
+ let closing = false;
103
+
104
+ async function serve(session) {
105
+ let conn = null;
106
+ let closed = false;
107
+ const end = () => { if (!closed) { closed = true; live.delete(session); conn?.close(); } };
108
+ live.add(session);
109
+ session.closed.then(end, end);
110
+ try {
111
+ await session.ready;
112
+ } catch { end(); return; }
113
+
114
+ let controlWriter = null;
115
+ const decoder = new FrameDecoder();
116
+ conn = attach({
117
+ sendMessage: (msg) => (controlWriter ? controlWriter.write(frameEncode(msg)) : undefined),
118
+ remote: { address: session.peerAddress, headers: session.header ?? {} },
119
+ closeTransport: () => { try { session.close(); } catch { /* already closed */ } },
120
+ });
121
+
122
+ const incoming = session.incomingBidirectionalStreams.getReader();
123
+ let first = true;
124
+ try {
125
+ for (;;) {
126
+ const { done, value: bidi } = await incoming.read();
127
+ if (done) break;
128
+ if (first) {
129
+ first = false;
130
+ controlWriter = bidi.writable.getWriter();
131
+ const reader = bidi.readable.getReader();
132
+ (async () => {
133
+ try {
134
+ for (;;) {
135
+ const { done: fin, value } = await reader.read();
136
+ if (fin) break;
137
+ let msgs;
138
+ try { msgs = decoder.feed(value); } catch (e) { log(`webtransport frame decode error: ${e.message}`); break; }
139
+ for (const m of msgs) conn.receiveMessage(m);
140
+ }
141
+ } catch { /* session went away */ }
142
+ end();
143
+ })();
144
+ } else {
145
+ const s = adaptStream(bidi);
146
+ conn.bindStream(s);
147
+ s.start();
148
+ }
149
+ }
150
+ } catch { /* session closed under us */ }
151
+ end();
152
+ }
153
+
154
+ (async () => {
155
+ const reader = sessions.getReader();
156
+ try {
157
+ for (;;) {
158
+ const { done, value: session } = await reader.read();
159
+ if (done || closing) break;
160
+ serve(session).catch((e) => log(`webtransport session error: ${e.message}`));
161
+ }
162
+ } catch { /* server stopped */ }
163
+ })();
164
+
165
+ const bound = {
166
+ port: addr.port,
167
+ host: addr.host,
168
+ path,
169
+ certificateHash: generated?.hash ?? null,
170
+ certificateHashHex: generated?.hashHex ?? null,
171
+ notAfter: generated?.notAfter ?? null,
172
+ async close() {
173
+ closing = true;
174
+ const open = [...live];
175
+ for (const s of open) { try { s.close(); } catch { /* gone */ } }
176
+ // Let the close reach the peers before the socket is torn down (bounded: a peer that never acks cannot hold shutdown).
177
+ await Promise.race([
178
+ Promise.allSettled(open.map((s) => s.closed)),
179
+ new Promise((r) => { const t = setTimeout(r, 500); t.unref?.(); }),
180
+ ]);
181
+ live.clear();
182
+ try { server.stopServer(); } catch { /* already stopped */ }
183
+ },
184
+ };
185
+ log(`webtransport listening on https://${addr.host}:${addr.port}${path}`
186
+ + (generated ? ` (self-signed, sha-256 ${generated.hashHex}, until ${generated.notAfter.toISOString()})` : ''));
187
+ return bound;
188
+ }
package/src/transport.mjs CHANGED
@@ -493,6 +493,11 @@ export class WebTransportTransport extends WshTransport {
493
493
  const wt = wtOptions ? new WebTransport(url, wtOptions) : new WebTransport(url);
494
494
  this.#wt = wt;
495
495
 
496
+ // A session that never becomes ready rejects `closed` as well as `ready`. Nothing
497
+ // observes `closed` until #monitorClosed (below, after `ready`), so the second
498
+ // rejection would surface as an unhandled one -- fatal in Node.
499
+ wt.closed?.catch?.(() => {});
500
+
496
501
  // Wait for the connection to be ready.
497
502
  await wt.ready;
498
503