@johnhenry/wsh 0.4.0 → 0.6.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/index.d.ts +0 -3
- package/src/messages.gen.mjs +0 -5
- package/src/transport-ws.mjs +28 -61
- package/src/transport.mjs +97 -16
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
package/src/messages.gen.mjs
CHANGED
package/src/transport-ws.mjs
CHANGED
|
@@ -16,17 +16,13 @@
|
|
|
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
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// message enum by spec/codegen.mjs) — it is never sent as the outer
|
|
27
|
-
// envelope's frame-type byte below. See spec/wsh-v1.yaml's
|
|
28
|
-
// `transport.websocket` section for the authoritative outer-envelope
|
|
29
|
-
// framing docs.
|
|
23
|
+
// Unrelated to the CBOR-layer MSG opcodes (messages.gen.mjs) -- these are
|
|
24
|
+
// the outer envelope's frame-type byte. See spec/wsh-v1.yaml's
|
|
25
|
+
// `transport.websocket` section for the authoritative framing docs.
|
|
30
26
|
|
|
31
27
|
const FRAME_CONTROL = 0x01;
|
|
32
28
|
const FRAME_DATA = 0x02;
|
|
@@ -207,25 +203,15 @@ export class WebSocketTransport extends WshTransport {
|
|
|
207
203
|
#closedByUs = false;
|
|
208
204
|
|
|
209
205
|
/**
|
|
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[]}
|
|
206
|
+
* Serializes dispatch of raw inbound WebSocket messages. Needed because
|
|
207
|
+
* some `WebSocket` implementations (notably Node's `ws` package parsing
|
|
208
|
+
* several frames out of one TCP read) can fire multiple synchronous
|
|
209
|
+
* `message` events within a single JS task, all before any microtask
|
|
210
|
+
* gets to run — see `SerialQueue`'s doc comment in transport.mjs for
|
|
211
|
+
* why dispatching those inline would be unsafe.
|
|
212
|
+
* @type {SerialQueue}
|
|
226
213
|
*/
|
|
227
|
-
#
|
|
228
|
-
#draining = false;
|
|
214
|
+
#inbox = new SerialQueue((raw) => this.#handleMessage(raw));
|
|
229
215
|
|
|
230
216
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
231
217
|
|
|
@@ -236,8 +222,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
236
222
|
this.#nextLocalId = 1;
|
|
237
223
|
this.#openResolvers.clear();
|
|
238
224
|
this.#closedByUs = false;
|
|
239
|
-
this.#
|
|
240
|
-
this.#draining = false;
|
|
225
|
+
this.#inbox.clear();
|
|
241
226
|
|
|
242
227
|
return new Promise((resolve, reject) => {
|
|
243
228
|
// Normalize URL scheme: wsh:// → wss://, http:// → ws://.
|
|
@@ -265,8 +250,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
265
250
|
});
|
|
266
251
|
|
|
267
252
|
ws.addEventListener('message', (ev) => {
|
|
268
|
-
this.#
|
|
269
|
-
this.#drainMessageQueue();
|
|
253
|
+
this.#inbox.push(ev.data);
|
|
270
254
|
});
|
|
271
255
|
});
|
|
272
256
|
}
|
|
@@ -320,33 +304,14 @@ export class WebSocketTransport extends WshTransport {
|
|
|
320
304
|
// ── Inbound message dispatch ───────────────────────────────────────
|
|
321
305
|
|
|
322
306
|
/**
|
|
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).
|
|
307
|
+
* Handle a raw WebSocket message (ArrayBuffer). Called serially by
|
|
308
|
+
* `#inbox` — never invoked directly from the `message` event listener.
|
|
309
|
+
* Async (and awaited by `#inbox`'s SerialQueue) because the
|
|
310
|
+
* FRAME_CONTROL case can itself dispatch several decoded messages —
|
|
311
|
+
* see `#handleControlFrame`.
|
|
347
312
|
* @param {ArrayBuffer} raw
|
|
348
313
|
*/
|
|
349
|
-
#handleMessage(raw) {
|
|
314
|
+
async #handleMessage(raw) {
|
|
350
315
|
if (typeof raw === 'string') {
|
|
351
316
|
this._emitError(new Error('Received text WebSocket frame; expected binary'));
|
|
352
317
|
return;
|
|
@@ -363,7 +328,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
363
328
|
|
|
364
329
|
switch (frame.type) {
|
|
365
330
|
case FRAME_CONTROL:
|
|
366
|
-
this.#handleControlFrame(frame.payload);
|
|
331
|
+
await this.#handleControlFrame(frame.payload);
|
|
367
332
|
break;
|
|
368
333
|
case FRAME_DATA:
|
|
369
334
|
this.#handleDataFrame(frame.streamId, frame.payload);
|
|
@@ -380,10 +345,14 @@ export class WebSocketTransport extends WshTransport {
|
|
|
380
345
|
}
|
|
381
346
|
|
|
382
347
|
/**
|
|
383
|
-
* Decode CBOR-framed control messages from stream 0.
|
|
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
|
|
351
|
+
* `dispatchSerially` — see its doc comment for why a plain for-loop
|
|
352
|
+
* here would be unsafe.
|
|
384
353
|
* @param {Uint8Array} payload
|
|
385
354
|
*/
|
|
386
|
-
#handleControlFrame(payload) {
|
|
355
|
+
async #handleControlFrame(payload) {
|
|
387
356
|
let messages;
|
|
388
357
|
try {
|
|
389
358
|
messages = this.#decoder.feed(payload);
|
|
@@ -396,9 +365,7 @@ export class WebSocketTransport extends WshTransport {
|
|
|
396
365
|
}
|
|
397
366
|
return;
|
|
398
367
|
}
|
|
399
|
-
|
|
400
|
-
this._emitControl(msg);
|
|
401
|
-
}
|
|
368
|
+
await dispatchSerially(messages, (msg) => this._emitControl(msg));
|
|
402
369
|
}
|
|
403
370
|
|
|
404
371
|
/**
|
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) {
|