@statewalker/webrun-http-streams 0.3.0 → 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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Makes `target` disposable with `await using`: installs `dispose` as its
3
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
4
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
5
+ * this gives it back. A no-op where the symbol is missing. Kept off the
6
+ * declared types, which would require consumers to compile with the
7
+ * `esnext.disposable` lib.
8
+ */
9
+ export declare function asyncDisposable<T extends object>(target: T, dispose: () => Promise<unknown>): T;
10
+ //# sourceMappingURL=async-dispose.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"async-dispose.d.ts","sourceRoot":"","sources":["../src/async-dispose.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAa/F"}
package/dist/fetch.d.ts CHANGED
@@ -11,4 +11,6 @@ export declare function fetchOverDuplex(call: Duplex, request: Request, options?
11
11
  * registered with any `webrun-streams-*` adapter's `serve`.
12
12
  */
13
13
  export declare function serveFetchOverDuplex(handler: (request: Request) => Promise<Response>, options?: HttpDataOptions): Duplex;
14
+ /** How long a request body may sit unread after the response before it is abandoned. */
15
+ export declare const DEFAULT_REQUEST_IDLE_MS = 30000;
14
16
  //# sourceMappingURL=fetch.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAE1D,OAAO,EAAE,KAAK,eAAe,EAAwB,MAAM,gBAAgB,CAAC;AAsF5E;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,QAAQ,CAAC,CAiDnB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,EAChD,OAAO,GAAE,eAAoB,GAC5B,MAAM,CAqDR"}
1
+ {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAG1D,OAAO,EAAe,KAAK,eAAe,EAAwB,MAAM,gBAAgB,CAAC;AAsFzF;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,QAAQ,CAAC,CA4DnB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,EAChD,OAAO,GAAE,eAAoB,GAC5B,MAAM,CAwER;AAED,wFAAwF;AACxF,eAAO,MAAM,uBAAuB,QAAS,CAAC"}
@@ -5,6 +5,24 @@ export declare const PEER_ERROR_HEADER = "x-webrun-error";
5
5
  export type HttpDataOptions = {
6
6
  /** Wire format. Defaults to `defaultCodec`: writes HTTP/1.1, accepts either. */
7
7
  codec?: MessageCodec;
8
+ /**
9
+ * `httpFetch` only: aborting it while the response head is awaited releases
10
+ * the call and rejects with the signal's reason. (Once the head is in, the
11
+ * body is the caller's to leave.)
12
+ */
13
+ signal?: AbortSignal;
14
+ /**
15
+ * `serveFetchOverDuplex` only: once the response is out, how long the request
16
+ * body may go unread before it is abandoned and the exchange ends (default
17
+ * 30 s). A handler that answers first and reads the upload afterwards keeps
18
+ * it as long as it keeps reading — the time it spends on one chunk before it
19
+ * asks for the next counts as idle, so a handler that pauses longer between
20
+ * reads (one slow write per chunk) needs a larger bound; one that took the
21
+ * body and dropped it would otherwise keep the exchange — on a multiplexed
22
+ * transport, its port — open for good. `Infinity` waits for as long as it
23
+ * takes.
24
+ */
25
+ requestIdleMs?: number;
8
26
  };
9
27
  export interface HttpFetchResult {
10
28
  envelope: ResponseEnvelope;
@@ -17,17 +35,35 @@ export interface HttpFetchResult {
17
35
  * custom transports) still type-check; `httpFetch` always sets it.
18
36
  */
19
37
  release?(): Promise<void>;
38
+ /**
39
+ * The caller is done with a response it took in full — read to its end, or
40
+ * one with no body to read (HEAD, 204, 304...): the call is read to its end
41
+ * in the background, so the peer ends it once its side is over (it may still
42
+ * be reading the request body). Reading `body` to its end does this by
43
+ * itself. Optional, like `release`; `httpFetch` always sets it.
44
+ */
45
+ finish?(): void;
20
46
  }
21
47
  export interface HttpDataHandlerResult {
22
48
  envelope: ResponseEnvelope;
23
49
  body?: ByteSource;
24
50
  /**
25
- * Called when the caller abandons the response before its end. A body the
26
- * codec never started pulling cannot be released through the iterator
27
- * protocol (`.return()` on a generator in suspended start does nothing), so
28
- * a producer that holds resources says here how to let them go.
51
+ * Called when the caller abandons the response before its end — never once
52
+ * the response went out in full. A body the codec never started pulling
53
+ * cannot be released through the iterator protocol (`.return()` on a
54
+ * generator in suspended start does nothing), so a producer that holds
55
+ * resources says here how to let them go.
29
56
  */
30
57
  release?: () => unknown;
58
+ /**
59
+ * Awaited once the response went out in full: the exchange — and with it
60
+ * the call, and on a multiplexed transport its port — ends when it
61
+ * resolves. A handler that answers before it has read the request body, and
62
+ * goes on reading it, resolves this once it is done with it; until then the
63
+ * caller keeps sending. Left out, the exchange ends with the response, and
64
+ * the rest of the request is dropped.
65
+ */
66
+ settled?: () => Promise<unknown>;
31
67
  }
32
68
  /**
33
69
  * Low-level HTTP-over-Duplex handler. Takes the request envelope plus body
@@ -47,6 +83,8 @@ export type HttpDataHandler = (env: RequestEnvelope, body: AsyncIterable<Uint8Ar
47
83
  * calls is the adapter's concern (native or `emulateMux`).
48
84
  */
49
85
  export declare function httpFetch(call: Duplex, env: RequestEnvelope, body?: ByteSource, options?: HttpDataOptions): Promise<HttpFetchResult>;
86
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
87
+ export declare function abortReason(signal: AbortSignal): Error;
50
88
  /**
51
89
  * Wrap an HTTP handler as a `Duplex` so it can be registered with any
52
90
  * `webrun-streams-*` adapter's `serve`.
@@ -1 +1 @@
1
- {"version":3,"file":"http-data.d.ts","sourceRoot":"","sources":["../src/http-data.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAK1D,OAAO,KAAK,EACV,UAAU,EAEV,YAAY,EACZ,eAAe,EACf,gBAAgB,EACjB,MAAM,cAAc,CAAC;AAEtB,gEAAgE;AAChE,eAAO,MAAM,iBAAiB,mBAAmB,CAAC;AAElD,MAAM,MAAM,eAAe,GAAG;IAC5B,gFAAgF;IAChF,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB,CAAC;AAEF,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IAChC;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,CAC5B,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,KAC5B,OAAO,CAAC,qBAAqB,CAAC,CAAC;AAgGpC;;;;;;;GAOG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,eAAe,EACpB,IAAI,CAAC,EAAE,UAAU,EACjB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,eAAe,CAAC,CAkB1B;AA0BD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,eAAe,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CAoDzF"}
1
+ {"version":3,"file":"http-data.d.ts","sourceRoot":"","sources":["../src/http-data.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAK1D,OAAO,KAAK,EACV,UAAU,EAEV,YAAY,EACZ,eAAe,EACf,gBAAgB,EACjB,MAAM,cAAc,CAAC;AAEtB,gEAAgE;AAChE,eAAO,MAAM,iBAAiB,mBAAmB,CAAC;AAElD,MAAM,MAAM,eAAe,GAAG;IAC5B,gFAAgF;IAChF,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB;;;;OAIG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IAChC;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B;;;;;;OAMG;IACH,MAAM,CAAC,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC;IACxB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CAClC;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,CAC5B,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,KAC5B,OAAO,CAAC,qBAAqB,CAAC,CAAC;AAgGpC;;;;;;;GAOG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,eAAe,EACpB,IAAI,CAAC,EAAE,UAAU,EACjB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,eAAe,CAAC,CA0D1B;AAsBD,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,KAAK,CAMtD;AA4CD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,eAAe,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CAqDzF"}
@@ -1 +1 @@
1
- {"version":3,"file":"http-stubs.d.ts","sourceRoot":"","sources":["../src/http-stubs.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,WAAW,CAAC,EAAE,kBAAkB,CAAC;IACjC,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,sBAAsB,CAAC,OAAO;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;OAUG;IACH,OAAO,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;CACpC;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAK7E,eAAO,MAAM,kBAAkB,aAAqC,CAAC;AAsBrE;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,CACJ,QAAQ,EAAE,sBAAsB,CAAC,qBAAqB,CAAC,KACpD,OAAO,CAAC,sBAAsB,CAAC,sBAAsB,CAAC,GAAG,SAAS,CAAC,GACvE,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAmD5D;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,WAAW,GACnB,CACD,QAAQ,EACJ,sBAAsB,CAAC,qBAAqB,CAAC,GAC7C,OAAO,CAAC,sBAAsB,CAAC,qBAAqB,CAAC,CAAC,KACvD,OAAO,CAAC,sBAAsB,CAAC,sBAAsB,CAAC,CAAC,CA0D3D"}
1
+ {"version":3,"file":"http-stubs.d.ts","sourceRoot":"","sources":["../src/http-stubs.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,WAAW,CAAC,EAAE,kBAAkB,CAAC;IACjC,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,sBAAsB,CAAC,OAAO;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;OAUG;IACH,OAAO,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;CACpC;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAK7E,eAAO,MAAM,kBAAkB,aAAqD,CAAC;AAsBrF;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,CACJ,QAAQ,EAAE,sBAAsB,CAAC,qBAAqB,CAAC,KACpD,OAAO,CAAC,sBAAsB,CAAC,sBAAsB,CAAC,GAAG,SAAS,CAAC,GACvE,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAmD5D;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,WAAW,GACnB,CACD,QAAQ,EACJ,sBAAsB,CAAC,qBAAqB,CAAC,GAC7C,OAAO,CAAC,sBAAsB,CAAC,qBAAqB,CAAC,CAAC,KACvD,OAAO,CAAC,sBAAsB,CAAC,sBAAsB,CAAC,CAAC,CA0D3D"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http1/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAKlD,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAC1B,oFAAoF;IACpF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,wBAAgB,YAAY,CAAC,OAAO,GAAE,gBAAqB,GAAG,YAAY,CAiBzE;AAED,eAAO,MAAM,SAAS,EAAE,YAA6B,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http1/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAKlD,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAC1B,oFAAoF;IACpF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,wBAAgB,YAAY,CAAC,OAAO,GAAE,gBAAqB,GAAG,YAAY,CAiBzE;AAED,eAAO,MAAM,SAAS,EAAE,YAA6C,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -139,8 +139,8 @@ var HttpParseError = class extends Error {
139
139
  const NEWLINE = 10;
140
140
  /** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
141
141
  const MAX_ENVELOPE_BYTES = 65536;
142
- const encoder$1 = new TextEncoder();
143
- const decoder = new TextDecoder();
142
+ const encoder$1 = /* @__PURE__ */ new TextEncoder();
143
+ const decoder = /* @__PURE__ */ new TextDecoder();
144
144
  /**
145
145
  * Encode an HTTP envelope plus optional body as one continuous byte stream:
146
146
  *
@@ -398,15 +398,15 @@ function resolveFraming(headers, version) {
398
398
  }
399
399
  //#endregion
400
400
  //#region src/http1/chunked.ts
401
- const CRLF = new Uint8Array([13, 10]);
402
- const LAST_CHUNK = new Uint8Array([
401
+ const CRLF = /* @__PURE__ */ new Uint8Array([13, 10]);
402
+ const LAST_CHUNK = /* @__PURE__ */ new Uint8Array([
403
403
  48,
404
404
  13,
405
405
  10,
406
406
  13,
407
407
  10
408
408
  ]);
409
- const encoder = new TextEncoder();
409
+ const encoder = /* @__PURE__ */ new TextEncoder();
410
410
  /**
411
411
  * Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
412
412
  * them in decimal is the defect note 15 found in @libp2p/http, and it is the
@@ -717,7 +717,7 @@ function newHttpCodec(options = {}) {
717
717
  decodeResponse: (input, o) => decodeResponse(input, opts, o.method)
718
718
  };
719
719
  }
720
- const httpCodec = newHttpCodec();
720
+ const httpCodec = /* @__PURE__ */ newHttpCodec();
721
721
  //#endregion
722
722
  //#region src/sniff.ts
723
723
  /**
@@ -771,11 +771,32 @@ function newSniffingCodec(options) {
771
771
  * Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
772
772
  * no `codec` option is supplied.
773
773
  */
774
- const defaultCodec = newSniffingCodec({
774
+ const defaultCodec = /* @__PURE__ */ newSniffingCodec({
775
775
  write: httpCodec,
776
776
  accept: [httpCodec, jsonEnvelopeCodec]
777
777
  });
778
778
  //#endregion
779
+ //#region src/async-dispose.ts
780
+ /**
781
+ * Makes `target` disposable with `await using`: installs `dispose` as its
782
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
783
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
784
+ * this gives it back. A no-op where the symbol is missing. Kept off the
785
+ * declared types, which would require consumers to compile with the
786
+ * `esnext.disposable` lib.
787
+ */
788
+ function asyncDisposable(target, dispose) {
789
+ const asyncDispose = Symbol.asyncDispose;
790
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
791
+ value: async () => {
792
+ await dispose();
793
+ },
794
+ configurable: true,
795
+ writable: true
796
+ });
797
+ return target;
798
+ }
799
+ //#endregion
779
800
  //#region src/http-data.ts
780
801
  /** Carries the serialized JS error between two webrun peers. */
781
802
  const PEER_ERROR_HEADER = "x-webrun-error";
@@ -854,41 +875,105 @@ async function throwIfPeerError(result, output) {
854
875
  */
855
876
  async function httpFetch(call, env, body, options = {}) {
856
877
  const codec = options.codec ?? defaultCodec;
878
+ const { signal } = options;
879
+ if (signal?.aborted) throw abortReason(signal);
857
880
  const output = call(codec.encodeRequest(env, body));
858
- const decoded = await codec.decodeResponse(output, { method: env.method });
859
- const release = async () => {
860
- try {
861
- await output.return?.(void 0);
862
- } catch {}
881
+ let released;
882
+ const release = () => {
883
+ released ??= (async () => {
884
+ try {
885
+ await output.return?.(void 0);
886
+ } catch {}
887
+ })();
888
+ return released;
889
+ };
890
+ let finished = false;
891
+ const finish = () => {
892
+ if (finished || released) return;
893
+ finished = true;
894
+ if (signal?.aborted) {
895
+ release();
896
+ return;
897
+ }
898
+ const onAbort = () => void release();
899
+ signal?.addEventListener("abort", onAbort, { once: true });
900
+ drain(output).finally(() => signal?.removeEventListener("abort", onAbort));
863
901
  };
902
+ let decoded;
903
+ try {
904
+ decoded = await untilAborted(codec.decodeResponse(output, { method: env.method }), signal);
905
+ } catch (error) {
906
+ if (signal?.aborted) release();
907
+ else await release();
908
+ throw error;
909
+ }
864
910
  const result = {
865
911
  envelope: decoded.envelope,
866
- body: releasingOnExit(decoded.body, release),
867
- release
912
+ body: releasingOnExit(decoded.body, finish, release),
913
+ release,
914
+ finish
868
915
  };
869
916
  await throwIfPeerError(result, output);
870
917
  return result;
871
918
  }
919
+ /** `promise`, rejected with the signal's reason if it aborts first. */
920
+ function untilAborted(promise, signal) {
921
+ if (!signal) return promise;
922
+ return new Promise((resolve, reject) => {
923
+ const onAbort = () => reject(abortReason(signal));
924
+ if (signal.aborted) return onAbort();
925
+ signal.addEventListener("abort", onAbort, { once: true });
926
+ promise.then((value) => {
927
+ signal.removeEventListener("abort", onAbort);
928
+ resolve(value);
929
+ }, (error) => {
930
+ signal.removeEventListener("abort", onAbort);
931
+ reject(error);
932
+ });
933
+ });
934
+ }
935
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
936
+ function abortReason(signal) {
937
+ const reason = signal.reason;
938
+ if (reason instanceof Error) return reason;
939
+ const err = new Error(reason === void 0 ? "Aborted" : String(reason));
940
+ err.name = "AbortError";
941
+ return err;
942
+ }
872
943
  /**
873
- * `body`, but leaving it before its end releases the call.
944
+ * `body`, ending the call however it is left: read to its end, or left early.
874
945
  *
875
946
  * The body the codec hands back is a generator of its own over a `ByteReader`,
876
- * and `.return()` on it never reaches the call's `output` (see
877
- * `throwIfPeerError` for why the codec must not do that itself). So a caller
878
- * that stopped reading — an aborted `fetch`, a cancelled response stream —
879
- * left the peer's producer parked forever, holding its handler open. Safe here
880
- * for the same reason it is safe in `throwIfPeerError`: `output` is this
881
- * call's own response-only generator, and the request was fully sent.
947
+ * and its ending never reaches the call's `output` (see `throwIfPeerError` for
948
+ * why the codec must not do that itself). A caller that stopped reading — an
949
+ * aborted `fetch`, a cancelled response stream — left the peer's producer
950
+ * parked forever; and one that read the body to its end left the call itself
951
+ * open, which on a multiplexed transport is one port per exchange until the
952
+ * multiplexer refuses to open more (`maxPorts`).
953
+ *
954
+ * Left early, the call is abandoned (`.return()`): the peer's producer is
955
+ * released. Read to its end, the call is not cut short but read to ITS end, in
956
+ * the background: the peer ends it once its side of the exchange is over,
957
+ * which may be later than the response — a handler that answered before it
958
+ * read the request body is still receiving it (`HttpDataHandlerResult.settled`),
959
+ * and abandoning the call would abort that upload.
882
960
  */
883
- async function* releasingOnExit(body, release) {
961
+ async function* releasingOnExit(body, finish, release) {
884
962
  let completed = false;
885
963
  try {
886
964
  yield* body;
887
965
  completed = true;
888
966
  } finally {
889
- if (!completed) await release();
967
+ if (completed) finish();
968
+ else await release();
890
969
  }
891
970
  }
971
+ /** Reads `output` to its end, discarding what is left, ignoring a failure. */
972
+ async function drain(output) {
973
+ try {
974
+ for (let r = await output.next(); !r.done; r = await output.next());
975
+ } catch {}
976
+ }
892
977
  /**
893
978
  * Wrap an HTTP handler as a `Duplex` so it can be registered with any
894
979
  * `webrun-streams-*` adapter's `serve`.
@@ -916,6 +1001,7 @@ function httpServe(handler, options = {}) {
916
1001
  try {
917
1002
  yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
918
1003
  completed = true;
1004
+ await result.settled?.();
919
1005
  } finally {
920
1006
  if (!completed) try {
921
1007
  await result.release?.();
@@ -1178,17 +1264,21 @@ async function fetchOverDuplex(call, request, options = {}) {
1178
1264
  const buffered = new Uint8Array(await request.arrayBuffer());
1179
1265
  if (buffered.byteLength > 0) body = [buffered];
1180
1266
  }
1181
- const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
1267
+ const { envelope, body: respBody, release, finish } = await httpFetch(call, env, body, {
1268
+ ...options,
1269
+ signal: request.signal ?? void 0
1270
+ });
1182
1271
  const responseInit = {
1183
1272
  status: envelope.status,
1184
1273
  statusText: envelope.statusText,
1185
1274
  headers: forwardableHeaders(envelope.headers)
1186
1275
  };
1187
1276
  if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
1188
- await release?.();
1277
+ if (finish) finish();
1278
+ else await release?.();
1189
1279
  return new Response(null, responseInit);
1190
1280
  }
1191
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
1281
+ return new Response(responseBody(respBody, request.signal, release), responseInit);
1192
1282
  }
1193
1283
  /**
1194
1284
  * Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
@@ -1200,13 +1290,21 @@ function serveFetchOverDuplex(handler, options = {}) {
1200
1290
  method: env.method,
1201
1291
  headers: forwardableHeaders(env.headers)
1202
1292
  };
1293
+ let upload;
1203
1294
  if (env.method !== "GET" && env.method !== "HEAD") {
1204
1295
  if (supportsRequestStreams()) {
1205
- reqInit.body = asyncIterableToReadable(body);
1296
+ upload = trackingEnd(body);
1297
+ reqInit.body = asyncIterableToReadable(upload.source);
1206
1298
  reqInit.duplex = "half";
1207
1299
  } else reqInit.body = await collectBytes(body);
1208
1300
  }
1209
- const response = await handler(new Request(env.url, reqInit));
1301
+ const request = new Request(env.url, reqInit);
1302
+ const response = await handler(request);
1303
+ const settled = async () => {
1304
+ if (!upload) return;
1305
+ if (!request.bodyUsed && !request.body?.locked) request.body?.cancel().catch(() => {});
1306
+ await upload.settle(options.requestIdleMs ?? 3e4);
1307
+ };
1210
1308
  const respEnv = {
1211
1309
  status: response.status,
1212
1310
  statusText: response.statusText,
@@ -1216,32 +1314,173 @@ function serveFetchOverDuplex(handler, options = {}) {
1216
1314
  await response.body?.cancel();
1217
1315
  return {
1218
1316
  envelope: respEnv,
1219
- body: void 0
1317
+ body: void 0,
1318
+ settled
1220
1319
  };
1221
1320
  }
1222
1321
  return {
1223
1322
  envelope: respEnv,
1224
1323
  body: response.body ? readableToAsyncIterable(response.body) : void 0,
1225
- release: () => response.body?.cancel()
1324
+ release: () => response.body?.cancel(),
1325
+ settled
1226
1326
  };
1227
1327
  }, options);
1228
1328
  }
1229
- async function* withAbort(iter, signal) {
1230
- if (!signal) {
1231
- yield* iter;
1232
- return;
1233
- }
1234
- for await (const chunk of iter) {
1235
- if (signal.aborted) throw abortReason(signal);
1236
- yield chunk;
1237
- }
1329
+ /**
1330
+ * `source`, and `settle(idleMs)`: resolves once it is done — read to its end,
1331
+ * failed, or abandoned (`return()` included before the first `next()`, which a
1332
+ * generator's `finally` would not see) — or once nobody has asked it for a
1333
+ * chunk for `idleMs`, in which case it is abandoned: later reads fail.
1334
+ *
1335
+ * Idle counts only while no `next()` is pending: a reader waiting for a slow
1336
+ * sender is still reading.
1337
+ */
1338
+ function trackingEnd(source) {
1339
+ let end = () => {};
1340
+ let isEnded = false;
1341
+ const ended = new Promise((resolve) => {
1342
+ end = () => {
1343
+ isEnded = true;
1344
+ resolve();
1345
+ };
1346
+ });
1347
+ let abandoned;
1348
+ let pending = 0;
1349
+ let idleSince = Date.now();
1350
+ const it = source[Symbol.asyncIterator]();
1351
+ const abandon = (reason) => {
1352
+ abandoned ??= reason;
1353
+ end();
1354
+ Promise.resolve(it.return?.(void 0)).catch(() => {});
1355
+ };
1356
+ const tracked = {
1357
+ [Symbol.asyncIterator]() {
1358
+ return tracked;
1359
+ },
1360
+ async next() {
1361
+ if (abandoned) throw abandoned;
1362
+ pending++;
1363
+ try {
1364
+ const r = await it.next();
1365
+ if (r.done) end();
1366
+ return r;
1367
+ } catch (error) {
1368
+ end();
1369
+ throw error;
1370
+ } finally {
1371
+ if (--pending === 0) idleSince = Date.now();
1372
+ }
1373
+ },
1374
+ async return(value) {
1375
+ end();
1376
+ try {
1377
+ await it.return?.(value);
1378
+ } catch {}
1379
+ return {
1380
+ done: true,
1381
+ value: void 0
1382
+ };
1383
+ }
1384
+ };
1385
+ asyncDisposable(tracked, () => tracked.return?.() ?? Promise.resolve());
1386
+ const settle = (idleMs) => {
1387
+ if (isEnded || !Number.isFinite(idleMs)) return ended;
1388
+ return new Promise((resolve) => {
1389
+ let timer;
1390
+ const check = () => {
1391
+ if (isEnded) return resolve();
1392
+ const idle = pending === 0 ? Date.now() - idleSince : 0;
1393
+ if (idle >= idleMs) {
1394
+ abandon(/* @__PURE__ */ new Error(`webrun-http-streams: the request body was left unread for ${idleMs} ms after the response; abandoned`));
1395
+ return resolve();
1396
+ }
1397
+ timer = setTimeout(check, Math.max(1, Math.min(idleMs - idle, 1e3)));
1398
+ timer.unref?.();
1399
+ };
1400
+ ended.then(() => {
1401
+ clearTimeout(timer);
1402
+ resolve();
1403
+ });
1404
+ check();
1405
+ });
1406
+ };
1407
+ return {
1408
+ source: tracked,
1409
+ settle
1410
+ };
1238
1411
  }
1239
- function abortReason(signal) {
1240
- const reason = signal.reason;
1241
- if (reason instanceof Error) return reason;
1242
- const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1243
- err.name = "AbortError";
1244
- return err;
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
+ });
1245
1484
  }
1246
1485
  //#endregion
1247
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.0",
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",
@@ -30,7 +30,7 @@
30
30
  "src"
31
31
  ],
32
32
  "dependencies": {
33
- "@statewalker/webrun-streams": "^0.2.2"
33
+ "@statewalker/webrun-streams": "^0.2.3"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@biomejs/biome": "^2.5.15",
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Makes `target` disposable with `await using`: installs `dispose` as its
3
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
4
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
5
+ * this gives it back. A no-op where the symbol is missing. Kept off the
6
+ * declared types, which would require consumers to compile with the
7
+ * `esnext.disposable` lib.
8
+ */
9
+ export function asyncDisposable<T extends object>(target: T, dispose: () => Promise<unknown>): T {
10
+ // Read here, not at module level: a top-level read is code a bundler keeps.
11
+ const asyncDispose = (Symbol as { asyncDispose?: symbol }).asyncDispose;
12
+ if (asyncDispose && !(asyncDispose in target)) {
13
+ Object.defineProperty(target, asyncDispose, {
14
+ value: async () => {
15
+ await dispose();
16
+ },
17
+ configurable: true,
18
+ writable: true,
19
+ });
20
+ }
21
+ return target;
22
+ }
package/src/bytes.ts CHANGED
@@ -2,7 +2,7 @@ import type { ByteSource } from "./message.js";
2
2
 
3
3
  const CR = 0x0d;
4
4
  const LF = 0x0a;
5
- const EMPTY = new Uint8Array(0);
5
+ const EMPTY = /* @__PURE__ */ new Uint8Array(0);
6
6
 
7
7
  export class ByteStreamError extends Error {
8
8
  override readonly name = "ByteStreamError";
@@ -7,7 +7,7 @@ import { newSniffingCodec } from "./sniff.js";
7
7
  * Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
8
8
  * no `codec` option is supplied.
9
9
  */
10
- export const defaultCodec: MessageCodec = newSniffingCodec({
10
+ export const defaultCodec: MessageCodec = /* @__PURE__ */ newSniffingCodec({
11
11
  write: httpCodec,
12
12
  accept: [httpCodec, jsonEnvelopeCodec],
13
13
  });
package/src/envelope.ts CHANGED
@@ -15,8 +15,8 @@ const NEWLINE = 0x0a;
15
15
 
16
16
  /** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
17
17
  const MAX_ENVELOPE_BYTES = 65536;
18
- const encoder = new TextEncoder();
19
- const decoder = new TextDecoder();
18
+ const encoder = /* @__PURE__ */ new TextEncoder();
19
+ const decoder = /* @__PURE__ */ new TextDecoder();
20
20
 
21
21
  /**
22
22
  * Encode an HTTP envelope plus optional body as one continuous byte stream:
package/src/fetch.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Duplex } from "@statewalker/webrun-streams";
2
+ import { asyncDisposable } from "./async-dispose.js";
2
3
  import type { RequestEnvelope, ResponseEnvelope } from "./envelope.js";
3
- import { type HttpDataOptions, httpFetch, httpServe } from "./http-data.js";
4
+ import { abortReason, type HttpDataOptions, httpFetch, httpServe } from "./http-data.js";
4
5
  import { NULL_BODY_STATUSES } from "./http-stubs.js";
5
6
  import type { ByteSource } from "./message.js";
6
7
  import { collectBytes, supportsRequestStreams } from "./request-streams.js";
@@ -10,7 +11,7 @@ import { collectBytes, supportsRequestStreams } from "./request-streams.js";
10
11
  * but they are meaningless to a `Request`/`Response`, and re-emitting them
11
12
  * from a relay would corrupt its framing.
12
13
  */
13
- const HOP_BY_HOP = new Set([
14
+ const HOP_BY_HOP = /* @__PURE__ */ new Set([
14
15
  "connection",
15
16
  "host",
16
17
  "keep-alive",
@@ -126,24 +127,35 @@ export async function fetchOverDuplex(
126
127
  const buffered = new Uint8Array(await request.arrayBuffer());
127
128
  if (buffered.byteLength > 0) body = [buffered];
128
129
  }
129
- const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
130
+ const {
131
+ envelope,
132
+ body: respBody,
133
+ release,
134
+ finish,
135
+ } = await httpFetch(call, env, body, {
136
+ ...options,
137
+ signal: request.signal ?? undefined,
138
+ });
130
139
  const responseInit: ResponseInit = {
131
140
  status: envelope.status,
132
141
  statusText: envelope.statusText,
133
142
  headers: forwardableHeaders(envelope.headers),
134
143
  };
135
144
  // `new Response(body, { status })` throws for the null-body-status set, and
136
- // HEAD/OPTIONS carry no body either, so return a bodyless Response and
137
- // release the call: nobody will read the body, so leaving it cannot.
145
+ // HEAD/OPTIONS carry no body either, so return a bodyless Response. The
146
+ // caller has taken it in full: the call is finished as for any body read to
147
+ // its end — not abandoned, which would cut off an upload the handler is
148
+ // still reading.
138
149
  if (
139
150
  request.method === "HEAD" ||
140
151
  request.method === "OPTIONS" ||
141
152
  NULL_BODY_STATUSES.has(envelope.status)
142
153
  ) {
143
- await release?.();
154
+ if (finish) finish();
155
+ else await release?.();
144
156
  return new Response(null, responseInit);
145
157
  }
146
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
158
+ return new Response(responseBody(respBody, request.signal, release), responseInit);
147
159
  }
148
160
 
149
161
  /**
@@ -159,9 +171,11 @@ export function serveFetchOverDuplex(
159
171
  method: env.method,
160
172
  headers: forwardableHeaders(env.headers),
161
173
  };
174
+ let upload: ReturnType<typeof trackingEnd> | undefined;
162
175
  if (env.method !== "GET" && env.method !== "HEAD") {
163
176
  if (supportsRequestStreams()) {
164
- reqInit.body = asyncIterableToReadable(body);
177
+ upload = trackingEnd(body);
178
+ reqInit.body = asyncIterableToReadable(upload.source);
165
179
  (reqInit as RequestInit & { duplex?: string }).duplex = "half";
166
180
  } else {
167
181
  // Firefox again, and this is the half that fails *silently*. The
@@ -180,6 +194,22 @@ export function serveFetchOverDuplex(
180
194
  }
181
195
  const request = new Request(env.url, reqInit);
182
196
  const response = await handler(request);
197
+ // Once the response is out, the exchange ends when the handler is done
198
+ // with the request body too: a handler may answer first and read the
199
+ // upload afterwards (`202`, then store it). One it never took — not used,
200
+ // not locked — it will not read: cancel it, and the exchange ends now. One
201
+ // it took and stopped reading (a reader dropped, a clone nobody reads) is
202
+ // abandoned once nothing has asked it for a chunk for `requestIdleMs`.
203
+ const settled = async () => {
204
+ if (!upload) return;
205
+ if (!request.bodyUsed && !request.body?.locked) {
206
+ // Not awaited: the cancel of one branch of a tee (`request.clone()`)
207
+ // settles only once the other branch is cancelled too, and a clone
208
+ // nobody reads never is. The idle bound below ends that one.
209
+ void request.body?.cancel().catch(() => {});
210
+ }
211
+ await upload.settle(options.requestIdleMs ?? DEFAULT_REQUEST_IDLE_MS);
212
+ };
183
213
  const respEnv: ResponseEnvelope = {
184
214
  status: response.status,
185
215
  statusText: response.statusText,
@@ -195,7 +225,7 @@ export function serveFetchOverDuplex(
195
225
  NULL_BODY_STATUSES.has(response.status)
196
226
  ) {
197
227
  await response.body?.cancel();
198
- return { envelope: respEnv, body: undefined };
228
+ return { envelope: respEnv, body: undefined, settled };
199
229
  }
200
230
  return {
201
231
  envelope: respEnv,
@@ -204,28 +234,183 @@ export function serveFetchOverDuplex(
204
234
  // through; cancel the stream itself. Once read, it is locked and this
205
235
  // rejects, harmlessly: the reader's own cancellation has run.
206
236
  release: () => response.body?.cancel(),
237
+ settled,
207
238
  };
208
239
  }, options);
209
240
  }
210
241
 
211
- async function* withAbort(
212
- iter: AsyncIterable<Uint8Array>,
213
- signal: AbortSignal | undefined,
214
- ): AsyncGenerator<Uint8Array> {
215
- if (!signal) {
216
- yield* iter;
217
- return;
218
- }
219
- for await (const chunk of iter) {
220
- if (signal.aborted) throw abortReason(signal);
221
- yield chunk;
222
- }
242
+ /** How long a request body may sit unread after the response before it is abandoned. */
243
+ export const DEFAULT_REQUEST_IDLE_MS = 30_000;
244
+
245
+ /**
246
+ * `source`, and `settle(idleMs)`: resolves once it is done — read to its end,
247
+ * failed, or abandoned (`return()` included before the first `next()`, which a
248
+ * generator's `finally` would not see) — or once nobody has asked it for a
249
+ * chunk for `idleMs`, in which case it is abandoned: later reads fail.
250
+ *
251
+ * Idle counts only while no `next()` is pending: a reader waiting for a slow
252
+ * sender is still reading.
253
+ */
254
+ function trackingEnd(source: AsyncIterable<Uint8Array>): {
255
+ source: AsyncIterable<Uint8Array>;
256
+ settle(idleMs: number): Promise<void>;
257
+ } {
258
+ let end: () => void = () => {};
259
+ let isEnded = false;
260
+ const ended = new Promise<void>((resolve) => {
261
+ end = () => {
262
+ isEnded = true;
263
+ resolve();
264
+ };
265
+ });
266
+ let abandoned: Error | undefined;
267
+ let pending = 0;
268
+ let idleSince = Date.now();
269
+ const it = source[Symbol.asyncIterator]();
270
+ const abandon = (reason: Error) => {
271
+ abandoned ??= reason;
272
+ end();
273
+ void Promise.resolve(it.return?.(undefined)).catch(() => {});
274
+ };
275
+ const tracked: AsyncIterableIterator<Uint8Array> = {
276
+ [Symbol.asyncIterator]() {
277
+ return tracked;
278
+ },
279
+ async next() {
280
+ if (abandoned) throw abandoned;
281
+ pending++;
282
+ try {
283
+ const r = await it.next();
284
+ if (r.done) end();
285
+ return r;
286
+ } catch (error) {
287
+ end();
288
+ throw error;
289
+ } finally {
290
+ if (--pending === 0) idleSince = Date.now();
291
+ }
292
+ },
293
+ async return(value?: unknown) {
294
+ end();
295
+ try {
296
+ await it.return?.(value);
297
+ } catch {
298
+ /* abandoned either way */
299
+ }
300
+ return { done: true, value: undefined };
301
+ },
302
+ };
303
+ asyncDisposable(tracked, () => tracked.return?.() ?? Promise.resolve());
304
+ const settle = (idleMs: number): Promise<void> => {
305
+ if (isEnded || !Number.isFinite(idleMs)) return ended;
306
+ return new Promise<void>((resolve) => {
307
+ let timer: ReturnType<typeof setTimeout> | undefined;
308
+ const check = () => {
309
+ if (isEnded) return resolve();
310
+ const idle = pending === 0 ? Date.now() - idleSince : 0;
311
+ if (idle >= idleMs) {
312
+ abandon(
313
+ new Error(
314
+ `webrun-http-streams: the request body was left unread for ${idleMs} ms after the response; abandoned`,
315
+ ),
316
+ );
317
+ return resolve();
318
+ }
319
+ timer = setTimeout(check, Math.max(1, Math.min(idleMs - idle, 1_000)));
320
+ (timer as { unref?: () => void }).unref?.();
321
+ };
322
+ void ended.then(() => {
323
+ clearTimeout(timer);
324
+ resolve();
325
+ });
326
+ check();
327
+ });
328
+ };
329
+ return { source: tracked, settle };
223
330
  }
224
331
 
225
- function abortReason(signal: AbortSignal): Error {
226
- const reason = (signal as unknown as { reason?: unknown }).reason;
227
- if (reason instanceof Error) return reason;
228
- const err = new Error(reason === undefined ? "Aborted" : String(reason));
229
- err.name = "AbortError";
230
- return err;
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>,
356
+ signal: AbortSignal | undefined,
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
+ });
231
416
  }
package/src/http-data.ts CHANGED
@@ -17,6 +17,24 @@ export const PEER_ERROR_HEADER = "x-webrun-error";
17
17
  export type HttpDataOptions = {
18
18
  /** Wire format. Defaults to `defaultCodec`: writes HTTP/1.1, accepts either. */
19
19
  codec?: MessageCodec;
20
+ /**
21
+ * `httpFetch` only: aborting it while the response head is awaited releases
22
+ * the call and rejects with the signal's reason. (Once the head is in, the
23
+ * body is the caller's to leave.)
24
+ */
25
+ signal?: AbortSignal;
26
+ /**
27
+ * `serveFetchOverDuplex` only: once the response is out, how long the request
28
+ * body may go unread before it is abandoned and the exchange ends (default
29
+ * 30 s). A handler that answers first and reads the upload afterwards keeps
30
+ * it as long as it keeps reading — the time it spends on one chunk before it
31
+ * asks for the next counts as idle, so a handler that pauses longer between
32
+ * reads (one slow write per chunk) needs a larger bound; one that took the
33
+ * body and dropped it would otherwise keep the exchange — on a multiplexed
34
+ * transport, its port — open for good. `Infinity` waits for as long as it
35
+ * takes.
36
+ */
37
+ requestIdleMs?: number;
20
38
  };
21
39
 
22
40
  export interface HttpFetchResult {
@@ -30,18 +48,36 @@ export interface HttpFetchResult {
30
48
  * custom transports) still type-check; `httpFetch` always sets it.
31
49
  */
32
50
  release?(): Promise<void>;
51
+ /**
52
+ * The caller is done with a response it took in full — read to its end, or
53
+ * one with no body to read (HEAD, 204, 304...): the call is read to its end
54
+ * in the background, so the peer ends it once its side is over (it may still
55
+ * be reading the request body). Reading `body` to its end does this by
56
+ * itself. Optional, like `release`; `httpFetch` always sets it.
57
+ */
58
+ finish?(): void;
33
59
  }
34
60
 
35
61
  export interface HttpDataHandlerResult {
36
62
  envelope: ResponseEnvelope;
37
63
  body?: ByteSource;
38
64
  /**
39
- * Called when the caller abandons the response before its end. A body the
40
- * codec never started pulling cannot be released through the iterator
41
- * protocol (`.return()` on a generator in suspended start does nothing), so
42
- * a producer that holds resources says here how to let them go.
65
+ * Called when the caller abandons the response before its end — never once
66
+ * the response went out in full. A body the codec never started pulling
67
+ * cannot be released through the iterator protocol (`.return()` on a
68
+ * generator in suspended start does nothing), so a producer that holds
69
+ * resources says here how to let them go.
43
70
  */
44
71
  release?: () => unknown;
72
+ /**
73
+ * Awaited once the response went out in full: the exchange — and with it
74
+ * the call, and on a multiplexed transport its port — ends when it
75
+ * resolves. A handler that answers before it has read the request body, and
76
+ * goes on reading it, resolves this once it is done with it; until then the
77
+ * caller keeps sending. Left out, the exchange ends with the response, and
78
+ * the rest of the request is dropped.
79
+ */
80
+ settled?: () => Promise<unknown>;
45
81
  }
46
82
 
47
83
  /**
@@ -166,37 +202,114 @@ export async function httpFetch(
166
202
  options: HttpDataOptions = {},
167
203
  ): Promise<HttpFetchResult> {
168
204
  const codec = options.codec ?? defaultCodec;
205
+ const { signal } = options;
206
+ if (signal?.aborted) throw abortReason(signal);
169
207
  const output = call(codec.encodeRequest(env, body));
170
- const decoded = await codec.decodeResponse(output, { method: env.method });
171
- const release = async () => {
172
- try {
173
- await output.return?.(undefined);
174
- } catch {
175
- /* the call is being abandoned; nothing to report */
208
+ // The call is one Duplex invocation, and only its own generator's ending
209
+ // ends it: on a multiplexed transport that `finally` is what frees the
210
+ // call's port. So every way out of an exchange ends it — the body read to
211
+ // its end (the call is read to its end too), left early, never started
212
+ // (bodyless), a peer error, a response that could not be decoded, an abort
213
+ // while waiting for the head (the call is abandoned).
214
+ let released: Promise<void> | undefined;
215
+ const release = () => {
216
+ released ??= (async () => {
217
+ try {
218
+ await output.return?.(undefined);
219
+ } catch {
220
+ /* the call is being abandoned; nothing to report */
221
+ }
222
+ })();
223
+ return released;
224
+ };
225
+ let finished = false;
226
+ const finish = () => {
227
+ if (finished || released) return;
228
+ finished = true;
229
+ // The peer ends the call; an abort of the request's signal abandons it
230
+ // instead (a peer that never ends a call, say).
231
+ if (signal?.aborted) {
232
+ void release();
233
+ return;
176
234
  }
235
+ const onAbort = () => void release();
236
+ signal?.addEventListener("abort", onAbort, { once: true });
237
+ void drain(output).finally(() => signal?.removeEventListener("abort", onAbort));
177
238
  };
239
+ let decoded: Awaited<ReturnType<MessageCodec["decodeResponse"]>>;
240
+ try {
241
+ decoded = await untilAborted(codec.decodeResponse(output, { method: env.method }), signal);
242
+ } catch (error) {
243
+ if (signal?.aborted) {
244
+ // `.return()` on a generator with a `next()` in flight is queued behind
245
+ // it: the call ends once the peer answers that `next()`, which an abort
246
+ // must not wait for.
247
+ void release();
248
+ } else {
249
+ await release();
250
+ }
251
+ throw error;
252
+ }
178
253
  const result: HttpFetchResult = {
179
254
  envelope: decoded.envelope,
180
- body: releasingOnExit(decoded.body, release),
255
+ body: releasingOnExit(decoded.body, finish, release),
181
256
  release,
257
+ finish,
182
258
  };
183
259
  await throwIfPeerError(result, output);
184
260
  return result;
185
261
  }
186
262
 
263
+ /** `promise`, rejected with the signal's reason if it aborts first. */
264
+ function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
265
+ if (!signal) return promise;
266
+ return new Promise<T>((resolve, reject) => {
267
+ const onAbort = () => reject(abortReason(signal));
268
+ if (signal.aborted) return onAbort();
269
+ signal.addEventListener("abort", onAbort, { once: true });
270
+ promise.then(
271
+ (value) => {
272
+ signal.removeEventListener("abort", onAbort);
273
+ resolve(value);
274
+ },
275
+ (error) => {
276
+ signal.removeEventListener("abort", onAbort);
277
+ reject(error);
278
+ },
279
+ );
280
+ });
281
+ }
282
+
283
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
284
+ export function abortReason(signal: AbortSignal): Error {
285
+ const reason = (signal as unknown as { reason?: unknown }).reason;
286
+ if (reason instanceof Error) return reason;
287
+ const err = new Error(reason === undefined ? "Aborted" : String(reason));
288
+ err.name = "AbortError";
289
+ return err;
290
+ }
291
+
187
292
  /**
188
- * `body`, but leaving it before its end releases the call.
293
+ * `body`, ending the call however it is left: read to its end, or left early.
189
294
  *
190
295
  * The body the codec hands back is a generator of its own over a `ByteReader`,
191
- * and `.return()` on it never reaches the call's `output` (see
192
- * `throwIfPeerError` for why the codec must not do that itself). So a caller
193
- * that stopped reading — an aborted `fetch`, a cancelled response stream —
194
- * left the peer's producer parked forever, holding its handler open. Safe here
195
- * for the same reason it is safe in `throwIfPeerError`: `output` is this
196
- * call's own response-only generator, and the request was fully sent.
296
+ * and its ending never reaches the call's `output` (see `throwIfPeerError` for
297
+ * why the codec must not do that itself). A caller that stopped reading — an
298
+ * aborted `fetch`, a cancelled response stream — left the peer's producer
299
+ * parked forever; and one that read the body to its end left the call itself
300
+ * open, which on a multiplexed transport is one port per exchange until the
301
+ * multiplexer refuses to open more (`maxPorts`).
302
+ *
303
+ * Left early, the call is abandoned (`.return()`): the peer's producer is
304
+ * released. Read to its end, the call is not cut short but read to ITS end, in
305
+ * the background: the peer ends it once its side of the exchange is over,
306
+ * which may be later than the response — a handler that answered before it
307
+ * read the request body is still receiving it (`HttpDataHandlerResult.settled`),
308
+ * and abandoning the call would abort that upload.
197
309
  */
198
310
  async function* releasingOnExit(
199
311
  body: AsyncIterable<Uint8Array>,
312
+ finish: () => void,
200
313
  release: () => Promise<void>,
201
314
  ): AsyncGenerator<Uint8Array> {
202
315
  let completed = false;
@@ -204,7 +317,17 @@ async function* releasingOnExit(
204
317
  yield* body;
205
318
  completed = true;
206
319
  } finally {
207
- if (!completed) await release();
320
+ if (completed) finish();
321
+ else await release();
322
+ }
323
+ }
324
+
325
+ /** Reads `output` to its end, discarding what is left, ignoring a failure. */
326
+ async function drain(output: AsyncGenerator<Uint8Array>): Promise<void> {
327
+ try {
328
+ for (let r = await output.next(); !r.done; r = await output.next());
329
+ } catch {
330
+ /* the exchange is over for this side; how the peer ended it is its own */
208
331
  }
209
332
  }
210
333
 
@@ -254,6 +377,7 @@ export function httpServe(handler: HttpDataHandler, options: HttpDataOptions = {
254
377
  method: decoded.envelope.method,
255
378
  });
256
379
  completed = true;
380
+ await result.settled?.();
257
381
  } finally {
258
382
  if (!completed) {
259
383
  try {
package/src/http-stubs.ts CHANGED
@@ -44,7 +44,7 @@ export type HttpHandler = (request: Request) => Response | Promise<Response>;
44
44
  // Statuses that MUST NOT carry a body — `new Response(body, { status })` throws
45
45
  // for these (per the Fetch spec's "null body status" set). Exported so
46
46
  // `fetch.ts` mirrors this exact set rather than redefining it.
47
- export const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
47
+ export const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
48
48
 
49
49
  const REQUEST_FIELDS = [
50
50
  "url",
@@ -4,9 +4,9 @@ import type { ByteSource } from "../message.js";
4
4
  import { HttpParseError } from "./errors.js";
5
5
  import { decodeLatin1 } from "./headers.js";
6
6
 
7
- const CRLF = new Uint8Array([0x0d, 0x0a]);
8
- const LAST_CHUNK = new Uint8Array([0x30, 0x0d, 0x0a, 0x0d, 0x0a]); // "0\r\n\r\n"
9
- const encoder = new TextEncoder();
7
+ const CRLF = /* @__PURE__ */ new Uint8Array([0x0d, 0x0a]);
8
+ const LAST_CHUNK = /* @__PURE__ */ new Uint8Array([0x30, 0x0d, 0x0a, 0x0d, 0x0a]); // "0\r\n\r\n"
9
+ const encoder = /* @__PURE__ */ new TextEncoder();
10
10
 
11
11
  /**
12
12
  * Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
@@ -15,7 +15,7 @@ import {
15
15
  resolveFraming,
16
16
  } from "./headers.js";
17
17
 
18
- const VERSIONS = new Set(["HTTP/1.1", "HTTP/1.0"]);
18
+ const VERSIONS = /* @__PURE__ */ new Set(["HTTP/1.1", "HTTP/1.0"]);
19
19
  const ABSOLUTE_FORM = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
20
20
 
21
21
  /**
@@ -3,7 +3,7 @@ import { HttpParseError } from "./errors.js";
3
3
 
4
4
  export type HeaderList = [string, string][];
5
5
 
6
- const TCHAR = new Set(
6
+ const TCHAR = /* @__PURE__ */ new Set(
7
7
  "!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
8
8
  );
9
9
 
@@ -36,5 +36,5 @@ export function newHttpCodec(options: HttpCodecOptions = {}): MessageCodec {
36
36
  };
37
37
  }
38
38
 
39
- export const httpCodec: MessageCodec = newHttpCodec();
39
+ export const httpCodec: MessageCodec = /* @__PURE__ */ newHttpCodec();
40
40
  export { HttpParseError } from "./errors.js";