@statewalker/webrun-http-streams 0.3.2 → 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
@@ -1285,10 +1324,11 @@ async function fetchOverDuplex(call, request, options = {}) {
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") {
@@ -1584,4 +1624,4 @@ var HttpError = class HttpError extends Error {
1584
1624
  }
1585
1625
  };
1586
1626
  //#endregion
1587
- 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.2",
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
@@ -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") {
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,