@johnhenry/wsh 0.7.0 → 0.14.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/package.json +4 -1
- package/src/auth.mjs +106 -0
- package/src/client.mjs +549 -189
- package/src/file-transfer.mjs +39 -166
- package/src/index.d.ts +156 -2
- package/src/index.mjs +13 -0
- package/src/messages.gen.mjs +73 -19
- package/src/messages.mjs +1 -1
- package/src/mlkem.mjs +144 -0
- package/src/qmux-connection.mjs +698 -0
- package/src/qmux.mjs +620 -0
- package/src/session.mjs +107 -1
- package/src/transport-ws.mjs +145 -352
package/src/transport-ws.mjs
CHANGED
|
@@ -1,101 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* wsh transport — WebSocket fallback with multiplexed virtual streams.
|
|
3
3
|
*
|
|
4
|
-
* Wire format
|
|
5
|
-
*
|
|
4
|
+
* Wire format: QMux (draft-ietf-quic-qmux-02) framing directly over the
|
|
5
|
+
* WebSocket's binary message stream -- see qmux.mjs for the wire codec
|
|
6
|
+
* and qmux-connection.mjs for the stream state machine and flow control
|
|
7
|
+
* this module wires into the DOM Streams API (ReadableStream/
|
|
8
|
+
* WritableStream) the rest of wsh expects from a transport.
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* Stream 0 (the first client-initiated bidirectional QMux stream) is
|
|
11
|
+
* always opened on connect and carries CBOR control messages using the
|
|
12
|
+
* same length-prefixed framing as before (frameEncode/FrameDecoder) --
|
|
13
|
+
* QMux streams are raw byte pipes with no message-boundary framing of
|
|
14
|
+
* their own, so that inner framing is still needed to know where one
|
|
15
|
+
* control message ends and the next begins. Every other stream (opened
|
|
16
|
+
* via openStream(), or peer-initiated for reverse-mode/server-pushed
|
|
17
|
+
* channels) carries a session's raw data with no extra framing, exactly
|
|
18
|
+
* as before.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
import { frameEncode, FrameDecoder, FrameSizeError } from './cbor.mjs';
|
|
19
22
|
import { WshTransport, dispatchSerially, SerialQueue } from './transport.mjs';
|
|
23
|
+
import { QMuxConnection } from './qmux-connection.mjs';
|
|
20
24
|
|
|
21
|
-
// ── Frame type constants
|
|
25
|
+
// ── Frame type constants (kept for backward-compat callers) ──────────
|
|
22
26
|
//
|
|
23
|
-
//
|
|
24
|
-
// the
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The mux frame-type byte values, exported for anything outside this
|
|
34
|
-
* module that needs to name or inspect them correctly — tooling,
|
|
35
|
-
* logging, or an alternative from-scratch implementation of this
|
|
36
|
-
* transport in another runtime, which needs these exact wire values to
|
|
37
|
-
* interoperate. Not related to `MSG` (messages.gen.mjs): that's the
|
|
38
|
-
* CBOR-layer protocol-message opcode space carried *inside* a
|
|
39
|
-
* FRAME_CONTROL payload, a separate numbering space at a different
|
|
40
|
-
* layer of the stack. (An earlier, since-removed `MSG.WS_DATA` conflated
|
|
41
|
-
* the two and collided with a real MSG opcode — see CHANGELOG 0.6.0.)
|
|
42
|
-
*/
|
|
27
|
+
// The old hand-rolled 5-byte mux's frame-type byte values. wsh now
|
|
28
|
+
// speaks QMux instead (see the file doc comment) -- this export is kept
|
|
29
|
+
// so nothing importing WS_FRAME_TYPE breaks, but nothing in this module
|
|
30
|
+
// produces or consumes these values anymore. Mirrors QMux's own frame
|
|
31
|
+
// type space where it lines up (STREAM/RESET_STREAM), for whatever
|
|
32
|
+
// documentation value that has; DATA/OPEN_STREAM/CLOSE_STREAM have no
|
|
33
|
+
// QMux equivalent (QMux streams are implicitly opened by reference, and
|
|
34
|
+
// "data" is just STREAM frame payload).
|
|
43
35
|
export const WS_FRAME_TYPE = Object.freeze({
|
|
44
|
-
CONTROL:
|
|
45
|
-
DATA:
|
|
46
|
-
OPEN_STREAM:
|
|
47
|
-
CLOSE_STREAM:
|
|
36
|
+
CONTROL: 0x01,
|
|
37
|
+
DATA: 0x02,
|
|
38
|
+
OPEN_STREAM: 0x03,
|
|
39
|
+
CLOSE_STREAM: 0x04,
|
|
48
40
|
});
|
|
49
41
|
|
|
50
|
-
// ──
|
|
51
|
-
|
|
52
|
-
const HEADER_SIZE = 5; // 1 byte type + 4 bytes stream ID
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Build a multiplexing frame.
|
|
56
|
-
* @param {number} type Frame type byte.
|
|
57
|
-
* @param {number} streamId 32-bit stream ID.
|
|
58
|
-
* @param {Uint8Array} [payload] Optional payload bytes.
|
|
59
|
-
* @returns {Uint8Array}
|
|
60
|
-
*/
|
|
61
|
-
function buildFrame(type, streamId, payload) {
|
|
62
|
-
const payloadLen = payload ? payload.byteLength : 0;
|
|
63
|
-
const frame = new Uint8Array(HEADER_SIZE + payloadLen);
|
|
64
|
-
const view = new DataView(frame.buffer);
|
|
65
|
-
view.setUint8(0, type);
|
|
66
|
-
view.setUint32(1, streamId);
|
|
67
|
-
if (payload) {
|
|
68
|
-
frame.set(payload, HEADER_SIZE);
|
|
69
|
-
}
|
|
70
|
-
return frame;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Parse a multiplexing frame header + payload.
|
|
75
|
-
* @param {Uint8Array} data
|
|
76
|
-
* @returns {{ type: number, streamId: number, payload: Uint8Array }}
|
|
77
|
-
*/
|
|
78
|
-
function parseFrame(data) {
|
|
79
|
-
if (data.byteLength < HEADER_SIZE) {
|
|
80
|
-
throw new Error(`Frame too short: ${data.byteLength} bytes`);
|
|
81
|
-
}
|
|
82
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
83
|
-
return {
|
|
84
|
-
type: view.getUint8(0),
|
|
85
|
-
streamId: view.getUint32(1),
|
|
86
|
-
payload: data.subarray(HEADER_SIZE),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ── Virtual stream ───────────────────────────────────────────────────
|
|
42
|
+
// ── DOM Streams adapter over a QMuxStream ───────────────────────────
|
|
91
43
|
|
|
92
44
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* consumers see the same API as native WebTransport streams.
|
|
45
|
+
* Wraps a qmux-connection.mjs `QMuxStream` (a push/pull data API with
|
|
46
|
+
* no DOM dependency) in the ReadableStream/WritableStream pair the rest
|
|
47
|
+
* of wsh expects from a transport stream.
|
|
97
48
|
*/
|
|
98
|
-
class
|
|
49
|
+
class QMuxStreamAdapter {
|
|
99
50
|
/** @type {number} */
|
|
100
51
|
id;
|
|
101
52
|
|
|
@@ -105,98 +56,52 @@ class VirtualStream {
|
|
|
105
56
|
/** @type {WritableStream<Uint8Array>} */
|
|
106
57
|
writable;
|
|
107
58
|
|
|
108
|
-
/** @type {ReadableStreamDefaultController} */
|
|
109
|
-
#readController = null;
|
|
110
|
-
|
|
111
|
-
/** @type {boolean} */
|
|
112
|
-
#readClosed = false;
|
|
113
|
-
|
|
114
|
-
/** @type {boolean} */
|
|
115
|
-
#writeClosed = false;
|
|
116
|
-
|
|
117
59
|
/**
|
|
118
|
-
* @param {
|
|
119
|
-
* @param {function(number, Uint8Array): void} sendData Send data frame callback.
|
|
120
|
-
* @param {function(number): void} sendClose Send close frame callback.
|
|
60
|
+
* @param {import('./qmux-connection.mjs').QMuxConnection} qs
|
|
121
61
|
*/
|
|
122
|
-
constructor(
|
|
123
|
-
this.id = id;
|
|
62
|
+
constructor(qs) {
|
|
63
|
+
this.id = qs.id;
|
|
124
64
|
|
|
125
65
|
this.readable = new ReadableStream({
|
|
126
66
|
start: (controller) => {
|
|
127
|
-
|
|
67
|
+
qs.onData = (data) => {
|
|
68
|
+
try { controller.enqueue(data); } catch { /* controller already closed/errored */ }
|
|
69
|
+
};
|
|
70
|
+
qs.onEnd = () => {
|
|
71
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
72
|
+
};
|
|
73
|
+
qs.onReset = (errorCode) => {
|
|
74
|
+
try { controller.error(new Error(`Stream ${qs.id} was reset by peer (error code ${errorCode})`)); } catch { /* already settled */ }
|
|
75
|
+
};
|
|
76
|
+
qs.onDestroy = (err) => {
|
|
77
|
+
try { controller.error(err); } catch { /* already settled */ }
|
|
78
|
+
};
|
|
128
79
|
},
|
|
129
80
|
cancel: () => {
|
|
130
|
-
|
|
81
|
+
qs.stopSending();
|
|
131
82
|
},
|
|
132
83
|
});
|
|
133
84
|
|
|
134
85
|
this.writable = new WritableStream({
|
|
135
|
-
write: (chunk) => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
const bytes = chunk instanceof Uint8Array
|
|
140
|
-
? chunk
|
|
141
|
-
: new Uint8Array(chunk);
|
|
142
|
-
sendData(id, bytes);
|
|
86
|
+
write: async (chunk) => {
|
|
87
|
+
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
88
|
+
await qs.write(bytes);
|
|
143
89
|
},
|
|
144
|
-
close: () => {
|
|
145
|
-
|
|
146
|
-
sendClose(id);
|
|
90
|
+
close: async () => {
|
|
91
|
+
await qs.close();
|
|
147
92
|
},
|
|
148
93
|
abort: () => {
|
|
149
|
-
|
|
150
|
-
sendClose(id);
|
|
94
|
+
qs.reset();
|
|
151
95
|
},
|
|
152
96
|
});
|
|
153
97
|
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Push inbound data into the readable side.
|
|
157
|
-
* @param {Uint8Array} data
|
|
158
|
-
*/
|
|
159
|
-
_pushData(data) {
|
|
160
|
-
if (this.#readClosed || !this.#readController) return;
|
|
161
|
-
try {
|
|
162
|
-
this.#readController.enqueue(data);
|
|
163
|
-
} catch {
|
|
164
|
-
// Controller may already be closed; ignore.
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Signal the remote closed this stream's readable side.
|
|
170
|
-
*/
|
|
171
|
-
_closeRead() {
|
|
172
|
-
if (this.#readClosed) return;
|
|
173
|
-
this.#readClosed = true;
|
|
174
|
-
try {
|
|
175
|
-
this.#readController?.close();
|
|
176
|
-
} catch {
|
|
177
|
-
// Already closed.
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Forcibly error both sides (used on transport close).
|
|
183
|
-
* @param {Error} err
|
|
184
|
-
*/
|
|
185
|
-
_destroy(err) {
|
|
186
|
-
if (!this.#readClosed) {
|
|
187
|
-
this.#readClosed = true;
|
|
188
|
-
try {
|
|
189
|
-
this.#readController?.error(err);
|
|
190
|
-
} catch { /* ignore */ }
|
|
191
|
-
}
|
|
192
|
-
this.#writeClosed = true;
|
|
193
|
-
}
|
|
194
98
|
}
|
|
195
99
|
|
|
196
100
|
// ── WebSocket transport ──────────────────────────────────────────────
|
|
197
101
|
|
|
198
102
|
/**
|
|
199
|
-
* wsh transport over a single WebSocket with multiplexed virtual
|
|
103
|
+
* wsh transport over a single WebSocket with QMux-multiplexed virtual
|
|
104
|
+
* streams.
|
|
200
105
|
*
|
|
201
106
|
* Provides the same interface as WebTransportTransport so that upper
|
|
202
107
|
* layers (session, client) work identically over either transport.
|
|
@@ -205,42 +110,37 @@ export class WebSocketTransport extends WshTransport {
|
|
|
205
110
|
/** @type {WebSocket} */
|
|
206
111
|
#ws = null;
|
|
207
112
|
|
|
208
|
-
/** @type {
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
/** @type {FrameDecoder} Decoder for inbound control messages on stream 0. */
|
|
212
|
-
#decoder = new FrameDecoder();
|
|
113
|
+
/** @type {import('./qmux-connection.mjs').QMuxConnection} */
|
|
114
|
+
#qmux = null;
|
|
213
115
|
|
|
214
|
-
/**
|
|
215
|
-
#
|
|
116
|
+
/** @type {import('./qmux-connection.mjs').QMuxConnection|null} The control channel (always QMux stream 0). */
|
|
117
|
+
#controlStream = null;
|
|
216
118
|
|
|
217
|
-
/**
|
|
218
|
-
#
|
|
119
|
+
/** @type {FrameDecoder} Decoder for CBOR control messages carried inside the control stream's byte pipe. */
|
|
120
|
+
#decoder = new FrameDecoder();
|
|
219
121
|
|
|
220
122
|
/** Tracks whether we initiated the close. */
|
|
221
123
|
#closedByUs = false;
|
|
222
124
|
|
|
223
125
|
/**
|
|
224
|
-
* Serializes dispatch of
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
126
|
+
* Serializes dispatch of inbound control-stream bytes. Needed because
|
|
127
|
+
* a single WebSocket 'message' event can decode into QMux STREAM
|
|
128
|
+
* frames for the control stream whose data callback fires
|
|
129
|
+
* synchronously and could otherwise interleave with — or race —
|
|
130
|
+
* whatever microtask continuation the *previous* chunk's dispatch
|
|
131
|
+
* triggers. See `SerialQueue`'s doc comment in transport.mjs.
|
|
230
132
|
* @type {SerialQueue}
|
|
231
133
|
*/
|
|
232
|
-
#inbox = new SerialQueue((raw) => this.#
|
|
134
|
+
#inbox = new SerialQueue((raw) => this.#handleControlBytes(raw));
|
|
233
135
|
|
|
234
136
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
235
137
|
|
|
236
138
|
/** @override */
|
|
237
139
|
async _doConnect(url) {
|
|
238
|
-
this.#streams.clear();
|
|
239
140
|
this.#decoder.reset();
|
|
240
|
-
this.#nextLocalId = 1;
|
|
241
|
-
this.#openResolvers.clear();
|
|
242
141
|
this.#closedByUs = false;
|
|
243
142
|
this.#inbox.clear();
|
|
143
|
+
this.#controlStream = null;
|
|
244
144
|
|
|
245
145
|
return new Promise((resolve, reject) => {
|
|
246
146
|
// Normalize URL scheme: wsh:// → wss://, http:// → ws://.
|
|
@@ -253,9 +153,39 @@ export class WebSocketTransport extends WshTransport {
|
|
|
253
153
|
ws.binaryType = 'arraybuffer';
|
|
254
154
|
this.#ws = ws;
|
|
255
155
|
|
|
256
|
-
|
|
156
|
+
const qmux = new QMuxConnection({
|
|
157
|
+
isClient: true,
|
|
158
|
+
send: (bytes) => this.#send(bytes),
|
|
159
|
+
});
|
|
160
|
+
this.#qmux = qmux;
|
|
161
|
+
|
|
162
|
+
qmux.onStreamOpen = (qs) => {
|
|
163
|
+
const adapter = new QMuxStreamAdapter(qs);
|
|
164
|
+
this._emitStreamOpen({ readable: adapter.readable, writable: adapter.writable, id: qs.id });
|
|
165
|
+
};
|
|
166
|
+
qmux.onError = (err) => this._emitError(err);
|
|
167
|
+
qmux.onClose = (errorCode, reason) => {
|
|
168
|
+
this.#handleClose(errorCode, reason || 'peer sent CONNECTION_CLOSE');
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
ws.addEventListener('open', async () => {
|
|
172
|
+
try {
|
|
173
|
+
qmux.sendHandshake();
|
|
174
|
+
// The control channel is always the first client-initiated
|
|
175
|
+
// QMux stream (ID 0) -- opened here, once, up front.
|
|
176
|
+
const controlQs = await qmux.openStream();
|
|
177
|
+
controlQs.onData = (data) => this.#inbox.push(data);
|
|
178
|
+
controlQs.onReset = (errorCode) => {
|
|
179
|
+
this._emitError(new Error(`control stream reset by peer (error code ${errorCode})`));
|
|
180
|
+
};
|
|
181
|
+
this.#controlStream = controlQs;
|
|
182
|
+
resolve();
|
|
183
|
+
} catch (err) {
|
|
184
|
+
reject(err);
|
|
185
|
+
}
|
|
186
|
+
}, { once: true });
|
|
257
187
|
|
|
258
|
-
ws.addEventListener('error', (
|
|
188
|
+
ws.addEventListener('error', () => {
|
|
259
189
|
if (this.state === 'connecting') {
|
|
260
190
|
reject(new Error('WebSocket connection failed'));
|
|
261
191
|
} else {
|
|
@@ -264,11 +194,15 @@ export class WebSocketTransport extends WshTransport {
|
|
|
264
194
|
});
|
|
265
195
|
|
|
266
196
|
ws.addEventListener('close', (ev) => {
|
|
267
|
-
this.#handleClose(ev.code
|
|
197
|
+
this.#handleClose(null, `WebSocket closed: ${ev.code} ${ev.reason}`);
|
|
268
198
|
});
|
|
269
199
|
|
|
270
200
|
ws.addEventListener('message', (ev) => {
|
|
271
|
-
|
|
201
|
+
if (typeof ev.data === 'string') {
|
|
202
|
+
this._emitError(new Error('Received text WebSocket frame; expected binary'));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
qmux.receiveBytes(new Uint8Array(ev.data));
|
|
272
206
|
});
|
|
273
207
|
});
|
|
274
208
|
}
|
|
@@ -276,8 +210,12 @@ export class WebSocketTransport extends WshTransport {
|
|
|
276
210
|
/** @override */
|
|
277
211
|
async _doClose() {
|
|
278
212
|
this.#closedByUs = true;
|
|
279
|
-
|
|
280
|
-
this
|
|
213
|
+
// Tell the peer we're closing gracefully, then locally tear down our
|
|
214
|
+
// own stream objects -- without this, any reader still waiting on a
|
|
215
|
+
// stream's readable side would hang forever, since nothing else
|
|
216
|
+
// errors or closes it once the peer can no longer respond.
|
|
217
|
+
this.#qmux?.close();
|
|
218
|
+
this.#qmux?.destroy(new Error('Transport closed'));
|
|
281
219
|
|
|
282
220
|
if (this.#ws) {
|
|
283
221
|
try {
|
|
@@ -291,86 +229,34 @@ export class WebSocketTransport extends WshTransport {
|
|
|
291
229
|
|
|
292
230
|
/** @override */
|
|
293
231
|
async _doSendControl(msg) {
|
|
232
|
+
if (!this.#controlStream) {
|
|
233
|
+
throw new Error('Control stream is not open');
|
|
234
|
+
}
|
|
235
|
+
// Length-prefixed CBOR inside the raw QMux byte stream -- QMux
|
|
236
|
+
// streams carry bytes, not pre-delimited messages, same reason the
|
|
237
|
+
// old hand-rolled mux's control channel needed this framing too.
|
|
294
238
|
const cbor = frameEncode(msg);
|
|
295
|
-
|
|
296
|
-
this.#send(frame);
|
|
239
|
+
await this.#controlStream.write(cbor);
|
|
297
240
|
}
|
|
298
241
|
|
|
299
242
|
/** @override */
|
|
300
243
|
async _doOpenStream() {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
// Send open request.
|
|
305
|
-
this.#send(buildFrame(FRAME_OPEN_STREAM, id));
|
|
306
|
-
|
|
307
|
-
// Create the virtual stream.
|
|
308
|
-
const stream = new VirtualStream(
|
|
309
|
-
id,
|
|
310
|
-
(sid, data) => this.#sendData(sid, data),
|
|
311
|
-
(sid) => this.#sendCloseStream(sid),
|
|
312
|
-
);
|
|
313
|
-
this.#streams.set(id, stream);
|
|
314
|
-
|
|
315
|
-
return {
|
|
316
|
-
readable: stream.readable,
|
|
317
|
-
writable: stream.writable,
|
|
318
|
-
id,
|
|
319
|
-
};
|
|
244
|
+
const qs = await this.#qmux.openStream();
|
|
245
|
+
const adapter = new QMuxStreamAdapter(qs);
|
|
246
|
+
return { readable: adapter.readable, writable: adapter.writable, id: qs.id };
|
|
320
247
|
}
|
|
321
248
|
|
|
322
|
-
// ── Inbound
|
|
249
|
+
// ── Inbound control-stream dispatch ─────────────────────────────────
|
|
323
250
|
|
|
324
251
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* FRAME_CONTROL case can itself dispatch several decoded messages —
|
|
329
|
-
* see `#handleControlFrame`.
|
|
330
|
-
* @param {ArrayBuffer} raw
|
|
331
|
-
*/
|
|
332
|
-
async #handleMessage(raw) {
|
|
333
|
-
if (typeof raw === 'string') {
|
|
334
|
-
this._emitError(new Error('Received text WebSocket frame; expected binary'));
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
const data = new Uint8Array(raw);
|
|
338
|
-
|
|
339
|
-
let frame;
|
|
340
|
-
try {
|
|
341
|
-
frame = parseFrame(data);
|
|
342
|
-
} catch (err) {
|
|
343
|
-
this._emitError(new Error(`Malformed frame: ${err.message}`));
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
switch (frame.type) {
|
|
348
|
-
case FRAME_CONTROL:
|
|
349
|
-
await this.#handleControlFrame(frame.payload);
|
|
350
|
-
break;
|
|
351
|
-
case FRAME_DATA:
|
|
352
|
-
this.#handleDataFrame(frame.streamId, frame.payload);
|
|
353
|
-
break;
|
|
354
|
-
case FRAME_OPEN_STREAM:
|
|
355
|
-
this.#handleOpenStream(frame.streamId);
|
|
356
|
-
break;
|
|
357
|
-
case FRAME_CLOSE_STREAM:
|
|
358
|
-
this.#handleCloseStream(frame.streamId);
|
|
359
|
-
break;
|
|
360
|
-
default:
|
|
361
|
-
this._emitError(new Error(`Unknown frame type: 0x${frame.type.toString(16)}`));
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Decode CBOR-framed control messages from stream 0. A single mux
|
|
367
|
-
* frame's payload can decode into more than one protocol message (the
|
|
368
|
-
* CBOR decoder is stateful/streaming), so dispatch uses
|
|
252
|
+
* Decode CBOR-framed control messages from the control stream's byte
|
|
253
|
+
* pipe. A single chunk can decode into more than one protocol message
|
|
254
|
+
* (the CBOR decoder is stateful/streaming), so dispatch uses
|
|
369
255
|
* `dispatchSerially` — see its doc comment for why a plain for-loop
|
|
370
256
|
* here would be unsafe.
|
|
371
257
|
* @param {Uint8Array} payload
|
|
372
258
|
*/
|
|
373
|
-
async #
|
|
259
|
+
async #handleControlBytes(payload) {
|
|
374
260
|
let messages;
|
|
375
261
|
try {
|
|
376
262
|
messages = this.#decoder.feed(payload);
|
|
@@ -387,76 +273,28 @@ export class WebSocketTransport extends WshTransport {
|
|
|
387
273
|
}
|
|
388
274
|
|
|
389
275
|
/**
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
|
|
394
|
-
#handleDataFrame(streamId, payload) {
|
|
395
|
-
const stream = this.#streams.get(streamId);
|
|
396
|
-
if (!stream) {
|
|
397
|
-
// Data for unknown stream; ignore silently (may arrive after close).
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
stream._pushData(payload);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* Server is opening a new stream (even IDs = server-initiated).
|
|
405
|
-
* @param {number} streamId
|
|
406
|
-
*/
|
|
407
|
-
#handleOpenStream(streamId) {
|
|
408
|
-
if (this.#streams.has(streamId)) return; // already tracked
|
|
409
|
-
|
|
410
|
-
const stream = new VirtualStream(
|
|
411
|
-
streamId,
|
|
412
|
-
(sid, data) => this.#sendData(sid, data),
|
|
413
|
-
(sid) => this.#sendCloseStream(sid),
|
|
414
|
-
);
|
|
415
|
-
this.#streams.set(streamId, stream);
|
|
416
|
-
|
|
417
|
-
this._emitStreamOpen({
|
|
418
|
-
readable: stream.readable,
|
|
419
|
-
writable: stream.writable,
|
|
420
|
-
id: streamId,
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Remote closed a stream.
|
|
426
|
-
* @param {number} streamId
|
|
427
|
-
*/
|
|
428
|
-
#handleCloseStream(streamId) {
|
|
429
|
-
const stream = this.#streams.get(streamId);
|
|
430
|
-
if (!stream) return;
|
|
431
|
-
stream._closeRead();
|
|
432
|
-
this.#streams.delete(streamId);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* WebSocket closed.
|
|
437
|
-
* @param {number} code
|
|
276
|
+
* Connection ended, either because the peer sent CONNECTION_CLOSE
|
|
277
|
+
* (QMux-graceful) or the underlying WebSocket itself closed (abrupt --
|
|
278
|
+
* QMux's own terms for a termination with no prior CONNECTION_CLOSE).
|
|
279
|
+
* @param {number|null} errorCode - QMux/QUIC error code, if this came from a CONNECTION_CLOSE frame
|
|
438
280
|
* @param {string} reason
|
|
439
281
|
*/
|
|
440
|
-
#handleClose(
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
this.#
|
|
444
|
-
this
|
|
445
|
-
this.#
|
|
446
|
-
|
|
447
|
-
if (this.state !== 'closed') {
|
|
448
|
-
this._setState('closed');
|
|
449
|
-
if (!this.#closedByUs) {
|
|
450
|
-
this._emitError(err);
|
|
451
|
-
}
|
|
452
|
-
this._emitClose();
|
|
282
|
+
#handleClose(errorCode, reason) {
|
|
283
|
+
if (this.state === 'closed') return;
|
|
284
|
+
const err = new Error(reason);
|
|
285
|
+
this.#qmux?.destroy(err);
|
|
286
|
+
this._setState('closed');
|
|
287
|
+
if (!this.#closedByUs) {
|
|
288
|
+
this._emitError(err);
|
|
453
289
|
}
|
|
290
|
+
this._emitClose();
|
|
454
291
|
}
|
|
455
292
|
|
|
456
|
-
// ── Outbound
|
|
293
|
+
// ── Outbound helper ──────────────────────────────────────────────
|
|
457
294
|
|
|
458
295
|
/**
|
|
459
|
-
* Send raw bytes over the WebSocket.
|
|
296
|
+
* Send raw QMux record bytes over the WebSocket. Passed to
|
|
297
|
+
* QMuxConnection as its `send` callback.
|
|
460
298
|
* @param {Uint8Array} data
|
|
461
299
|
*/
|
|
462
300
|
#send(data) {
|
|
@@ -465,49 +303,4 @@ export class WebSocketTransport extends WshTransport {
|
|
|
465
303
|
}
|
|
466
304
|
this.#ws.send(data);
|
|
467
305
|
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Send a data frame for a virtual stream.
|
|
471
|
-
* @param {number} streamId
|
|
472
|
-
* @param {Uint8Array} payload
|
|
473
|
-
*/
|
|
474
|
-
#sendData(streamId, payload) {
|
|
475
|
-
this.#send(buildFrame(FRAME_DATA, streamId, payload));
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Send a close frame for a virtual stream.
|
|
480
|
-
* @param {number} streamId
|
|
481
|
-
*/
|
|
482
|
-
#sendCloseStream(streamId) {
|
|
483
|
-
try {
|
|
484
|
-
this.#send(buildFrame(FRAME_CLOSE_STREAM, streamId));
|
|
485
|
-
} catch {
|
|
486
|
-
// WebSocket may already be closed.
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// ── Cleanup ────────────────────────────────────────────────────────
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* Destroy all active virtual streams with an error.
|
|
494
|
-
* @param {Error} err
|
|
495
|
-
*/
|
|
496
|
-
#destroyAllStreams(err) {
|
|
497
|
-
for (const stream of this.#streams.values()) {
|
|
498
|
-
stream._destroy(err);
|
|
499
|
-
}
|
|
500
|
-
this.#streams.clear();
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Reject all pending open-stream promises.
|
|
505
|
-
* @param {Error} err
|
|
506
|
-
*/
|
|
507
|
-
#rejectAllOpens(err) {
|
|
508
|
-
for (const { reject } of this.#openResolvers.values()) {
|
|
509
|
-
reject(err);
|
|
510
|
-
}
|
|
511
|
-
this.#openResolvers.clear();
|
|
512
|
-
}
|
|
513
306
|
}
|