@statewalker/webrun-http-streams 0.3.1 → 0.3.2

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.
Files changed (3) hide show
  1. package/dist/index.js +73 -10
  2. package/package.json +1 -1
  3. package/src/fetch.ts +84 -12
package/dist/index.js CHANGED
@@ -1278,7 +1278,7 @@ async function fetchOverDuplex(call, request, options = {}) {
1278
1278
  else await release?.();
1279
1279
  return new Response(null, responseInit);
1280
1280
  }
1281
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
1281
+ return new Response(responseBody(respBody, request.signal, release), responseInit);
1282
1282
  }
1283
1283
  /**
1284
1284
  * Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
@@ -1409,15 +1409,78 @@ function trackingEnd(source) {
1409
1409
  settle
1410
1410
  };
1411
1411
  }
1412
- async function* withAbort(iter, signal) {
1413
- if (!signal) {
1414
- yield* iter;
1415
- return;
1416
- }
1417
- for await (const chunk of iter) {
1418
- if (signal.aborted) throw abortReason(signal);
1419
- yield chunk;
1420
- }
1412
+ /**
1413
+ * How long a cancelled body waits for the read in flight before it abandons the call anyway.
1414
+ * The read's answer tells a body that has ended (the call is finished: an upload the handler
1415
+ * reads after answering goes on) from one that has not; a peer that sends nothing more never
1416
+ * answers it. A body whose end comes later than this after the cancel is abandoned too, so a
1417
+ * handler still streaming its response and still reading the upload then loses the upload.
1418
+ */
1419
+ const CANCEL_GRACE_MS = 1e3;
1420
+ /**
1421
+ * The response body as a stream that the request's signal can end at any time.
1422
+ *
1423
+ * An abort errors the stream with the abort's reason at once — a read waiting
1424
+ * for a peer that sends nothing more rejects then, not when a next chunk
1425
+ * arrives, which may be never — and abandons the call (`release`), so on a
1426
+ * multiplexed transport its port is given back whether or not anyone still
1427
+ * reads the body. The listener is removed once the body ends, however it ends:
1428
+ * read to its end, cancelled, failed or aborted. A body nobody reads or cancels
1429
+ * keeps it until the signal aborts (and keeps the call open until then anyway).
1430
+ * A cancel abandons the call too — unless the read in flight comes back with the
1431
+ * body's end, which finishes it instead (see `cancel`).
1432
+ */
1433
+ function responseBody(body, signal, release) {
1434
+ const it = body[Symbol.asyncIterator]();
1435
+ let controller;
1436
+ let ended = false;
1437
+ let reading = false;
1438
+ const end = () => {
1439
+ ended = true;
1440
+ signal?.removeEventListener("abort", onAbort);
1441
+ };
1442
+ const onAbort = () => {
1443
+ if (ended) return;
1444
+ end();
1445
+ try {
1446
+ controller.error(abortReason(signal));
1447
+ } catch {}
1448
+ release?.();
1449
+ };
1450
+ return new ReadableStream({
1451
+ start(c) {
1452
+ controller = c;
1453
+ if (signal?.aborted) onAbort();
1454
+ else signal?.addEventListener("abort", onAbort, { once: true });
1455
+ },
1456
+ async pull(c) {
1457
+ try {
1458
+ reading = true;
1459
+ const { done, value } = await it.next().finally(() => {
1460
+ reading = false;
1461
+ });
1462
+ if (ended) return;
1463
+ if (done) {
1464
+ end();
1465
+ c.close();
1466
+ return;
1467
+ }
1468
+ c.enqueue(value);
1469
+ } catch (err) {
1470
+ if (ended) return;
1471
+ end();
1472
+ c.error(err);
1473
+ }
1474
+ },
1475
+ cancel() {
1476
+ end();
1477
+ const returned = Promise.resolve(it.return?.()).catch(() => {});
1478
+ if (!reading) return;
1479
+ const grace = setTimeout(() => void release?.(), CANCEL_GRACE_MS);
1480
+ grace.unref?.();
1481
+ returned.finally(() => clearTimeout(grace));
1482
+ }
1483
+ });
1421
1484
  }
1422
1485
  //#endregion
1423
1486
  //#region src/duplex-site-builder.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-streams",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "HTTP request/response over a webrun-streams Duplex, encoded as HTTP/1.1, plus transport-agnostic fetch stubs",
