@johnhenry/wsh 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/transport-ws.mjs +25 -54
- package/src/transport.mjs +97 -16
package/package.json
CHANGED
package/src/transport-ws.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { frameEncode, FrameDecoder, FrameSizeError } from './cbor.mjs';
|
|
19
|
-
import { WshTransport } from './transport.mjs';
|
|
19
|
+
import { WshTransport, dispatchSerially, SerialQueue } from './transport.mjs';
|
|
20
20
|
|
|
21
21
|
// ── Frame type constants ─────────────────────────────────────────────
|
|
22
22
|
//
|
|
@@ -207,25 +207,15 @@ export class WebSocketTransport extends WshTransport {
|
|
|
207
207
|
#closedByUs = false;
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* to run its `await`'d continuation — which registers the *next* waiter
|
|
218
|
-
* (e.g. for CHALLENGE) — before the next queued message arrives. That
|
|
219
|
-
* continuation is a microtask; a same-task, no-yield dispatch loop starves
|
|
220
|
-
* it, so the next message's waiter isn't registered yet and gets dropped.
|
|
221
|
-
* Draining the queue with an `await Promise.resolve()` between each
|
|
222
|
-
* message yields to the microtask queue after every dispatch, and since
|
|
223
|
-
* microtasks run FIFO, any continuation queued by handling message N
|
|
224
|
-
* (which runs first) completes before message N+1 is dispatched.
|
|
225
|
-
* @type {ArrayBuffer[]}
|
|
210
|
+
* Serializes dispatch of raw inbound WebSocket messages. Needed because
|
|
211
|
+
* some `WebSocket` implementations (notably Node's `ws` package parsing
|
|
212
|
+
* several frames out of one TCP read) can fire multiple synchronous
|
|
213
|
+
* `message` events within a single JS task, all before any microtask
|
|
214
|
+
* gets to run — see `SerialQueue`'s doc comment in transport.mjs for
|
|
215
|
+
* why dispatching those inline would be unsafe.
|
|
216
|
+
* @type {SerialQueue}
|
|
226
217
|
*/
|
|
227
|
-
#
|
|
228
|
-
#draining = false;
|
|
218
|
+
#inbox = new SerialQueue((raw) => this.#handleMessage(raw));
|
|
229
219
|
|
|
230
220
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
231
221
|
|
|
@@ -236,8 +226,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
236
226
|
this.#nextLocalId = 1;
|
|
237
227
|
this.#openResolvers.clear();
|
|
238
228
|
this.#closedByUs = false;
|
|
239
|
-
this.#
|
|
240
|
-
this.#draining = false;
|
|
229
|
+
this.#inbox.clear();
|
|
241
230
|
|
|
242
231
|
return new Promise((resolve, reject) => {
|
|
243
232
|
// Normalize URL scheme: wsh:// → wss://, http:// → ws://.
|
|
@@ -265,8 +254,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
265
254
|
});
|
|
266
255
|
|
|
267
256
|
ws.addEventListener('message', (ev) => {
|
|
268
|
-
this.#
|
|
269
|
-
this.#drainMessageQueue();
|
|
257
|
+
this.#inbox.push(ev.data);
|
|
270
258
|
});
|
|
271
259
|
});
|
|
272
260
|
}
|
|
@@ -320,33 +308,14 @@ export class WebSocketTransport extends WshTransport {
|
|
|
320
308
|
// ── Inbound message dispatch ───────────────────────────────────────
|
|
321
309
|
|
|
322
310
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* even when the underlying WebSocket implementation delivered several
|
|
329
|
-
* messages synchronously in one task.
|
|
330
|
-
*/
|
|
331
|
-
async #drainMessageQueue() {
|
|
332
|
-
if (this.#draining) return;
|
|
333
|
-
this.#draining = true;
|
|
334
|
-
try {
|
|
335
|
-
while (this.#messageQueue.length > 0) {
|
|
336
|
-
const raw = this.#messageQueue.shift();
|
|
337
|
-
this.#handleMessage(raw);
|
|
338
|
-
await Promise.resolve();
|
|
339
|
-
}
|
|
340
|
-
} finally {
|
|
341
|
-
this.#draining = false;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Handle a raw WebSocket message (ArrayBuffer).
|
|
311
|
+
* Handle a raw WebSocket message (ArrayBuffer). Called serially by
|
|
312
|
+
* `#inbox` — never invoked directly from the `message` event listener.
|
|
313
|
+
* Async (and awaited by `#inbox`'s SerialQueue) because the
|
|
314
|
+
* FRAME_CONTROL case can itself dispatch several decoded messages —
|
|
315
|
+
* see `#handleControlFrame`.
|
|
347
316
|
* @param {ArrayBuffer} raw
|
|
348
317
|
*/
|
|
349
|
-
#handleMessage(raw) {
|
|
318
|
+
async #handleMessage(raw) {
|
|
350
319
|
if (typeof raw === 'string') {
|
|
351
320
|
this._emitError(new Error('Received text WebSocket frame; expected binary'));
|
|
352
321
|
return;
|
|
@@ -363,7 +332,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
363
332
|
|
|
364
333
|
switch (frame.type) {
|
|
365
334
|
case FRAME_CONTROL:
|
|
366
|
-
this.#handleControlFrame(frame.payload);
|
|
335
|
+
await this.#handleControlFrame(frame.payload);
|
|
367
336
|
break;
|
|
368
337
|
case FRAME_DATA:
|
|
369
338
|
this.#handleDataFrame(frame.streamId, frame.payload);
|
|
@@ -380,10 +349,14 @@ export class WebSocketTransport extends WshTransport {
|
|
|
380
349
|
}
|
|
381
350
|
|
|
382
351
|
/**
|
|
383
|
-
* Decode CBOR-framed control messages from stream 0.
|
|
352
|
+
* Decode CBOR-framed control messages from stream 0. A single mux
|
|
353
|
+
* frame's payload can decode into more than one protocol message (the
|
|
354
|
+
* CBOR decoder is stateful/streaming), so dispatch uses
|
|
355
|
+
* `dispatchSerially` — see its doc comment for why a plain for-loop
|
|
356
|
+
* here would be unsafe.
|
|
384
357
|
* @param {Uint8Array} payload
|
|
385
358
|
*/
|
|
386
|
-
#handleControlFrame(payload) {
|
|
359
|
+
async #handleControlFrame(payload) {
|
|
387
360
|
let messages;
|
|
388
361
|
try {
|
|
389
362
|
messages = this.#decoder.feed(payload);
|
|
@@ -396,9 +369,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
396
369
|
}
|
|
397
370
|
return;
|
|
398
371
|
}
|
|
399
|
-
|
|
400
|
-
this._emitControl(msg);
|
|
401
|
-
}
|
|
372
|
+
await dispatchSerially(messages, (msg) => this._emitControl(msg));
|
|
402
373
|
}
|
|
403
374
|
|
|
404
375
|
/**
|
package/src/transport.mjs
CHANGED
|
@@ -7,6 +7,97 @@
|
|
|
7
7
|
|
|
8
8
|
import { frameEncode, FrameDecoder, cborEncode, FrameSizeError } from './cbor.mjs';
|
|
9
9
|
|
|
10
|
+
// ── Serial dispatch (shared by both transports) ───────────────────────
|
|
11
|
+
//
|
|
12
|
+
// A push-based transport can hand this library more than one decoded
|
|
13
|
+
// protocol message at once — either because several arrived in a single
|
|
14
|
+
// underlying read (e.g. multiple WebSocket `message` events fired
|
|
15
|
+
// synchronously from one TCP read, or one CBOR decoder call returning
|
|
16
|
+
// several frames from one already-received chunk) — with no guaranteed
|
|
17
|
+
// yield to the microtask queue between them. That matters because a
|
|
18
|
+
// message handler can be `async`: it resolves a pending waiter
|
|
19
|
+
// synchronously, but the code that runs *after* that resolution (e.g.
|
|
20
|
+
// registering the *next* waiter) only runs as a microtask continuation.
|
|
21
|
+
// Dispatching message N+1 before that continuation gets a turn means
|
|
22
|
+
// N+1 arrives with state that N's handling hasn't finished setting up
|
|
23
|
+
// yet — most visibly, a dropped CHALLENGE that arrived batched with
|
|
24
|
+
// SERVER_HELLO (see CHANGELOG 0.3.0). `dispatchSerially` and
|
|
25
|
+
// `SerialQueue` both fix this the same way: yield to the microtask
|
|
26
|
+
// queue after every single dispatch, so FIFO microtask ordering
|
|
27
|
+
// guarantees any continuation queued by message N runs before message
|
|
28
|
+
// N+1 is handled.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Invoke `handler` once per item in `items`, awaiting each call before
|
|
32
|
+
* starting the next. Use this for a *fixed* batch that's already fully
|
|
33
|
+
* available (e.g. everything a single decoder.feed() call returned) —
|
|
34
|
+
* see `SerialQueue` for items that arrive incrementally over time via a
|
|
35
|
+
* push callback.
|
|
36
|
+
*
|
|
37
|
+
* `handler` may be sync or async — either way, `await handler(item)`
|
|
38
|
+
* yields to the microtask queue at least once before the next item is
|
|
39
|
+
* dispatched (awaiting any value does, even a plain `undefined`), and if
|
|
40
|
+
* `handler` is itself async, this also waits for its *own* internal work
|
|
41
|
+
* (including any nested dispatchSerially/SerialQueue use) to fully
|
|
42
|
+
* settle first. That matters when a handler for item N does async work
|
|
43
|
+
* that must complete before N+1 is safe to dispatch — a fire-and-forget
|
|
44
|
+
* (non-awaited) handler call wouldn't provide that guarantee, only a
|
|
45
|
+
* same-tick yield.
|
|
46
|
+
* @param {Iterable<*>} items
|
|
47
|
+
* @param {(item: *) => (void | Promise<void>)} handler
|
|
48
|
+
*/
|
|
49
|
+
export async function dispatchSerially(items, handler) {
|
|
50
|
+
for (const item of items) {
|
|
51
|
+
await handler(item);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A queue drained one item at a time, awaiting each dispatch before
|
|
57
|
+
* starting the next (see `dispatchSerially`'s doc comment for exactly
|
|
58
|
+
* what that guarantees for sync vs. async handlers). Use this where
|
|
59
|
+
* items arrive incrementally over time via a push callback (e.g. a
|
|
60
|
+
* transport's raw `message` event) rather than as one fixed,
|
|
61
|
+
* already-available batch (see `dispatchSerially` for that case).
|
|
62
|
+
* `push()` is safe to call at any time, including while a previous
|
|
63
|
+
* drain is still in progress — the same drain loop just picks up the
|
|
64
|
+
* newly-pushed item in its next iteration.
|
|
65
|
+
*/
|
|
66
|
+
export class SerialQueue {
|
|
67
|
+
#items = [];
|
|
68
|
+
#handler;
|
|
69
|
+
#draining = false;
|
|
70
|
+
|
|
71
|
+
/** @param {(item: *) => (void | Promise<void>)} handler */
|
|
72
|
+
constructor(handler) {
|
|
73
|
+
this.#handler = handler;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @param {*} item */
|
|
77
|
+
push(item) {
|
|
78
|
+
this.#items.push(item);
|
|
79
|
+
this.#drain();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Discard any queued-but-not-yet-dispatched items (e.g. on reconnect). */
|
|
83
|
+
clear() {
|
|
84
|
+
this.#items.length = 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async #drain() {
|
|
88
|
+
if (this.#draining) return;
|
|
89
|
+
this.#draining = true;
|
|
90
|
+
try {
|
|
91
|
+
while (this.#items.length > 0) {
|
|
92
|
+
const item = this.#items.shift();
|
|
93
|
+
await this.#handler(item);
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
this.#draining = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
10
101
|
// ── Transport states ─────────────────────────────────────────────────
|
|
11
102
|
|
|
12
103
|
const STATE_DISCONNECTED = 'disconnected';
|
|
@@ -278,23 +369,13 @@ export class WebTransportTransport extends WshTransport {
|
|
|
278
369
|
const { done, value } = await reader.read();
|
|
279
370
|
if (done) break;
|
|
280
371
|
|
|
372
|
+
// A QUIC stream has no per-message framing at the transport layer,
|
|
373
|
+
// so a single read() can return bytes for several messages at
|
|
374
|
+
// once (e.g. SERVER_HELLO immediately followed by CHALLENGE).
|
|
375
|
+
// dispatchSerially yields between each — see its doc comment for
|
|
376
|
+
// why that matters.
|
|
281
377
|
const messages = this.#decoder.feed(value);
|
|
282
|
-
|
|
283
|
-
this._emitControl(msg);
|
|
284
|
-
// Yield to the microtask queue between messages. A QUIC stream
|
|
285
|
-
// has no per-message framing at the transport layer, so a single
|
|
286
|
-
// read() can return bytes for several messages at once (e.g.
|
|
287
|
-
// SERVER_HELLO immediately followed by CHALLENGE). Emitting them
|
|
288
|
-
// in a tight synchronous loop starves any `await`'d continuation
|
|
289
|
-
// triggered by handling an earlier message (e.g. registering the
|
|
290
|
-
// CHALLENGE waiter after SERVER_HELLO resolves) — that
|
|
291
|
-
// continuation is a microtask, and without a yield here it never
|
|
292
|
-
// gets to run before the next message is dispatched. Since
|
|
293
|
-
// microtasks run FIFO, a continuation queued while handling msg
|
|
294
|
-
// N always completes before this yield's own continuation
|
|
295
|
-
// resumes to dispatch msg N+1.
|
|
296
|
-
await Promise.resolve();
|
|
297
|
-
}
|
|
378
|
+
await dispatchSerially(messages, (msg) => this._emitControl(msg));
|
|
298
379
|
}
|
|
299
380
|
} catch (err) {
|
|
300
381
|
if (!this.#abort.signal.aborted) {
|