@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
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QMux connection: stream state machine + flow-control accounting on
|
|
3
|
+
* top of the wire codec in qmux.mjs.
|
|
4
|
+
*
|
|
5
|
+
* This module has no DOM Streams API dependency and no transport
|
|
6
|
+
* dependency of its own — it takes a `send(bytes)` callback for
|
|
7
|
+
* outbound record bytes and a `receiveBytes(chunk)` method fed by
|
|
8
|
+
* whatever owns the underlying connection (transport-ws.mjs). That
|
|
9
|
+
* keeps the state machine testable without a real or fake WebSocket.
|
|
10
|
+
*
|
|
11
|
+
* Because the underlying transport (a WebSocket) already guarantees
|
|
12
|
+
* in-order, lossless delivery, there is no packet loss, reordering, or
|
|
13
|
+
* retransmission to handle — flow control here exists purely to bound
|
|
14
|
+
* memory (backpressure), not to manage congestion. See qmux.mjs's file
|
|
15
|
+
* doc comment for why that's QMux's whole design point.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
encodeRecord, RecordDecoder, decodeFrames,
|
|
20
|
+
encodeStream, encodeResetStream, encodeResetStreamAt, encodeStopSending,
|
|
21
|
+
encodeMaxData, encodeMaxStreamData, encodeMaxStreams,
|
|
22
|
+
encodeDataBlocked, encodeStreamDataBlocked, encodeStreamsBlocked,
|
|
23
|
+
encodeConnectionClose, encodeDatagram, encodeTransportParameters,
|
|
24
|
+
ERROR_CODE, STREAM_INITIATOR, firstBidiStreamId, nextBidiStreamId,
|
|
25
|
+
isClientInitiated, QMuxProtocolError,
|
|
26
|
+
} from './qmux.mjs';
|
|
27
|
+
|
|
28
|
+
export const DEFAULTS = Object.freeze({
|
|
29
|
+
initialMaxData: 8 * 1024 * 1024, // 8 MiB per connection
|
|
30
|
+
initialMaxStreamData: 1024 * 1024, // 1 MiB per stream
|
|
31
|
+
initialMaxStreamsBidi: 100,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Send a window update once the peer has consumed at least this
|
|
35
|
+
// fraction of the previously-granted window, rather than after every
|
|
36
|
+
// single read (classic QUIC-style windowed flow control).
|
|
37
|
+
const WINDOW_UPDATE_THRESHOLD = 0.5;
|
|
38
|
+
|
|
39
|
+
// ── Per-stream send/receive state ───────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const SEND_STATE = Object.freeze({
|
|
42
|
+
READY: 'ready', // nothing sent yet
|
|
43
|
+
SEND: 'send', // sending data
|
|
44
|
+
RESET_SENT: 'reset_sent', // we sent RESET_STREAM/RESET_STREAM_AT
|
|
45
|
+
DATA_SENT: 'data_sent', // we sent FIN (final size known to peer)
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const RECV_STATE = Object.freeze({
|
|
49
|
+
RECV: 'recv', // receiving data, final size unknown
|
|
50
|
+
SIZE_KNOWN: 'size_known', // FIN or RESET_STREAM_AT seen; final size known
|
|
51
|
+
DATA_RECVD: 'data_recvd', // all bytes up to final/reliable size delivered to app
|
|
52
|
+
RESET_RECVD: 'reset_recvd', // peer reset with no reliable prefix left to deliver
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One multiplexed stream. Exposes a minimal push/pull data API (not a
|
|
57
|
+
* DOM ReadableStream/WritableStream directly) — transport-ws.mjs wraps
|
|
58
|
+
* this in the actual Streams API objects the rest of wsh expects.
|
|
59
|
+
*/
|
|
60
|
+
class QMuxStream {
|
|
61
|
+
id;
|
|
62
|
+
#conn;
|
|
63
|
+
|
|
64
|
+
// Send side
|
|
65
|
+
#sendState = SEND_STATE.READY;
|
|
66
|
+
#sendOffset = 0;
|
|
67
|
+
#sendWindow; // bytes we're currently allowed to send beyond #sendOffset
|
|
68
|
+
#sendWaiters = []; // {resolve} queue, FIFO, woken as window opens
|
|
69
|
+
|
|
70
|
+
// Receive side
|
|
71
|
+
#recvState = RECV_STATE.RECV;
|
|
72
|
+
#recvOffset = 0; // bytes delivered to the application (onData)
|
|
73
|
+
#recvBufferedUpTo = 0; // bytes received from the wire (buffered + delivered)
|
|
74
|
+
#recvWindow; // bytes we've told the peer they may still send
|
|
75
|
+
#recvWindowGranted; // the window value last announced via MAX_STREAM_DATA
|
|
76
|
+
#finalSize = null; // set once FIN or RESET_STREAM_AT/RESET_STREAM is seen
|
|
77
|
+
#reliableSize = null; // set on RESET_STREAM_AT; null means "no reset"
|
|
78
|
+
#resetErrorCode = null;
|
|
79
|
+
#outOfOrderChunks = new Map(); // offset -> data, for frames that arrive with gaps (shouldn't happen over an in-order transport, but guarded)
|
|
80
|
+
|
|
81
|
+
onData = null; // (Uint8Array) => void -- in-order delivery
|
|
82
|
+
onEnd = null; // () => void -- clean FIN, all data delivered
|
|
83
|
+
onReset = null; // (errorCode) => void -- peer aborted (after any reliable prefix delivered)
|
|
84
|
+
onDestroy = null; // (Error) => void -- the whole connection died (transport closed/errored), not a protocol-level reset
|
|
85
|
+
|
|
86
|
+
constructor(conn, id, { sendWindow, recvWindow }) {
|
|
87
|
+
this.#conn = conn;
|
|
88
|
+
this.id = id;
|
|
89
|
+
this.#sendWindow = sendWindow;
|
|
90
|
+
this.#recvWindow = recvWindow;
|
|
91
|
+
this.#recvWindowGranted = recvWindow;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get sendState() { return this.#sendState; }
|
|
95
|
+
get recvState() { return this.#recvState; }
|
|
96
|
+
|
|
97
|
+
/** Both directions have reached a terminal state (sent/reset, and received/reset). */
|
|
98
|
+
_isFullyClosed() {
|
|
99
|
+
const sendDone = this.#sendState === SEND_STATE.DATA_SENT || this.#sendState === SEND_STATE.RESET_SENT;
|
|
100
|
+
const recvDone = this.#recvState === RECV_STATE.DATA_RECVD || this.#recvState === RECV_STATE.RESET_RECVD;
|
|
101
|
+
return sendDone && recvDone;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Write `bytes` to the stream. Resolves once the bytes have been
|
|
106
|
+
* hand off to the connection (which may itself wait on connection-
|
|
107
|
+
* level flow control) -- QMux has no real acknowledgment, so "sent"
|
|
108
|
+
* is as strong a guarantee as this layer offers (the underlying
|
|
109
|
+
* transport is reliable, so that's sufficient).
|
|
110
|
+
* @param {Uint8Array} bytes
|
|
111
|
+
*/
|
|
112
|
+
async write(bytes) {
|
|
113
|
+
if (this.#sendState === SEND_STATE.RESET_SENT) {
|
|
114
|
+
throw new Error(`Cannot write to stream ${this.id}: already reset`);
|
|
115
|
+
}
|
|
116
|
+
if (this.#sendState === SEND_STATE.DATA_SENT) {
|
|
117
|
+
throw new Error(`Cannot write to stream ${this.id}: already closed (FIN sent)`);
|
|
118
|
+
}
|
|
119
|
+
this.#sendState = SEND_STATE.SEND;
|
|
120
|
+
|
|
121
|
+
let offset = 0;
|
|
122
|
+
while (offset < bytes.byteLength) {
|
|
123
|
+
const chunk = await this.#waitForSendWindow(bytes.byteLength - offset);
|
|
124
|
+
const slice = bytes.subarray(offset, offset + chunk);
|
|
125
|
+
this.#conn._sendFrame(encodeStream({
|
|
126
|
+
streamId: this.id,
|
|
127
|
+
offset: this.#sendOffset,
|
|
128
|
+
data: slice,
|
|
129
|
+
fin: false,
|
|
130
|
+
}), slice.byteLength);
|
|
131
|
+
this.#sendOffset += slice.byteLength;
|
|
132
|
+
this.#sendWindow -= slice.byteLength;
|
|
133
|
+
offset += slice.byteLength;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Half-close the send side: send FIN. No more write() calls after this. */
|
|
138
|
+
async close() {
|
|
139
|
+
if (this.#sendState === SEND_STATE.RESET_SENT || this.#sendState === SEND_STATE.DATA_SENT) return;
|
|
140
|
+
this.#conn._sendFrame(encodeStream({
|
|
141
|
+
streamId: this.id,
|
|
142
|
+
offset: this.#sendOffset,
|
|
143
|
+
data: new Uint8Array(0),
|
|
144
|
+
fin: true,
|
|
145
|
+
}), 0);
|
|
146
|
+
this.#sendState = SEND_STATE.DATA_SENT;
|
|
147
|
+
this.#conn._maybeStreamClosed(this.id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Abort the send side. `reliableSize` (bytes already written via
|
|
152
|
+
* write(), counted from offset 0) are guaranteed delivered even
|
|
153
|
+
* though the stream is reset -- draft-ietf-quic-reliable-stream-
|
|
154
|
+
* reset-09. Defaults to 0 (an ordinary abrupt reset, no prefix
|
|
155
|
+
* preserved) to match RESET_STREAM's semantics exactly when omitted.
|
|
156
|
+
*/
|
|
157
|
+
reset(errorCode = ERROR_CODE.APPLICATION_ERROR, reliableSize = 0) {
|
|
158
|
+
if (this.#sendState === SEND_STATE.RESET_SENT || this.#sendState === SEND_STATE.DATA_SENT) return;
|
|
159
|
+
const finalSize = this.#sendOffset;
|
|
160
|
+
if (reliableSize > 0) {
|
|
161
|
+
this.#conn._sendFrame(encodeResetStreamAt({
|
|
162
|
+
streamId: this.id, errorCode, finalSize, reliableSize: Math.min(reliableSize, finalSize),
|
|
163
|
+
}), 0);
|
|
164
|
+
} else {
|
|
165
|
+
this.#conn._sendFrame(encodeResetStream({ streamId: this.id, errorCode, finalSize }), 0);
|
|
166
|
+
}
|
|
167
|
+
this.#sendState = SEND_STATE.RESET_SENT;
|
|
168
|
+
this.#failSendWaiters(new Error(`Stream ${this.id} was reset`));
|
|
169
|
+
this.#conn._maybeStreamClosed(this.id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Tell the peer to stop sending (we're no longer interested in their data). */
|
|
173
|
+
stopSending(errorCode = ERROR_CODE.APPLICATION_ERROR) {
|
|
174
|
+
this.#conn._sendFrame(encodeStopSending({ streamId: this.id, errorCode }), 0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Forcibly tear down this stream because the whole connection died
|
|
179
|
+
* (transport closed/errored) -- not a protocol-level RESET_STREAM, so
|
|
180
|
+
* no frame is sent (there's nowhere to send it). No-ops if the stream
|
|
181
|
+
* already reached a terminal state on its own.
|
|
182
|
+
* @param {Error} err
|
|
183
|
+
*/
|
|
184
|
+
_destroy(err) {
|
|
185
|
+
if (this.#sendState !== SEND_STATE.RESET_SENT && this.#sendState !== SEND_STATE.DATA_SENT) {
|
|
186
|
+
this.#sendState = SEND_STATE.RESET_SENT;
|
|
187
|
+
}
|
|
188
|
+
this.#failSendWaiters(err);
|
|
189
|
+
if (this.#recvState !== RECV_STATE.DATA_RECVD && this.#recvState !== RECV_STATE.RESET_RECVD) {
|
|
190
|
+
this.#recvState = RECV_STATE.RESET_RECVD;
|
|
191
|
+
try { this.onDestroy?.(err); } catch (handlerErr) { console.error('[qmux] onDestroy handler error:', handlerErr); }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Flow control (send side) ────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/** Wait until at least 1 byte of send window is available; returns how many bytes may be sent now (<= requested). */
|
|
198
|
+
#waitForSendWindow(requested) {
|
|
199
|
+
const available = Math.min(requested, this.#sendWindow, this.#conn._connSendWindow());
|
|
200
|
+
if (available > 0) {
|
|
201
|
+
// Also reserve against the connection-level window immediately,
|
|
202
|
+
// synchronously with this stream's own window check, so two
|
|
203
|
+
// streams can't both observe "room" and together overrun it.
|
|
204
|
+
this.#conn._reserveConnSendWindow(available);
|
|
205
|
+
return Promise.resolve(available);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Tell the peer which limit we're blocked on -- informational (we'll
|
|
209
|
+
// proceed as soon as a MAX_DATA/MAX_STREAM_DATA arrives regardless),
|
|
210
|
+
// but real QUIC implementations rely on *_BLOCKED to prioritize whose
|
|
211
|
+
// window to raise first, so a full-spec implementation sends them.
|
|
212
|
+
if (this.#sendWindow <= 0) {
|
|
213
|
+
this.#conn._sendFrame(encodeStreamDataBlocked({ streamId: this.id, streamDataLimit: this.#sendOffset + this.#sendWindow }), 0);
|
|
214
|
+
}
|
|
215
|
+
if (this.#conn._connSendWindow() <= 0) {
|
|
216
|
+
this.#conn._sendDataBlocked();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
this.#sendWaiters.push({ resolve, reject, requested });
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Called by the connection when peer grants more stream-level window (MAX_STREAM_DATA). */
|
|
225
|
+
_grantSendWindow(newLimit) {
|
|
226
|
+
// newLimit is an absolute offset limit, not a delta; MAX_STREAM_DATA
|
|
227
|
+
// frames MUST NOT be allowed to reduce the window (RFC 9000 §4.1) --
|
|
228
|
+
// a lower/stale value (possible if frames could reorder) is ignored.
|
|
229
|
+
const newWindow = newLimit - this.#sendOffset;
|
|
230
|
+
if (newWindow <= this.#sendWindow) return;
|
|
231
|
+
this.#sendWindow = newWindow;
|
|
232
|
+
this.#wakeSendWaiters();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Called by the connection when connection-level MAX_DATA increases --
|
|
237
|
+
* this stream's own window is unchanged, but a waiter blocked purely
|
|
238
|
+
* on the connection-level window may now be able to proceed.
|
|
239
|
+
*/
|
|
240
|
+
_recheckSendWindow() {
|
|
241
|
+
this.#wakeSendWaiters();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#wakeSendWaiters() {
|
|
245
|
+
while (this.#sendWaiters.length > 0 && this.#sendWindow > 0) {
|
|
246
|
+
const waiter = this.#sendWaiters[0];
|
|
247
|
+
const available = Math.min(waiter.requested, this.#sendWindow, this.#conn._connSendWindow());
|
|
248
|
+
if (available <= 0) break;
|
|
249
|
+
this.#sendWaiters.shift();
|
|
250
|
+
this.#conn._reserveConnSendWindow(available);
|
|
251
|
+
waiter.resolve(available);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#failSendWaiters(err) {
|
|
256
|
+
for (const waiter of this.#sendWaiters.splice(0)) waiter.reject(err);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Flow control (receive side) ─────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Handle an inbound STREAM frame's payload for this stream. Delivers
|
|
263
|
+
* data to onData in order (the underlying transport is already
|
|
264
|
+
* in-order, so out-of-order arrival here would itself be a protocol
|
|
265
|
+
* violation per QMux -- guarded defensively, not expected).
|
|
266
|
+
*/
|
|
267
|
+
/** @returns {number} bytes newly accounted for (for the connection's own MAX_DATA bookkeeping) */
|
|
268
|
+
_receiveStreamData(offset, data, fin) {
|
|
269
|
+
if (offset !== this.#recvBufferedUpTo) {
|
|
270
|
+
throw new QMuxProtocolError(
|
|
271
|
+
ERROR_CODE.PROTOCOL_VIOLATION,
|
|
272
|
+
`stream ${this.id}: out-of-order STREAM frame (expected offset ${this.#recvBufferedUpTo}, got ${offset})`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (this.#recvState === RECV_STATE.RESET_RECVD) return 0; // peer already reset; ignore trailing data
|
|
276
|
+
|
|
277
|
+
this.#recvBufferedUpTo += data.byteLength;
|
|
278
|
+
if (data.byteLength > 0) {
|
|
279
|
+
this.#recvOffset += data.byteLength;
|
|
280
|
+
try { this.onData?.(data); } catch (err) { console.error('[qmux] onData handler error:', err); }
|
|
281
|
+
this.#maybeGrantMoreWindow();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (fin) {
|
|
285
|
+
this.#finalSize = this.#recvBufferedUpTo;
|
|
286
|
+
this.#recvState = RECV_STATE.DATA_RECVD;
|
|
287
|
+
try { this.onEnd?.(); } catch (err) { console.error('[qmux] onEnd handler error:', err); }
|
|
288
|
+
this.#conn._maybeStreamClosed(this.id);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return data.byteLength;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Handle an inbound RESET_STREAM/RESET_STREAM_AT for this stream. */
|
|
295
|
+
_receiveReset(errorCode, finalSize, reliableSize) {
|
|
296
|
+
if (this.#recvState === RECV_STATE.RESET_RECVD || this.#recvState === RECV_STATE.DATA_RECVD) return;
|
|
297
|
+
|
|
298
|
+
this.#finalSize = finalSize;
|
|
299
|
+
this.#reliableSize = reliableSize;
|
|
300
|
+
this.#resetErrorCode = errorCode;
|
|
301
|
+
this.#recvState = RECV_STATE.SIZE_KNOWN;
|
|
302
|
+
|
|
303
|
+
// If we've already received (and delivered) everything up to the
|
|
304
|
+
// reliable prefix, the abort is actionable immediately. Otherwise
|
|
305
|
+
// wait -- more STREAM frames for the reliable prefix are still
|
|
306
|
+
// coming (guaranteed by the sender's obligation to deliver them).
|
|
307
|
+
this.#maybeCompleteReset();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#maybeCompleteReset() {
|
|
311
|
+
if (this.#reliableSize === null) return;
|
|
312
|
+
if (this.#recvBufferedUpTo < this.#reliableSize) return;
|
|
313
|
+
if (this.#recvState === RECV_STATE.RESET_RECVD) return;
|
|
314
|
+
this.#recvState = RECV_STATE.RESET_RECVD;
|
|
315
|
+
try { this.onReset?.(this.#resetErrorCode); } catch (err) { console.error('[qmux] onReset handler error:', err); }
|
|
316
|
+
this.#conn._maybeStreamClosed(this.id);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Top up the stream-level window once the peer has used up more than
|
|
321
|
+
* WINDOW_UPDATE_THRESHOLD of the *target* window size (#recvWindow,
|
|
322
|
+
* a fixed size we aim to always keep available) -- not a threshold of
|
|
323
|
+
* the absolute granted limit, which only grows over the stream's
|
|
324
|
+
* lifetime and would make updates rarer and rarer over time if used
|
|
325
|
+
* directly.
|
|
326
|
+
*/
|
|
327
|
+
#maybeGrantMoreWindow() {
|
|
328
|
+
this.#maybeCompleteReset();
|
|
329
|
+
const remaining = this.#recvWindowGranted - this.#recvBufferedUpTo;
|
|
330
|
+
if (remaining <= this.#recvWindow * (1 - WINDOW_UPDATE_THRESHOLD)) {
|
|
331
|
+
const newLimit = this.#recvBufferedUpTo + this.#recvWindow;
|
|
332
|
+
this.#recvWindowGranted = newLimit;
|
|
333
|
+
this.#conn._sendFrame(encodeMaxStreamData({ streamId: this.id, maxStreamData: newLimit }), 0);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Connection ───────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
export class QMuxConnection {
|
|
341
|
+
#isClient;
|
|
342
|
+
#send; // (Uint8Array) => void, raw transport write
|
|
343
|
+
#decoder = new RecordDecoder();
|
|
344
|
+
#streams = new Map(); // streamId -> QMuxStream
|
|
345
|
+
#nextLocalStreamId;
|
|
346
|
+
#closed = false;
|
|
347
|
+
|
|
348
|
+
// Connection-level flow control (send side: bytes *we* may still send)
|
|
349
|
+
#connSendOffset = 0;
|
|
350
|
+
#connSendWindow;
|
|
351
|
+
#lastDataBlockedAt = -1; // last connSendOffset a DATA_BLOCKED was announced for, to avoid duplicates
|
|
352
|
+
|
|
353
|
+
// Connection-level flow control (receive side: bytes we allow the peer to send)
|
|
354
|
+
#connRecvBufferedUpTo = 0;
|
|
355
|
+
#connRecvWindow;
|
|
356
|
+
#connRecvWindowGranted;
|
|
357
|
+
|
|
358
|
+
#maxStreamsBidi;
|
|
359
|
+
#streamsOpened = 0; // locally-initiated bidi streams created so far
|
|
360
|
+
#peerMaxStreamsBidi; // MAX_STREAMS grant from the peer (undefined until received)
|
|
361
|
+
#streamOpenWaiters = [];
|
|
362
|
+
|
|
363
|
+
// MAX_STREAMS we grant the peer: streamsGranted starts at #maxStreamsBidi
|
|
364
|
+
// (sent in the handshake) and is topped up as peer-initiated streams
|
|
365
|
+
// close, so the peer doesn't permanently lose capacity as streams
|
|
366
|
+
// come and go over a long-lived connection.
|
|
367
|
+
#peerStreamsOpened = 0; // cumulative count of peer-initiated streams accepted
|
|
368
|
+
#peerStreamsGranted; // last MAX_STREAMS value announced (== #maxStreamsBidi until topped up)
|
|
369
|
+
|
|
370
|
+
#initialMaxStreamData;
|
|
371
|
+
|
|
372
|
+
onStreamOpen = null; // (QMuxStream) => void, for peer-initiated streams
|
|
373
|
+
onDatagram = null; // (Uint8Array) => void
|
|
374
|
+
onClose = null; // (errorCode, reason) => void
|
|
375
|
+
onError = null; // (Error) => void
|
|
376
|
+
|
|
377
|
+
constructor({
|
|
378
|
+
isClient,
|
|
379
|
+
send,
|
|
380
|
+
initialMaxData = DEFAULTS.initialMaxData,
|
|
381
|
+
initialMaxStreamData = DEFAULTS.initialMaxStreamData,
|
|
382
|
+
initialMaxStreamsBidi = DEFAULTS.initialMaxStreamsBidi,
|
|
383
|
+
}) {
|
|
384
|
+
this.#isClient = isClient;
|
|
385
|
+
this.#send = send;
|
|
386
|
+
this.#connSendWindow = initialMaxData; // updated once we learn the peer's real initial_max_data
|
|
387
|
+
this.#connRecvWindow = initialMaxData;
|
|
388
|
+
this.#connRecvWindowGranted = initialMaxData;
|
|
389
|
+
this.#initialMaxStreamData = initialMaxStreamData;
|
|
390
|
+
this.#maxStreamsBidi = initialMaxStreamsBidi;
|
|
391
|
+
this.#peerStreamsGranted = initialMaxStreamsBidi;
|
|
392
|
+
this.#nextLocalStreamId = firstBidiStreamId(isClient ? STREAM_INITIATOR.CLIENT : STREAM_INITIATOR.SERVER);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Send the QX_TRANSPORT_PARAMETERS handshake frame — must be the first frame sent (QMux §4.1). */
|
|
396
|
+
sendHandshake() {
|
|
397
|
+
this.#send(encodeRecord(encodeTransportParameters({
|
|
398
|
+
initial_max_data: this.#connRecvWindowGranted,
|
|
399
|
+
initial_max_stream_data_bidi_local: this.#initialMaxStreamData,
|
|
400
|
+
initial_max_stream_data_bidi_remote: this.#initialMaxStreamData,
|
|
401
|
+
initial_max_streams_bidi: this.#maxStreamsBidi,
|
|
402
|
+
})));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Open a new locally-initiated bidirectional stream. */
|
|
406
|
+
async openStream() {
|
|
407
|
+
if (this.#peerMaxStreamsBidi !== undefined && this.#streamsOpened >= this.#peerMaxStreamsBidi) {
|
|
408
|
+
this.#send(encodeRecord(encodeStreamsBlocked({ unidirectional: false, streamLimit: this.#peerMaxStreamsBidi })));
|
|
409
|
+
await new Promise((resolve) => this.#streamOpenWaiters.push(resolve));
|
|
410
|
+
}
|
|
411
|
+
const id = this.#nextLocalStreamId;
|
|
412
|
+
this.#nextLocalStreamId = nextBidiStreamId(id);
|
|
413
|
+
this.#streamsOpened++;
|
|
414
|
+
return this.#createStream(id);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#createStream(id) {
|
|
418
|
+
const stream = new QMuxStream(this, id, {
|
|
419
|
+
sendWindow: this.#initialMaxStreamData,
|
|
420
|
+
recvWindow: this.#initialMaxStreamData,
|
|
421
|
+
});
|
|
422
|
+
this.#streams.set(id, stream);
|
|
423
|
+
return stream;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Look up a stream by ID, lazily creating (and firing onStreamOpen for)
|
|
428
|
+
* a peer-initiated one that hasn't been referenced yet -- QMux/QUIC
|
|
429
|
+
* streams aren't explicitly opened on the wire, so any frame
|
|
430
|
+
* referencing an unseen peer-initiated stream ID implicitly creates
|
|
431
|
+
* it, not just STREAM frames. Returns undefined (without creating
|
|
432
|
+
* anything) for an ID this endpoint should itself have allocated,
|
|
433
|
+
* since that can never legitimately be "peer-initiated."
|
|
434
|
+
*/
|
|
435
|
+
#getOrCreatePeerStream(id) {
|
|
436
|
+
let stream = this.#streams.get(id);
|
|
437
|
+
if (stream) return stream;
|
|
438
|
+
if (isClientInitiated(id) === this.#isClient) return undefined;
|
|
439
|
+
|
|
440
|
+
const streamOrdinal = Math.floor(id / 4) + 1;
|
|
441
|
+
if (streamOrdinal > this.#peerStreamsGranted) {
|
|
442
|
+
throw new QMuxProtocolError(ERROR_CODE.STREAM_LIMIT_ERROR, `peer referenced stream ${id} beyond the granted MAX_STREAMS limit (${this.#peerStreamsGranted})`);
|
|
443
|
+
}
|
|
444
|
+
this.#peerStreamsOpened = Math.max(this.#peerStreamsOpened, streamOrdinal);
|
|
445
|
+
stream = this.#createStream(id);
|
|
446
|
+
try { this.onStreamOpen?.(stream); } catch (err) { console.error('[qmux] onStreamOpen handler error:', err); }
|
|
447
|
+
return stream;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
getStream(id) {
|
|
451
|
+
return this.#streams.get(id);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Called by a QMuxStream whenever either of its directions reaches a
|
|
456
|
+
* terminal state; removes it from bookkeeping once *both* have, and
|
|
457
|
+
* for a peer-initiated stream, tops up the MAX_STREAMS grant so a
|
|
458
|
+
* long-lived connection doesn't permanently lose capacity as streams
|
|
459
|
+
* come and go.
|
|
460
|
+
*/
|
|
461
|
+
_maybeStreamClosed(id) {
|
|
462
|
+
const stream = this.#streams.get(id);
|
|
463
|
+
if (!stream || !stream._isFullyClosed()) return;
|
|
464
|
+
this.#streams.delete(id);
|
|
465
|
+
|
|
466
|
+
if (isClientInitiated(id) !== this.#isClient) {
|
|
467
|
+
// Peer-initiated: consider raising MAX_STREAMS once less than half
|
|
468
|
+
// the original window's worth of headroom remains, mirroring the
|
|
469
|
+
// same threshold heuristic used for MAX_DATA/MAX_STREAM_DATA.
|
|
470
|
+
const headroom = this.#peerStreamsGranted - this.#peerStreamsOpened;
|
|
471
|
+
if (headroom <= this.#maxStreamsBidi * (1 - WINDOW_UPDATE_THRESHOLD)) {
|
|
472
|
+
this.#peerStreamsGranted = this.#peerStreamsOpened + this.#maxStreamsBidi;
|
|
473
|
+
this.#send(encodeRecord(encodeMaxStreams({ unidirectional: false, maxStreams: this.#peerStreamsGranted })));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Send an unreliable-in-the-QUIC-sense-but-actually-reliable-here datagram (RFC 9221, via QMux). */
|
|
479
|
+
sendDatagram(bytes) {
|
|
480
|
+
this.#send(encodeRecord(encodeDatagram(bytes)));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Feed raw bytes received from the underlying transport. */
|
|
484
|
+
receiveBytes(chunk) {
|
|
485
|
+
let records;
|
|
486
|
+
try {
|
|
487
|
+
records = this.#decoder.feed(chunk);
|
|
488
|
+
} catch (err) {
|
|
489
|
+
this.#emitError(err);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
for (const record of records) {
|
|
493
|
+
this.#handleRecord(record);
|
|
494
|
+
if (this.#closed) break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#handleRecord(record) {
|
|
499
|
+
let frames;
|
|
500
|
+
try {
|
|
501
|
+
frames = decodeFrames(record);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
if (err instanceof QMuxProtocolError) {
|
|
504
|
+
this.#closeLocally(err.errorCode, err.message);
|
|
505
|
+
}
|
|
506
|
+
this.#emitError(err);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
for (const frame of frames) {
|
|
510
|
+
try {
|
|
511
|
+
this.#handleFrame(frame);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
if (err instanceof QMuxProtocolError) {
|
|
514
|
+
this.#closeLocally(err.errorCode, err.message);
|
|
515
|
+
}
|
|
516
|
+
this.#emitError(err);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#handleFrame(frame) {
|
|
523
|
+
switch (frame.frameType) {
|
|
524
|
+
case 'QX_TRANSPORT_PARAMETERS': {
|
|
525
|
+
if (frame.params.initial_max_data !== undefined) {
|
|
526
|
+
this.#connSendWindow = Number(frame.params.initial_max_data) - this.#connSendOffset;
|
|
527
|
+
}
|
|
528
|
+
this.#peerMaxStreamsBidi = frame.params.initial_max_streams_bidi !== undefined
|
|
529
|
+
? Number(frame.params.initial_max_streams_bidi)
|
|
530
|
+
: DEFAULTS.initialMaxStreamsBidi;
|
|
531
|
+
this.#wakeStreamOpenWaiters();
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
case 'STREAM': {
|
|
535
|
+
const stream = this.#getOrCreatePeerStream(Number(frame.streamId));
|
|
536
|
+
if (!stream) break; // an ID this endpoint should have allocated itself -- ignore/guard
|
|
537
|
+
const delivered = stream._receiveStreamData(Number(frame.offset), frame.data, frame.fin);
|
|
538
|
+
this.#accountConnRecv(delivered);
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
case 'RESET_STREAM': {
|
|
542
|
+
// RESET_STREAM (like RESET_STREAM_AT below) can legitimately be
|
|
543
|
+
// the *first* frame ever seen for a peer-initiated stream (reset
|
|
544
|
+
// before ever writing anything) -- must lazily create it too,
|
|
545
|
+
// not only the STREAM frame handler, or the reset is silently lost.
|
|
546
|
+
const stream = this.#getOrCreatePeerStream(Number(frame.streamId));
|
|
547
|
+
stream?._receiveReset(Number(frame.errorCode), Number(frame.finalSize), 0);
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
case 'RESET_STREAM_AT': {
|
|
551
|
+
const stream = this.#getOrCreatePeerStream(Number(frame.streamId));
|
|
552
|
+
stream?._receiveReset(Number(frame.errorCode), Number(frame.finalSize), Number(frame.reliableSize));
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
case 'STOP_SENDING': {
|
|
556
|
+
const stream = this.#streams.get(Number(frame.streamId));
|
|
557
|
+
// We're being told to stop sending; reset our send side with no reliable prefix owed.
|
|
558
|
+
stream?.reset(ERROR_CODE.NO_ERROR, 0);
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
case 'MAX_DATA': {
|
|
562
|
+
const newWindow = Number(frame.maxData) - this.#connSendOffset;
|
|
563
|
+
if (newWindow > this.#connSendWindow) {
|
|
564
|
+
this.#connSendWindow = newWindow;
|
|
565
|
+
this.#wakeAllStreamSendWaiters();
|
|
566
|
+
}
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case 'MAX_STREAM_DATA': {
|
|
570
|
+
const stream = this.#streams.get(Number(frame.streamId));
|
|
571
|
+
stream?._grantSendWindow(Number(frame.maxStreamData));
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
case 'MAX_STREAMS': {
|
|
575
|
+
if (!frame.unidirectional) {
|
|
576
|
+
this.#peerMaxStreamsBidi = Number(frame.maxStreams);
|
|
577
|
+
this.#wakeStreamOpenWaiters();
|
|
578
|
+
}
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
case 'DATA_BLOCKED':
|
|
582
|
+
case 'STREAM_DATA_BLOCKED':
|
|
583
|
+
case 'STREAMS_BLOCKED': {
|
|
584
|
+
// Informational: the peer is blocked on a limit we control.
|
|
585
|
+
// Nothing to do here since we already grant more window
|
|
586
|
+
// proactively as data is consumed (see #maybeGrantMoreWindow).
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
case 'CONNECTION_CLOSE': {
|
|
590
|
+
this.#closed = true;
|
|
591
|
+
try { this.onClose?.(frame.errorCode, frame.reason); } catch (err) { console.error('[qmux] onClose handler error:', err); }
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
case 'DATAGRAM': {
|
|
595
|
+
try { this.onDatagram?.(frame.data); } catch (err) { console.error('[qmux] onDatagram handler error:', err); }
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
case 'PADDING':
|
|
599
|
+
break;
|
|
600
|
+
default:
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Gracefully close the connection with CONNECTION_CLOSE. */
|
|
606
|
+
close(errorCode = ERROR_CODE.NO_ERROR, reason = '') {
|
|
607
|
+
if (this.#closed) return;
|
|
608
|
+
this.#send(encodeRecord(encodeConnectionClose({ application: true, errorCode, reason })));
|
|
609
|
+
this.#closed = true;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Forcibly tear down every stream because the underlying transport
|
|
614
|
+
* itself died (closed or errored below the QMux layer -- e.g. the
|
|
615
|
+
* WebSocket connection dropped) -- no CONNECTION_CLOSE is sent, since
|
|
616
|
+
* there's nowhere left to send it.
|
|
617
|
+
* @param {Error} err
|
|
618
|
+
*/
|
|
619
|
+
destroy(err) {
|
|
620
|
+
this.#closed = true;
|
|
621
|
+
for (const stream of this.#streams.values()) {
|
|
622
|
+
stream._destroy(err);
|
|
623
|
+
}
|
|
624
|
+
this.#streams.clear();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
#closeLocally(errorCode, reason) {
|
|
628
|
+
if (this.#closed) return;
|
|
629
|
+
try {
|
|
630
|
+
this.#send(encodeRecord(encodeConnectionClose({ application: false, errorCode, reason })));
|
|
631
|
+
} catch { /* best-effort */ }
|
|
632
|
+
this.#closed = true;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#emitError(err) {
|
|
636
|
+
try { this.onError?.(err); } catch (handlerErr) { console.error('[qmux] onError handler error:', handlerErr); }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
#wakeStreamOpenWaiters() {
|
|
640
|
+
while (
|
|
641
|
+
this.#streamOpenWaiters.length > 0 &&
|
|
642
|
+
(this.#peerMaxStreamsBidi === undefined || this.#streamsOpened < this.#peerMaxStreamsBidi)
|
|
643
|
+
) {
|
|
644
|
+
this.#streamOpenWaiters.shift()();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Connection-level MAX_DATA increased -- recheck every stream's send
|
|
650
|
+
* waiters, since any of them could have been blocked purely on the
|
|
651
|
+
* connection-level window rather than their own stream-level window.
|
|
652
|
+
*/
|
|
653
|
+
#wakeAllStreamSendWaiters() {
|
|
654
|
+
for (const stream of this.#streams.values()) {
|
|
655
|
+
stream._recheckSendWindow();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Account newly-delivered bytes against the connection-level receive
|
|
661
|
+
* window and top it up (MAX_DATA) once consumption crosses the same
|
|
662
|
+
* threshold used for individual streams' MAX_STREAM_DATA.
|
|
663
|
+
*/
|
|
664
|
+
#accountConnRecv(byteCount) {
|
|
665
|
+
if (byteCount <= 0) return;
|
|
666
|
+
this.#connRecvBufferedUpTo += byteCount;
|
|
667
|
+
const remaining = this.#connRecvWindowGranted - this.#connRecvBufferedUpTo;
|
|
668
|
+
if (remaining <= this.#connRecvWindow * (1 - WINDOW_UPDATE_THRESHOLD)) {
|
|
669
|
+
const newLimit = this.#connRecvBufferedUpTo + this.#connRecvWindow;
|
|
670
|
+
this.#connRecvWindowGranted = newLimit;
|
|
671
|
+
this.#send(encodeRecord(encodeMaxData(newLimit)));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Internal hooks used by QMuxStream ───────────────────────────
|
|
676
|
+
|
|
677
|
+
_sendFrame(frameBytes, dataLength) {
|
|
678
|
+
this.#send(encodeRecord(frameBytes));
|
|
679
|
+
this.#connSendOffset += dataLength;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
_connSendWindow() {
|
|
683
|
+
return this.#connSendWindow;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
_reserveConnSendWindow(n) {
|
|
687
|
+
this.#connSendWindow -= n;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
_sendDataBlocked() {
|
|
691
|
+
// Avoid spamming an identical DATA_BLOCKED for the same limit on
|
|
692
|
+
// every single blocked write -- only announce when the limit we're
|
|
693
|
+
// blocked on has actually changed since the last announcement.
|
|
694
|
+
if (this.#lastDataBlockedAt === this.#connSendOffset) return;
|
|
695
|
+
this.#lastDataBlockedAt = this.#connSendOffset;
|
|
696
|
+
this.#send(encodeRecord(encodeDataBlocked(this.#connSendOffset + this.#connSendWindow)));
|
|
697
|
+
}
|
|
698
|
+
}
|