package/src/fetch.ts CHANGED
@@ -155,7 +155,7 @@ export async function fetchOverDuplex(
155
155
  else await release?.();
156
156
  return new Response(null, responseInit);
157
157
  }
158
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
158
+ return new Response(responseBody(respBody, request.signal, release), responseInit);
159
159
  }
160
160
 
161
161
  /**
@@ -329,16 +329,88 @@ function trackingEnd(source: AsyncIterable<Uint8Array>): {
329
329
  return { source: tracked, settle };
330
330
  }
331
331
 
332
- async function* withAbort(
333
- iter: AsyncIterable<Uint8Array>,
332
+ /**
333
+ * How long a cancelled body waits for the read in flight before it abandons the call anyway.
334
+ * The read's answer tells a body that has ended (the call is finished: an upload the handler
335
+ * reads after answering goes on) from one that has not; a peer that sends nothing more never
336
+ * answers it. A body whose end comes later than this after the cancel is abandoned too, so a
337
+ * handler still streaming its response and still reading the upload then loses the upload.
338
+ */
339
+ const CANCEL_GRACE_MS = 1_000;
340
+
341
+ /**
342
+ * The response body as a stream that the request's signal can end at any time.
343
+ *
344
+ * An abort errors the stream with the abort's reason at once — a read waiting
345
+ * for a peer that sends nothing more rejects then, not when a next chunk
346
+ * arrives, which may be never — and abandons the call (`release`), so on a
347
+ * multiplexed transport its port is given back whether or not anyone still
348
+ * reads the body. The listener is removed once the body ends, however it ends:
349
+ * read to its end, cancelled, failed or aborted. A body nobody reads or cancels
350
+ * keeps it until the signal aborts (and keeps the call open until then anyway).
351
+ * A cancel abandons the call too — unless the read in flight comes back with the
352
+ * body's end, which finishes it instead (see `cancel`).
353
+ */
354
+ function responseBody(
355
+ body: AsyncIterable<Uint8Array>,
334
356
  signal: AbortSignal | undefined,
335
- ): AsyncGenerator<Uint8Array> {
336
- if (!signal) {
337
- yield* iter;
338
- return;
339
- }
340
- for await (const chunk of iter) {
341
- if (signal.aborted) throw abortReason(signal);
342
- yield chunk;
343
- }
357
+ release: (() => Promise<void>) | undefined,
358
+ ): ReadableStream<Uint8Array> {
359
+ const it = body[Symbol.asyncIterator]();
360
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
361
+ let ended = false;
362
+ let reading = false;
363
+ const end = () => {
364
+ ended = true;
365
+ signal?.removeEventListener("abort", onAbort);
366
+ };
367
+ const onAbort = () => {
368
+ if (ended) return;
369
+ end();
370
+ try {
371
+ controller.error(abortReason(signal as AbortSignal));
372
+ } catch {
373
+ /* already closed or errored */
374
+ }
375
+ void release?.();
376
+ };
377
+ return new ReadableStream<Uint8Array>({
378
+ start(c) {
379
+ controller = c;
380
+ if (signal?.aborted) onAbort();
381
+ else signal?.addEventListener("abort", onAbort, { once: true });
382
+ },
383
+ async pull(c) {
384
+ try {
385
+ reading = true;
386
+ const { done, value } = await it.next().finally(() => {
387
+ reading = false;
388
+ });
389
+ if (ended) return; // aborted or cancelled while this read waited
390
+ if (done) {
391
+ end();
392
+ c.close();
393
+ return;
394
+ }
395
+ c.enqueue(value);
396
+ } catch (err) {
397
+ if (ended) return;
398
+ end();
399
+ c.error(err);
400
+ }
401
+ },
402
+ cancel() {
403
+ end();
404
+ const returned = Promise.resolve(it.return?.()).catch(() => {});
405
+ if (!reading) return; // `it` is at rest: its return abandons the call (releasingOnExit)
406
+ // A read is in flight, and `it.return()` is queued behind it: that read's answer decides.
407
+ // The body's end → the call is finished, not abandoned, so an upload the handler reads
408
+ // after answering goes on; a chunk → the return then abandons the call. A peer that never
409
+ // answers (sends nothing more, for good) would hold the call forever: abandon it after a
410
+ // grace, without waiting in `cancel()` either way.
411
+ const grace = setTimeout(() => void release?.(), CANCEL_GRACE_MS);
412
+ (grace as { unref?: () => void }).unref?.();
413
+ void returned.finally(() => clearTimeout(grace));
414
+ },
415
+ });
344
416
  }