@johnhenry/wsh 0.6.0 → 0.13.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 +491 -174
- package/src/file-transfer.mjs +39 -166
- package/src/index.d.ts +110 -0
- package/src/index.mjs +15 -2
- package/src/messages.gen.mjs +66 -10
- 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 +62 -0
- package/src/transport-ws.mjs +148 -337
package/src/transport-ws.mjs
CHANGED
|
@@ -1,83 +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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* @param {number} streamId 32-bit stream ID.
|
|
40
|
-
* @param {Uint8Array} [payload] Optional payload bytes.
|
|
41
|
-
* @returns {Uint8Array}
|
|
42
|
-
*/
|
|
43
|
-
function buildFrame(type, streamId, payload) {
|
|
44
|
-
const payloadLen = payload ? payload.byteLength : 0;
|
|
45
|
-
const frame = new Uint8Array(HEADER_SIZE + payloadLen);
|
|
46
|
-
const view = new DataView(frame.buffer);
|
|
47
|
-
view.setUint8(0, type);
|
|
48
|
-
view.setUint32(1, streamId);
|
|
49
|
-
if (payload) {
|
|
50
|
-
frame.set(payload, HEADER_SIZE);
|
|
51
|
-
}
|
|
52
|
-
return frame;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Parse a multiplexing frame header + payload.
|
|
57
|
-
* @param {Uint8Array} data
|
|
58
|
-
* @returns {{ type: number, streamId: number, payload: Uint8Array }}
|
|
59
|
-
*/
|
|
60
|
-
function parseFrame(data) {
|
|
61
|
-
if (data.byteLength < HEADER_SIZE) {
|
|
62
|
-
throw new Error(`Frame too short: ${data.byteLength} bytes`);
|
|
63
|
-
}
|
|
64
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
65
|
-
return {
|
|
66
|
-
type: view.getUint8(0),
|
|
67
|
-
streamId: view.getUint32(1),
|
|
68
|
-
payload: data.subarray(HEADER_SIZE),
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ── Virtual stream ───────────────────────────────────────────────────
|
|
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).
|
|
35
|
+
export const WS_FRAME_TYPE = Object.freeze({
|
|
36
|
+
CONTROL: 0x01,
|
|
37
|
+
DATA: 0x02,
|
|
38
|
+
OPEN_STREAM: 0x03,
|
|
39
|
+
CLOSE_STREAM: 0x04,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── DOM Streams adapter over a QMuxStream ───────────────────────────
|
|
73
43
|
|
|
74
44
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* 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.
|
|
79
48
|
*/
|
|
80
|
-
class
|
|
49
|
+
class QMuxStreamAdapter {
|
|
81
50
|
/** @type {number} */
|
|
82
51
|
id;
|
|
83
52
|
|
|
@@ -87,98 +56,52 @@ class VirtualStream {
|
|
|
87
56
|
/** @type {WritableStream<Uint8Array>} */
|
|
88
57
|
writable;
|
|
89
58
|
|
|
90
|
-
/** @type {ReadableStreamDefaultController} */
|
|
91
|
-
#readController = null;
|
|
92
|
-
|
|
93
|
-
/** @type {boolean} */
|
|
94
|
-
#readClosed = false;
|
|
95
|
-
|
|
96
|
-
/** @type {boolean} */
|
|
97
|
-
#writeClosed = false;
|
|
98
|
-
|
|
99
59
|
/**
|
|
100
|
-
* @param {
|
|
101
|
-
* @param {function(number, Uint8Array): void} sendData Send data frame callback.
|
|
102
|
-
* @param {function(number): void} sendClose Send close frame callback.
|
|
60
|
+
* @param {import('./qmux-connection.mjs').QMuxConnection} qs
|
|
103
61
|
*/
|
|
104
|
-
constructor(
|
|
105
|
-
this.id = id;
|
|
62
|
+
constructor(qs) {
|
|
63
|
+
this.id = qs.id;
|
|
106
64
|
|
|
107
65
|
this.readable = new ReadableStream({
|
|
108
66
|
start: (controller) => {
|
|
109
|
-
|
|
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
|
+
};
|
|
110
79
|
},
|
|
111
80
|
cancel: () => {
|
|
112
|
-
|
|
81
|
+
qs.stopSending();
|
|
113
82
|
},
|
|
114
83
|
});
|
|
115
84
|
|
|
116
85
|
this.writable = new WritableStream({
|
|
117
|
-
write: (chunk) => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
const bytes = chunk instanceof Uint8Array
|
|
122
|
-
? chunk
|
|
123
|
-
: new Uint8Array(chunk);
|
|
124
|
-
sendData(id, bytes);
|
|
86
|
+
write: async (chunk) => {
|
|
87
|
+
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
88
|
+
await qs.write(bytes);
|
|
125
89
|
},
|
|
126
|
-
close: () => {
|
|
127
|
-
|
|
128
|
-
sendClose(id);
|
|
90
|
+
close: async () => {
|
|
91
|
+
await qs.close();
|
|
129
92
|
},
|
|
130
93
|
abort: () => {
|
|
131
|
-
|
|
132
|
-
sendClose(id);
|
|
94
|
+
qs.reset();
|
|
133
95
|
},
|
|
134
96
|
});
|
|
135
97
|
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Push inbound data into the readable side.
|
|
139
|
-
* @param {Uint8Array} data
|
|
140
|
-
*/
|
|
141
|
-
_pushData(data) {
|
|
142
|
-
if (this.#readClosed || !this.#readController) return;
|
|
143
|
-
try {
|
|
144
|
-
this.#readController.enqueue(data);
|
|
145
|
-
} catch {
|
|
146
|
-
// Controller may already be closed; ignore.
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Signal the remote closed this stream's readable side.
|
|
152
|
-
*/
|
|
153
|
-
_closeRead() {
|
|
154
|
-
if (this.#readClosed) return;
|
|
155
|
-
this.#readClosed = true;
|
|
156
|
-
try {
|
|
157
|
-
this.#readController?.close();
|
|
158
|
-
} catch {
|
|
159
|
-
// Already closed.
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Forcibly error both sides (used on transport close).
|
|
165
|
-
* @param {Error} err
|
|
166
|
-
*/
|
|
167
|
-
_destroy(err) {
|
|
168
|
-
if (!this.#readClosed) {
|
|
169
|
-
this.#readClosed = true;
|
|
170
|
-
try {
|
|
171
|
-
this.#readController?.error(err);
|
|
172
|
-
} catch { /* ignore */ }
|
|
173
|
-
}
|
|
174
|
-
this.#writeClosed = true;
|
|
175
|
-
}
|
|
176
98
|
}
|
|
177
99
|
|
|
178
100
|
// ── WebSocket transport ──────────────────────────────────────────────
|
|
179
101
|
|
|
180
102
|
/**
|
|
181
|
-
* wsh transport over a single WebSocket with multiplexed virtual
|
|
103
|
+
* wsh transport over a single WebSocket with QMux-multiplexed virtual
|
|
104
|
+
* streams.
|
|
182
105
|
*
|
|
183
106
|
* Provides the same interface as WebTransportTransport so that upper
|
|
184
107
|
* layers (session, client) work identically over either transport.
|
|
@@ -187,42 +110,37 @@ export class WebSocketTransport extends WshTransport {
|
|
|
187
110
|
/** @type {WebSocket} */
|
|
188
111
|
#ws = null;
|
|
189
112
|
|
|
190
|
-
/** @type {
|
|
191
|
-
#
|
|
113
|
+
/** @type {import('./qmux-connection.mjs').QMuxConnection} */
|
|
114
|
+
#qmux = null;
|
|
192
115
|
|
|
193
|
-
/** @type {
|
|
194
|
-
#
|
|
195
|
-
|
|
196
|
-
/** Next stream ID for locally-opened streams (odd = client, even = server). */
|
|
197
|
-
#nextLocalId = 1;
|
|
116
|
+
/** @type {import('./qmux-connection.mjs').QMuxConnection|null} The control channel (always QMux stream 0). */
|
|
117
|
+
#controlStream = null;
|
|
198
118
|
|
|
199
|
-
/**
|
|
200
|
-
#
|
|
119
|
+
/** @type {FrameDecoder} Decoder for CBOR control messages carried inside the control stream's byte pipe. */
|
|
120
|
+
#decoder = new FrameDecoder();
|
|
201
121
|
|
|
202
122
|
/** Tracks whether we initiated the close. */
|
|
203
123
|
#closedByUs = false;
|
|
204
124
|
|
|
205
125
|
/**
|
|
206
|
-
* Serializes dispatch of
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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.
|
|
212
132
|
* @type {SerialQueue}
|
|
213
133
|
*/
|
|
214
|
-
#inbox = new SerialQueue((raw) => this.#
|
|
134
|
+
#inbox = new SerialQueue((raw) => this.#handleControlBytes(raw));
|
|
215
135
|
|
|
216
136
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
217
137
|
|
|
218
138
|
/** @override */
|
|
219
139
|
async _doConnect(url) {
|
|
220
|
-
this.#streams.clear();
|
|
221
140
|
this.#decoder.reset();
|
|
222
|
-
this.#nextLocalId = 1;
|
|
223
|
-
this.#openResolvers.clear();
|
|
224
141
|
this.#closedByUs = false;
|
|
225
142
|
this.#inbox.clear();
|
|
143
|
+
this.#controlStream = null;
|
|
226
144
|
|
|
227
145
|
return new Promise((resolve, reject) => {
|
|
228
146
|
// Normalize URL scheme: wsh:// → wss://, http:// → ws://.
|
|
@@ -235,9 +153,39 @@ export class WebSocketTransport extends WshTransport {
|
|
|
235
153
|
ws.binaryType = 'arraybuffer';
|
|
236
154
|
this.#ws = ws;
|
|
237
155
|
|
|
238
|
-
|
|
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 });
|
|
239
187
|
|
|
240
|
-
ws.addEventListener('error', (
|
|
188
|
+
ws.addEventListener('error', () => {
|
|
241
189
|
if (this.state === 'connecting') {
|
|
242
190
|
reject(new Error('WebSocket connection failed'));
|
|
243
191
|
} else {
|
|
@@ -246,11 +194,15 @@ export class WebSocketTransport extends WshTransport {
|
|
|
246
194
|
});
|
|
247
195
|
|
|
248
196
|
ws.addEventListener('close', (ev) => {
|
|
249
|
-
this.#handleClose(ev.code
|
|
197
|
+
this.#handleClose(null, `WebSocket closed: ${ev.code} ${ev.reason}`);
|
|
250
198
|
});
|
|
251
199
|
|
|
252
200
|
ws.addEventListener('message', (ev) => {
|
|
253
|
-
|
|
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));
|
|
254
206
|
});
|
|
255
207
|
});
|
|
256
208
|
}
|
|
@@ -258,8 +210,12 @@ export class WebSocketTransport extends WshTransport {
|
|
|
258
210
|
/** @override */
|
|
259
211
|
async _doClose() {
|
|
260
212
|
this.#closedByUs = true;
|
|
261
|
-
|
|
262
|
-
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'));
|
|
263
219
|
|
|
264
220
|
if (this.#ws) {
|
|
265
221
|
try {
|
|
@@ -273,86 +229,34 @@ export class WebSocketTransport extends WshTransport {
|
|
|
273
229
|
|
|
274
230
|
/** @override */
|
|
275
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.
|
|
276
238
|
const cbor = frameEncode(msg);
|
|
277
|
-
|
|
278
|
-
this.#send(frame);
|
|
239
|
+
await this.#controlStream.write(cbor);
|
|
279
240
|
}
|
|
280
241
|
|
|
281
242
|
/** @override */
|
|
282
243
|
async _doOpenStream() {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// Send open request.
|
|
287
|
-
this.#send(buildFrame(FRAME_OPEN_STREAM, id));
|
|
288
|
-
|
|
289
|
-
// Create the virtual stream.
|
|
290
|
-
const stream = new VirtualStream(
|
|
291
|
-
id,
|
|
292
|
-
(sid, data) => this.#sendData(sid, data),
|
|
293
|
-
(sid) => this.#sendCloseStream(sid),
|
|
294
|
-
);
|
|
295
|
-
this.#streams.set(id, stream);
|
|
296
|
-
|
|
297
|
-
return {
|
|
298
|
-
readable: stream.readable,
|
|
299
|
-
writable: stream.writable,
|
|
300
|
-
id,
|
|
301
|
-
};
|
|
244
|
+
const qs = await this.#qmux.openStream();
|
|
245
|
+
const adapter = new QMuxStreamAdapter(qs);
|
|
246
|
+
return { readable: adapter.readable, writable: adapter.writable, id: qs.id };
|
|
302
247
|
}
|
|
303
248
|
|
|
304
|
-
// ── Inbound
|
|
249
|
+
// ── Inbound control-stream dispatch ─────────────────────────────────
|
|
305
250
|
|
|
306
251
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* FRAME_CONTROL case can itself dispatch several decoded messages —
|
|
311
|
-
* see `#handleControlFrame`.
|
|
312
|
-
* @param {ArrayBuffer} raw
|
|
313
|
-
*/
|
|
314
|
-
async #handleMessage(raw) {
|
|
315
|
-
if (typeof raw === 'string') {
|
|
316
|
-
this._emitError(new Error('Received text WebSocket frame; expected binary'));
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
const data = new Uint8Array(raw);
|
|
320
|
-
|
|
321
|
-
let frame;
|
|
322
|
-
try {
|
|
323
|
-
frame = parseFrame(data);
|
|
324
|
-
} catch (err) {
|
|
325
|
-
this._emitError(new Error(`Malformed frame: ${err.message}`));
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
switch (frame.type) {
|
|
330
|
-
case FRAME_CONTROL:
|
|
331
|
-
await this.#handleControlFrame(frame.payload);
|
|
332
|
-
break;
|
|
333
|
-
case FRAME_DATA:
|
|
334
|
-
this.#handleDataFrame(frame.streamId, frame.payload);
|
|
335
|
-
break;
|
|
336
|
-
case FRAME_OPEN_STREAM:
|
|
337
|
-
this.#handleOpenStream(frame.streamId);
|
|
338
|
-
break;
|
|
339
|
-
case FRAME_CLOSE_STREAM:
|
|
340
|
-
this.#handleCloseStream(frame.streamId);
|
|
341
|
-
break;
|
|
342
|
-
default:
|
|
343
|
-
this._emitError(new Error(`Unknown frame type: 0x${frame.type.toString(16)}`));
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Decode CBOR-framed control messages from stream 0. A single mux
|
|
349
|
-
* frame's payload can decode into more than one protocol message (the
|
|
350
|
-
* 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
|
|
351
255
|
* `dispatchSerially` — see its doc comment for why a plain for-loop
|
|
352
256
|
* here would be unsafe.
|
|
353
257
|
* @param {Uint8Array} payload
|
|
354
258
|
*/
|
|
355
|
-
async #
|
|
259
|
+
async #handleControlBytes(payload) {
|
|
356
260
|
let messages;
|
|
357
261
|
try {
|
|
358
262
|
messages = this.#decoder.feed(payload);
|
|
@@ -369,76 +273,28 @@ export class WebSocketTransport extends WshTransport {
|
|
|
369
273
|
}
|
|
370
274
|
|
|
371
275
|
/**
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
|
|
376
|
-
#handleDataFrame(streamId, payload) {
|
|
377
|
-
const stream = this.#streams.get(streamId);
|
|
378
|
-
if (!stream) {
|
|
379
|
-
// Data for unknown stream; ignore silently (may arrive after close).
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
stream._pushData(payload);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Server is opening a new stream (even IDs = server-initiated).
|
|
387
|
-
* @param {number} streamId
|
|
388
|
-
*/
|
|
389
|
-
#handleOpenStream(streamId) {
|
|
390
|
-
if (this.#streams.has(streamId)) return; // already tracked
|
|
391
|
-
|
|
392
|
-
const stream = new VirtualStream(
|
|
393
|
-
streamId,
|
|
394
|
-
(sid, data) => this.#sendData(sid, data),
|
|
395
|
-
(sid) => this.#sendCloseStream(sid),
|
|
396
|
-
);
|
|
397
|
-
this.#streams.set(streamId, stream);
|
|
398
|
-
|
|
399
|
-
this._emitStreamOpen({
|
|
400
|
-
readable: stream.readable,
|
|
401
|
-
writable: stream.writable,
|
|
402
|
-
id: streamId,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Remote closed a stream.
|
|
408
|
-
* @param {number} streamId
|
|
409
|
-
*/
|
|
410
|
-
#handleCloseStream(streamId) {
|
|
411
|
-
const stream = this.#streams.get(streamId);
|
|
412
|
-
if (!stream) return;
|
|
413
|
-
stream._closeRead();
|
|
414
|
-
this.#streams.delete(streamId);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* WebSocket closed.
|
|
419
|
-
* @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
|
|
420
280
|
* @param {string} reason
|
|
421
281
|
*/
|
|
422
|
-
#handleClose(
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
this.#
|
|
426
|
-
this
|
|
427
|
-
this.#
|
|
428
|
-
|
|
429
|
-
if (this.state !== 'closed') {
|
|
430
|
-
this._setState('closed');
|
|
431
|
-
if (!this.#closedByUs) {
|
|
432
|
-
this._emitError(err);
|
|
433
|
-
}
|
|
434
|
-
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);
|
|
435
289
|
}
|
|
290
|
+
this._emitClose();
|
|
436
291
|
}
|
|
437
292
|
|
|
438
|
-
// ── Outbound
|
|
293
|
+
// ── Outbound helper ──────────────────────────────────────────────
|
|
439
294
|
|
|
440
295
|
/**
|
|
441
|
-
* Send raw bytes over the WebSocket.
|
|
296
|
+
* Send raw QMux record bytes over the WebSocket. Passed to
|
|
297
|
+
* QMuxConnection as its `send` callback.
|
|
442
298
|
* @param {Uint8Array} data
|
|
443
299
|
*/
|
|
444
300
|
#send(data) {
|
|
@@ -447,49 +303,4 @@ export class WebSocketTransport extends WshTransport {
|
|
|
447
303
|
}
|
|
448
304
|
this.#ws.send(data);
|
|
449
305
|
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Send a data frame for a virtual stream.
|
|
453
|
-
* @param {number} streamId
|
|
454
|
-
* @param {Uint8Array} payload
|
|
455
|
-
*/
|
|
456
|
-
#sendData(streamId, payload) {
|
|
457
|
-
this.#send(buildFrame(FRAME_DATA, streamId, payload));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Send a close frame for a virtual stream.
|
|
462
|
-
* @param {number} streamId
|
|
463
|
-
*/
|
|
464
|
-
#sendCloseStream(streamId) {
|
|
465
|
-
try {
|
|
466
|
-
this.#send(buildFrame(FRAME_CLOSE_STREAM, streamId));
|
|
467
|
-
} catch {
|
|
468
|
-
// WebSocket may already be closed.
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// ── Cleanup ────────────────────────────────────────────────────────
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Destroy all active virtual streams with an error.
|
|
476
|
-
* @param {Error} err
|
|
477
|
-
*/
|
|
478
|
-
#destroyAllStreams(err) {
|
|
479
|
-
for (const stream of this.#streams.values()) {
|
|
480
|
-
stream._destroy(err);
|
|
481
|
-
}
|
|
482
|
-
this.#streams.clear();
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Reject all pending open-stream promises.
|
|
487
|
-
* @param {Error} err
|
|
488
|
-
*/
|
|
489
|
-
#rejectAllOpens(err) {
|
|
490
|
-
for (const { reject } of this.#openResolvers.values()) {
|
|
491
|
-
reject(err);
|
|
492
|
-
}
|
|
493
|
-
this.#openResolvers.clear();
|
|
494
|
-
}
|
|
495
306
|
}
|