@statewalker/webrun-http-streams 0.3.1 → 0.4.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/README.md CHANGED
@@ -112,7 +112,8 @@ const { envelope, body } = await httpFetch(call, {
112
112
  ```
113
113
 
114
114
  Signatures: `httpFetch(call, env, body?, { codec? })` and
115
- `httpServe(handler, { codec? })`. Over a real transport, `call` comes from an
115
+ `httpServe(handler, { codec? })`; the handler is `(env, body, exchange) => result`, where
116
+ `exchange.signal` aborts when the caller abandons the exchange (see below). Over a real transport, `call` comes from an
116
117
  adapter's `connect` and `httpServe(...)` goes to its `serve`.
117
118
 
118
119
  A relative `url` works but does not survive the round trip verbatim. HTTP/1.1
@@ -137,6 +138,40 @@ Both take an optional last argument `{ codec }`. `fetchOverDuplex` plumbs
137
138
  `request.signal` into body iteration, so aborting the signal ends the call. An
138
139
  already-aborted signal rejects before anything is sent.
139
140
 
141
+ On the serving side, the handler's `request.signal` aborts when the exchange is abandoned before
142
+ it is over — the caller aborted its fetch, stopped reading, or its transport went away, or the
143
+ serving side tore the stream down (its timeout, a shutdown) — with `webrun-http-streams: the
144
+ exchange was abandoned before it was over` (`EXCHANGE_ABANDONED_MESSAGE`). A handler that waits
145
+ before it answers can stop then instead of holding on for nothing:
146
+
147
+ ```ts
148
+ import { serveFetchOverDuplex } from "@statewalker/webrun-http-streams";
149
+
150
+ // A long poll: answer in 30 s, or stop at once when the caller leaves.
151
+ const handler = serveFetchOverDuplex(async (request) => {
152
+ if (!request.signal.aborted) {
153
+ await new Promise<void>((resolve) => {
154
+ const timer = setTimeout(resolve, 30_000);
155
+ request.signal.addEventListener(
156
+ "abort",
157
+ () => {
158
+ clearTimeout(timer);
159
+ resolve();
160
+ },
161
+ { once: true },
162
+ );
163
+ });
164
+ }
165
+ return new Response(request.signal.aborted ? null : "news");
166
+ });
167
+ ```
168
+
169
+ Check `signal.aborted` before waiting: the exchange may already be abandoned when the handler
170
+ runs. The exchange is over once the response went out in full and the handler is done with the
171
+ request body; until then it can still be abandoned, so a handler that answers `202` first and
172
+ reads the upload afterwards sees an abort if the upload is cut. A completed exchange never aborts.
173
+ `newHttpServerStub` builds its requests without this signal.
174
+
140
175
  ### Site host: `DuplexSiteBuilder`
141
176
 
142
177
  ```ts
@@ -246,7 +281,7 @@ Check `err.name`, not `instanceof`; see
246
281
 
247
282
  | Export | What it is |
248
283
  | --- | --- |
249
- | `httpFetch`, `httpServe` | Data layer. Types: `HttpDataHandler`, `HttpDataHandlerResult`, `HttpDataOptions`, `HttpFetchResult`. |
284
+ | `httpFetch`, `httpServe` | Data layer. Types: `HttpDataHandler`, `HttpDataHandlerResult`, `HttpDataOptions`, `HttpExchange`, `HttpFetchResult`; `EXCHANGE_ABANDONED_MESSAGE`. |
250
285
  | `fetchOverDuplex`, `serveFetchOverDuplex` | Fetch layer. |
251
286
  | `DuplexSiteBuilder`, `SiteHandler` | Site host over an adapter's `Serve<P>`. |
252
287
  | `newHttpClientStub`, `newHttpServerStub` | Transport-agnostic stubs. Types: `HttpHandler`, `SerializedHttpEnvelope`, `SerializedHttpRequest`, `SerializedHttpResponse`. |
@@ -332,6 +367,18 @@ producer's `finally` does not run. On a transport that multiplexes calls with a
332
367
  bounded stream table this holds one slot per such call. Closing it needs
333
368
  `httpFetch` to expose the call for cancellation.
334
369
 
370
+ ### How a handler that has not answered learns its caller left
371
+
372
+ A transport tells the serving side a caller is gone by calling `return()` on the handler's
373
+ output. On an async generator that call is queued behind the `next()` still pending inside it —
374
+ the `await handler(request)` — so it would arrive only once the handler answered, which a long
375
+ poll may never do. `httpServe` therefore hands the transport a generator whose `return()` and
376
+ `throw()` abort the exchange's signal at once, then forward as usual; one that already finished
377
+ aborts nothing. It relies on the transport calling `return()` without waiting for the pending
378
+ `next()`: `@statewalker/webrun-rpc`'s `serve` and `@statewalker/webrun-streams-libp2p`'s `serve` do.
379
+ A transport that only awaits `return()` after `next()` settles delivers the abandonment no
380
+ earlier than the answer.
381
+
335
382
  ### Hop-by-hop headers are dropped at the fetch layer
336
383
 
337
384
  The codec surfaces every header verbatim. The fetch layer strips `connection`,
@@ -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;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"}
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,CA0ER;AAED,wFAAwF;AACxF,eAAO,MAAM,uBAAuB,QAAS,CAAC"}
@@ -73,7 +73,26 @@ export interface HttpDataHandlerResult {
73
73
  * conventional `HttpHandler` used by `webrun-http-browser` and `SiteBuilder`
74
74
  * — see `http-stubs.ts` and `fetch.ts`.
75
75
  */
76
- export type HttpDataHandler = (env: RequestEnvelope, body: AsyncIterable<Uint8Array>) => Promise<HttpDataHandlerResult>;
76
+ export type HttpDataHandler = (env: RequestEnvelope, body: AsyncIterable<Uint8Array>, exchange: HttpExchange) => Promise<HttpDataHandlerResult>;
77
+ /** What a handler is told about the exchange it serves. */
78
+ export interface HttpExchange {
79
+ /**
80
+ * Aborts when the exchange is abandoned before it is over: the caller gave
81
+ * up or its transport went away, or the serving side tore the stream down
82
+ * (its own timeout, a shutdown). A handler that waits before it answers (a
83
+ * long poll) can stop then. The exchange is over once the response went out
84
+ * in full and the handler is done with the request body: a handler that
85
+ * answers first and reads the upload afterwards still sees an abort if the
86
+ * upload is cut. A completed exchange never aborts.
87
+ */
88
+ signal: AbortSignal;
89
+ }
90
+ /**
91
+ * The reason of `HttpExchange.signal` when the exchange is abandoned with no
92
+ * error of its own (`return()`); one abandoned by `throw(error)` aborts with
93
+ * that error.
94
+ */
95
+ export declare const EXCHANGE_ABANDONED_MESSAGE = "webrun-http-streams: the exchange was abandoned before it was over";
77
96
  /**
78
97
  * Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
79
98
  * obtained from any `webrun-streams-*` adapter's `connect`. Returns the
@@ -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;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
+ {"version":3,"file":"http-data.d.ts","sourceRoot":"","sources":["../src/http-data.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAM1D,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,EAC/B,QAAQ,EAAE,YAAY,KACnB,OAAO,CAAC,qBAAqB,CAAC,CAAC;AAEpC,2DAA2D;AAC3D,MAAM,WAAW,YAAY;IAC3B;;;;;;;;OAQG;IACH,MAAM,EAAE,WAAW,CAAC;CACrB;AAED;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,uEAC+B,CAAC;AAgGvE;;;;;;;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,CA8DzF"}
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { defaultCodec } from "./codec-default.js";
2
2
  export { DuplexSiteBuilder, type SiteHandler } from "./duplex-site-builder.js";
3
3
  export { decodeMessage, encodeMessage, jsonEnvelopeCodec, type RequestEnvelope, type ResponseEnvelope, } from "./envelope.js";
4
4
  export { fetchOverDuplex, serveFetchOverDuplex } from "./fetch.js";
5
- export { type HttpDataHandler, type HttpDataHandlerResult, type HttpDataOptions, type HttpFetchResult, httpFetch, httpServe, PEER_ERROR_HEADER, } from "./http-data.js";
5
+ export { EXCHANGE_ABANDONED_MESSAGE, type HttpDataHandler, type HttpDataHandlerResult, type HttpDataOptions, type HttpExchange, type HttpFetchResult, httpFetch, httpServe, PEER_ERROR_HEADER, } from "./http-data.js";
6
6
  export { HttpError, type HttpErrorOptions } from "./http-error.js";
7
7
  export { type HttpHandler, newHttpClientStub, newHttpServerStub, type SerializedHttpEnvelope, type SerializedHttpRequest, type SerializedHttpResponse, } from "./http-stubs.js";
8
8
  export { type HttpCodecOptions, HttpParseError, httpCodec, newHttpCodec, } from "./http1/index.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC/E,OAAO,EACL,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AACnE,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,SAAS,EACT,SAAS,EACT,iBAAiB,GAClB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EACL,KAAK,WAAW,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,KAAK,gBAAgB,EACrB,cAAc,EACd,SAAS,EACT,YAAY,GACb,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,UAAU,EACV,cAAc,EACd,eAAe,EACf,YAAY,EACZ,oBAAoB,GACrB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,gBAAgB,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC/E,OAAO,EACL,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AACnE,OAAO,EACL,0BAA0B,EAC1B,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,SAAS,EACT,SAAS,EACT,iBAAiB,GAClB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EACL,KAAK,WAAW,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,KAAK,gBAAgB,EACrB,cAAc,EACd,SAAS,EACT,YAAY,GACb,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,UAAU,EACV,cAAc,EACd,eAAe,EACf,YAAY,EACZ,oBAAoB,GACrB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,gBAAgB,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -800,6 +800,12 @@ function asyncDisposable(target, dispose) {
800
800
  //#region src/http-data.ts
801
801
  /** Carries the serialized JS error between two webrun peers. */
802
802
  const PEER_ERROR_HEADER = "x-webrun-error";
803
+ /**
804
+ * The reason of `HttpExchange.signal` when the exchange is abandoned with no
805
+ * error of its own (`return()`); one abandoned by `throw(error)` aborts with
806
+ * that error.
807
+ */
808
+ const EXCHANGE_ABANDONED_MESSAGE = "webrun-http-streams: the exchange was abandoned before it was over";
803
809
  /** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
804
810
  const MAX_DETAIL_CHARS = 4096;
805
811
  /**
@@ -980,7 +986,11 @@ async function drain(output) {
980
986
  */
981
987
  function httpServe(handler, options = {}) {
982
988
  const codec = options.codec ?? defaultCodec;
983
- return async function* httpHandlerDuplex(input) {
989
+ return (input) => {
990
+ const abandoned = new AbortController();
991
+ return abortingOnReturn(exchange(input, { signal: abandoned.signal }), (reason) => abandoned.abort(reason ?? /* @__PURE__ */ new Error("webrun-http-streams: the exchange was abandoned before it was over")));
992
+ };
993
+ async function* exchange(input, context) {
984
994
  let decoded;
985
995
  try {
986
996
  decoded = await codec.decodeRequest(input);
@@ -992,7 +1002,7 @@ function httpServe(handler, options = {}) {
992
1002
  const replyCodec = decoded.codec ?? codec;
993
1003
  let result;
994
1004
  try {
995
- result = await handler(decoded.envelope, decoded.body);
1005
+ result = await handler(decoded.envelope, decoded.body, context);
996
1006
  } catch (error) {
997
1007
  yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
998
1008
  return;
@@ -1007,7 +1017,36 @@ function httpServe(handler, options = {}) {
1007
1017
  await result.release?.();
1008
1018
  } catch {}
1009
1019
  }
1020
+ }
1021
+ }
1022
+ /**
1023
+ * `gen`, except that `return()` and `throw()` from its consumer call `abandon`
1024
+ * at once — before they are queued behind a `next()` still parked inside the
1025
+ * generator (the handler's `await`) — unless the generator already finished.
1026
+ */
1027
+ function abortingOnReturn(gen, abandon) {
1028
+ let finished = false;
1029
+ const settle = (r) => {
1030
+ if (r.done) finished = true;
1031
+ return r;
1032
+ };
1033
+ const wrapped = {
1034
+ next: (...args) => gen.next(...args).then(settle),
1035
+ return(value) {
1036
+ if (!finished) abandon();
1037
+ finished = true;
1038
+ return gen.return(value);
1039
+ },
1040
+ throw(error) {
1041
+ if (!finished) abandon(error);
1042
+ finished = true;
1043
+ return gen.throw(error);
1044
+ },
1045
+ [Symbol.asyncIterator]() {
1046
+ return wrapped;
1047
+ }
1010
1048
  };
1049
+ return asyncDisposable(wrapped, () => wrapped.return(void 0));
1011
1050
  }
1012
1051
  //#endregion
1013
1052
  //#region src/request-streams.ts
@@ -1278,17 +1317,18 @@ async function fetchOverDuplex(call, request, options = {}) {
1278
1317
  else await release?.();
1279
1318
  return new Response(null, responseInit);
1280
1319
  }
1281
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
1320
+ return new Response(responseBody(respBody, request.signal, release), responseInit);
1282
1321
  }
1283
1322
  /**
1284
1323
  * Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
1285
1324
  * registered with any `webrun-streams-*` adapter's `serve`.
1286
1325
  */
1287
1326
  function serveFetchOverDuplex(handler, options = {}) {
1288
- return httpServe(async (env, body) => {
1327
+ return httpServe(async (env, body, exchange) => {
1289
1328
  const reqInit = {
1290
1329
  method: env.method,
1291
- headers: forwardableHeaders(env.headers)
1330
+ headers: forwardableHeaders(env.headers),
1331
+ signal: exchange?.signal
1292
1332
  };
1293
1333
  let upload;
1294
1334
  if (env.method !== "GET" && env.method !== "HEAD") {
@@ -1409,15 +1449,78 @@ function trackingEnd(source) {
1409
1449
  settle
1410
1450
  };
1411
1451
  }
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
- }
1452
+ /**
1453
+ * How long a cancelled body waits for the read in flight before it abandons the call anyway.
1454
+ * The read's answer tells a body that has ended (the call is finished: an upload the handler
1455
+ * reads after answering goes on) from one that has not; a peer that sends nothing more never
1456
+ * answers it. A body whose end comes later than this after the cancel is abandoned too, so a
1457
+ * handler still streaming its response and still reading the upload then loses the upload.
1458
+ */
1459
+ const CANCEL_GRACE_MS = 1e3;
1460
+ /**
1461
+ * The response body as a stream that the request's signal can end at any time.
1462
+ *
1463
+ * An abort errors the stream with the abort's reason at once — a read waiting
1464
+ * for a peer that sends nothing more rejects then, not when a next chunk
1465
+ * arrives, which may be never — and abandons the call (`release`), so on a
1466
+ * multiplexed transport its port is given back whether or not anyone still
1467
+ * reads the body. The listener is removed once the body ends, however it ends:
1468
+ * read to its end, cancelled, failed or aborted. A body nobody reads or cancels
1469
+ * keeps it until the signal aborts (and keeps the call open until then anyway).
1470
+ * A cancel abandons the call too — unless the read in flight comes back with the
1471
+ * body's end, which finishes it instead (see `cancel`).
1472
+ */
1473
+ function responseBody(body, signal, release) {
1474
+ const it = body[Symbol.asyncIterator]();
1475
+ let controller;
1476
+ let ended = false;
1477
+ let reading = false;
1478
+ const end = () => {
1479
+ ended = true;
1480
+ signal?.removeEventListener("abort", onAbort);
1481
+ };
1482
+ const onAbort = () => {
1483
+ if (ended) return;
1484
+ end();
1485
+ try {
1486
+ controller.error(abortReason(signal));
1487
+ } catch {}
1488
+ release?.();
1489
+ };
1490
+ return new ReadableStream({
1491
+ start(c) {
1492
+ controller = c;
1493
+ if (signal?.aborted) onAbort();
1494
+ else signal?.addEventListener("abort", onAbort, { once: true });
1495
+ },
1496
+ async pull(c) {
1497
+ try {
1498
+ reading = true;
1499
+ const { done, value } = await it.next().finally(() => {
1500
+ reading = false;
1501
+ });
1502
+ if (ended) return;
1503
+ if (done) {
1504
+ end();
1505
+ c.close();
1506
+ return;
1507
+ }
1508
+ c.enqueue(value);
1509
+ } catch (err) {
1510
+ if (ended) return;
1511
+ end();
1512
+ c.error(err);
1513
+ }
1514
+ },
1515
+ cancel() {
1516
+ end();
1517
+ const returned = Promise.resolve(it.return?.()).catch(() => {});
1518
+ if (!reading) return;
1519
+ const grace = setTimeout(() => void release?.(), CANCEL_GRACE_MS);
1520
+ grace.unref?.();
1521
+ returned.finally(() => clearTimeout(grace));
1522
+ }
1523
+ });
1421
1524
  }
1422
1525
  //#endregion
1423
1526
  //#region src/duplex-site-builder.ts
@@ -1521,4 +1624,4 @@ var HttpError = class HttpError extends Error {
1521
1624
  }
1522
1625
  };
1523
1626
  //#endregion
1524
- export { DuplexSiteBuilder, HttpError, HttpParseError, PEER_ERROR_HEADER, decodeMessage, defaultCodec, encodeMessage, fetchOverDuplex, httpCodec, httpFetch, httpServe, jsonEnvelopeCodec, newHttpClientStub, newHttpCodec, newHttpServerStub, newSniffingCodec, serveFetchOverDuplex };
1627
+ export { DuplexSiteBuilder, EXCHANGE_ABANDONED_MESSAGE, HttpError, HttpParseError, PEER_ERROR_HEADER, decodeMessage, defaultCodec, encodeMessage, fetchOverDuplex, httpCodec, httpFetch, httpServe, jsonEnvelopeCodec, newHttpClientStub, newHttpCodec, newHttpServerStub, newSniffingCodec, serveFetchOverDuplex };
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.4.0",
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
  /**
@@ -166,10 +166,12 @@ export function serveFetchOverDuplex(
166
166
  handler: (request: Request) => Promise<Response>,
167
167
  options: HttpDataOptions = {},
168
168
  ): Duplex {
169
- return httpServe(async (env, body) => {
169
+ return httpServe(async (env, body, exchange) => {
170
170
  const reqInit: RequestInit = {
171
171
  method: env.method,
172
172
  headers: forwardableHeaders(env.headers),
173
+ // Aborts when the caller abandons the exchange (see `HttpExchange`).
174
+ signal: exchange?.signal,
173
175
  };
174
176
  let upload: ReturnType<typeof trackingEnd> | undefined;
175
177
  if (env.method !== "GET" && env.method !== "HEAD") {
@@ -329,16 +331,88 @@ function trackingEnd(source: AsyncIterable<Uint8Array>): {
329
331
  return { source: tracked, settle };
330
332
  }
331
333
 
332
- async function* withAbort(
333
- iter: AsyncIterable<Uint8Array>,
334
+ /**
335
+ * How long a cancelled body waits for the read in flight before it abandons the call anyway.
336
+ * The read's answer tells a body that has ended (the call is finished: an upload the handler
337
+ * reads after answering goes on) from one that has not; a peer that sends nothing more never
338
+ * answers it. A body whose end comes later than this after the cancel is abandoned too, so a
339
+ * handler still streaming its response and still reading the upload then loses the upload.
340
+ */
341
+ const CANCEL_GRACE_MS = 1_000;
342
+
343
+ /**
344
+ * The response body as a stream that the request's signal can end at any time.
345
+ *
346
+ * An abort errors the stream with the abort's reason at once — a read waiting
347
+ * for a peer that sends nothing more rejects then, not when a next chunk
348
+ * arrives, which may be never — and abandons the call (`release`), so on a
349
+ * multiplexed transport its port is given back whether or not anyone still
350
+ * reads the body. The listener is removed once the body ends, however it ends:
351
+ * read to its end, cancelled, failed or aborted. A body nobody reads or cancels
352
+ * keeps it until the signal aborts (and keeps the call open until then anyway).
353
+ * A cancel abandons the call too — unless the read in flight comes back with the
354
+ * body's end, which finishes it instead (see `cancel`).
355
+ */
356
+ function responseBody(
357
+ body: AsyncIterable<Uint8Array>,
334
358
  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
- }
359
+ release: (() => Promise<void>) | undefined,
360
+ ): ReadableStream<Uint8Array> {
361
+ const it = body[Symbol.asyncIterator]();
362
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
363
+ let ended = false;
364
+ let reading = false;
365
+ const end = () => {
366
+ ended = true;
367
+ signal?.removeEventListener("abort", onAbort);
368
+ };
369
+ const onAbort = () => {
370
+ if (ended) return;
371
+ end();
372
+ try {
373
+ controller.error(abortReason(signal as AbortSignal));
374
+ } catch {
375
+ /* already closed or errored */
376
+ }
377
+ void release?.();
378
+ };
379
+ return new ReadableStream<Uint8Array>({
380
+ start(c) {
381
+ controller = c;
382
+ if (signal?.aborted) onAbort();
383
+ else signal?.addEventListener("abort", onAbort, { once: true });
384
+ },
385
+ async pull(c) {
386
+ try {
387
+ reading = true;
388
+ const { done, value } = await it.next().finally(() => {
389
+ reading = false;
390
+ });
391
+ if (ended) return; // aborted or cancelled while this read waited
392
+ if (done) {
393
+ end();
394
+ c.close();
395
+ return;
396
+ }
397
+ c.enqueue(value);
398
+ } catch (err) {
399
+ if (ended) return;
400
+ end();
401
+ c.error(err);
402
+ }
403
+ },
404
+ cancel() {
405
+ end();
406
+ const returned = Promise.resolve(it.return?.()).catch(() => {});
407
+ if (!reading) return; // `it` is at rest: its return abandons the call (releasingOnExit)
408
+ // A read is in flight, and `it.return()` is queued behind it: that read's answer decides.
409
+ // The body's end → the call is finished, not abandoned, so an upload the handler reads
410
+ // after answering goes on; a chunk → the return then abandons the call. A peer that never
411
+ // answers (sends nothing more, for good) would hold the call forever: abandon it after a
412
+ // grace, without waiting in `cancel()` either way.
413
+ const grace = setTimeout(() => void release?.(), CANCEL_GRACE_MS);
414
+ (grace as { unref?: () => void }).unref?.();
415
+ void returned.finally(() => clearTimeout(grace));
416
+ },
417
+ });
344
418
  }
package/src/http-data.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Duplex } from "@statewalker/webrun-streams";
2
2
  import { deserializeError, serializeError } from "@statewalker/webrun-streams";
3
+ import { asyncDisposable } from "./async-dispose.js";
3
4
  import { discard } from "./bytes.js";
4
5
  import { defaultCodec } from "./codec-default.js";
5
6
  import { HttpParseError } from "./http1/errors.js";
@@ -91,8 +92,31 @@ export interface HttpDataHandlerResult {
91
92
  export type HttpDataHandler = (
92
93
  env: RequestEnvelope,
93
94
  body: AsyncIterable<Uint8Array>,
95
+ exchange: HttpExchange,
94
96
  ) => Promise<HttpDataHandlerResult>;
95
97
 
98
+ /** What a handler is told about the exchange it serves. */
99
+ export interface HttpExchange {
100
+ /**
101
+ * Aborts when the exchange is abandoned before it is over: the caller gave
102
+ * up or its transport went away, or the serving side tore the stream down
103
+ * (its own timeout, a shutdown). A handler that waits before it answers (a
104
+ * long poll) can stop then. The exchange is over once the response went out
105
+ * in full and the handler is done with the request body: a handler that
106
+ * answers first and reads the upload afterwards still sees an abort if the
107
+ * upload is cut. A completed exchange never aborts.
108
+ */
109
+ signal: AbortSignal;
110
+ }
111
+
112
+ /**
113
+ * The reason of `HttpExchange.signal` when the exchange is abandoned with no
114
+ * error of its own (`return()`); one abandoned by `throw(error)` aborts with
115
+ * that error.
116
+ */
117
+ export const EXCHANGE_ABANDONED_MESSAGE =
118
+ "webrun-http-streams: the exchange was abandoned before it was over";
119
+
96
120
  /** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
97
121
  const MAX_DETAIL_CHARS = 4096;
98
122
 
@@ -337,7 +361,16 @@ async function drain(output: AsyncGenerator<Uint8Array>): Promise<void> {
337
361
  */
338
362
  export function httpServe(handler: HttpDataHandler, options: HttpDataOptions = {}): Duplex {
339
363
  const codec = options.codec ?? defaultCodec;
340
- return async function* httpHandlerDuplex(input) {
364
+ return (input) => {
365
+ const abandoned = new AbortController();
366
+ return abortingOnReturn(exchange(input, { signal: abandoned.signal }), (reason) =>
367
+ abandoned.abort(reason ?? new Error(EXCHANGE_ABANDONED_MESSAGE)),
368
+ );
369
+ };
370
+ async function* exchange(
371
+ input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
372
+ context: HttpExchange,
373
+ ): AsyncGenerator<Uint8Array> {
341
374
  // A refusal to parse the request must still produce a response. Left
342
375
  // uncaught, the exception propagates out of this generator and NOTHING is
343
376
  // written — a real peer waits, then sees the connection end with no status
@@ -366,7 +399,7 @@ export function httpServe(handler: HttpDataHandler, options: HttpDataOptions = {
366
399
  const replyCodec = decoded.codec ?? codec;
367
400
  let result: HttpDataHandlerResult;
368
401
  try {
369
- result = await handler(decoded.envelope, decoded.body);
402
+ result = await handler(decoded.envelope, decoded.body, context);
370
403
  } catch (error) {
371
404
  yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
372
405
  return;
@@ -387,5 +420,39 @@ export function httpServe(handler: HttpDataHandler, options: HttpDataOptions = {
387
420
  }
388
421
  }
389
422
  }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * `gen`, except that `return()` and `throw()` from its consumer call `abandon`
428
+ * at once — before they are queued behind a `next()` still parked inside the
429
+ * generator (the handler's `await`) — unless the generator already finished.
430
+ */
431
+ function abortingOnReturn<T>(
432
+ gen: AsyncGenerator<T>,
433
+ abandon: (reason?: unknown) => void,
434
+ ): AsyncGenerator<T> {
435
+ let finished = false;
436
+ const settle = (r: IteratorResult<T>) => {
437
+ if (r.done) finished = true;
438
+ return r;
439
+ };
440
+ const wrapped: AsyncGenerator<T> = {
441
+ next: (...args: [] | [unknown]) => gen.next(...args).then(settle),
442
+ return(value) {
443
+ if (!finished) abandon();
444
+ finished = true;
445
+ return gen.return(value);
446
+ },
447
+ throw(error) {
448
+ if (!finished) abandon(error);
449
+ finished = true;
450
+ return gen.throw(error);
451
+ },
452
+ [Symbol.asyncIterator]() {
453
+ return wrapped;
454
+ },
390
455
  };
456
+ // `await using` ends it as leaving a `for await` early does.
457
+ return asyncDisposable(wrapped, () => wrapped.return(undefined));
391
458
  }
package/src/index.ts CHANGED
@@ -9,9 +9,11 @@ export {
9
9
  } from "./envelope.js";
10
10
  export { fetchOverDuplex, serveFetchOverDuplex } from "./fetch.js";
11
11
  export {
12
+ EXCHANGE_ABANDONED_MESSAGE,
12
13
  type HttpDataHandler,
13
14
  type HttpDataHandlerResult,
14
15
  type HttpDataOptions,
16
+ type HttpExchange,
15
17
  type HttpFetchResult,
16
18
  httpFetch,
17
19
  httpServe,