@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/index.d.ts CHANGED
@@ -127,9 +127,6 @@ export const MSG: {
127
127
  readonly REVERSE_ACCEPT: 0x54;
128
128
  readonly REVERSE_REJECT: 0x55;
129
129
 
130
- // Framing
131
- readonly WS_DATA: 0x60;
132
-
133
130
  // Gateway
134
131
  readonly OPEN_TCP: 0x70;
135
132
  readonly OPEN_UDP: 0x71;
@@ -66,11 +66,6 @@ export const MSG = Object.freeze({
66
66
  // Session
67
67
  SESSION_LIST: 0x5f,
68
68
  DETACH: 0x60,
69
-
70
- // Framing
71
- WS_DATA: 0x60,
72
-
73
- // Session
74
69
  DETACH_OK: 0x61,
75
70
  DETACH_FAIL: 0x62,
76
71
 
@@ -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
- // NOTE: these are unrelated to MSG.WS_DATA (0x60) in messages.gen.mjs.
24
- // WS_DATA is a JS-only, CBOR-layer bookkeeping marker (it shares its
25
- // opcode with MSG.DETACH and is explicitly excluded from the Rust
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
- * Queue of raw inbound messages awaiting dispatch, plus a drain-in-progress
211
- * flag. Needed because some `WebSocket` implementations (notably Node's
212
- * `ws` package parsing several frames out of one TCP read) can fire
213
- * multiple synchronous `message` events within a single JS task, all
214
- * before any microtask gets to run. If we dispatched each message inline,
215
- * a handler that resolves a pending waiter (e.g. SERVER_HELLO resolving
216
- * the "wait for SERVER_HELLO or CHALLENGE" promise) wouldn't get a chance
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
- #messageQueue = [];
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.#messageQueue.length = 0;
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.#messageQueue.push(ev.data);
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
- * Drain `#messageQueue` one message at a time, yielding to the microtask
324
- * queue between each dispatch. See the `#messageQueue` field doc for why:
325
- * this is what lets a waiter registered by an earlier message's `await`
326
- * continuation (e.g. registering the CHALLENGE waiter after SERVER_HELLO
327
- * resolves) actually be in place before the next queued message arrives,
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
- for (const msg of messages) {
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
- for (const msg of messages) {
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) {