@johnhenry/wsh 0.2.0 → 0.3.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.2.0",
3
+ "version": "0.3.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",
@@ -206,6 +206,27 @@ export class WebSocketTransport extends WshTransport {
206
206
  /** Tracks whether we initiated the close. */
207
207
  #closedByUs = false;
208
208
 
209
+ /**
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[]}
226
+ */
227
+ #messageQueue = [];
228
+ #draining = false;
229
+
209
230
  // ── Lifecycle ──────────────────────────────────────────────────────
210
231
 
211
232
  /** @override */
@@ -215,6 +236,8 @@ export class WebSocketTransport extends WshTransport {
215
236
  this.#nextLocalId = 1;
216
237
  this.#openResolvers.clear();
217
238
  this.#closedByUs = false;
239
+ this.#messageQueue.length = 0;
240
+ this.#draining = false;
218
241
 
219
242
  return new Promise((resolve, reject) => {
220
243
  // Normalize URL scheme: wsh:// → wss://, http:// → ws://.
@@ -242,7 +265,8 @@ export class WebSocketTransport extends WshTransport {
242
265
  });
243
266
 
244
267
  ws.addEventListener('message', (ev) => {
245
- this.#handleMessage(ev.data);
268
+ this.#messageQueue.push(ev.data);
269
+ this.#drainMessageQueue();
246
270
  });
247
271
  });
248
272
  }
@@ -295,6 +319,29 @@ export class WebSocketTransport extends WshTransport {
295
319
 
296
320
  // ── Inbound message dispatch ───────────────────────────────────────
297
321
 
322
+ /**
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
+
298
345
  /**
299
346
  * Handle a raw WebSocket message (ArrayBuffer).
300
347
  * @param {ArrayBuffer} raw
package/src/transport.mjs CHANGED
@@ -281,6 +281,19 @@ export class WebTransportTransport extends WshTransport {
281
281
  const messages = this.#decoder.feed(value);
282
282
  for (const msg of messages) {
283
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();
284
297
  }
285
298
  }
286
299
  } catch (err) {