@statewalker/webrun-http-streams 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,CA0EnB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,EAChD,OAAO,GAAE,eAAoB,GAC5B,MAAM,CAiDR"}
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"}
@@ -9,10 +9,25 @@ export type HttpDataOptions = {
9
9
  export interface HttpFetchResult {
10
10
  envelope: ResponseEnvelope;
11
11
  body: AsyncIterable<Uint8Array>;
12
+ /**
13
+ * Abandons the call: the peer's producer is released (its `finally` runs).
14
+ * Leaving `body` early — `break`, `.return()`, a cancelled stream — does
15
+ * the same; this is for a caller that never starts reading it, such as a
16
+ * bodyless response. Optional, so that results built elsewhere (mocks,
17
+ * custom transports) still type-check; `httpFetch` always sets it.
18
+ */
19
+ release?(): Promise<void>;
12
20
  }
13
21
  export interface HttpDataHandlerResult {
14
22
  envelope: ResponseEnvelope;
15
23
  body?: ByteSource;
24
+ /**
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.
29
+ */
30
+ release?: () => unknown;
16
31
  }
17
32
  /**
18
33
  * Low-level HTTP-over-Duplex handler. Takes the request envelope plus body
@@ -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;CACjC;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;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,CAM1B;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,eAAe,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CAwCzF"}
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"}
package/dist/index.js CHANGED
@@ -855,11 +855,41 @@ async function throwIfPeerError(result, output) {
855
855
  async function httpFetch(call, env, body, options = {}) {
856
856
  const codec = options.codec ?? defaultCodec;
857
857
  const output = call(codec.encodeRequest(env, body));
858
- const result = await codec.decodeResponse(output, { method: env.method });
858
+ const decoded = await codec.decodeResponse(output, { method: env.method });
859
+ const release = async () => {
860
+ try {
861
+ await output.return?.(void 0);
862
+ } catch {}
863
+ };
864
+ const result = {
865
+ envelope: decoded.envelope,
866
+ body: releasingOnExit(decoded.body, release),
867
+ release
868
+ };
859
869
  await throwIfPeerError(result, output);
860
870
  return result;
861
871
  }
862
872
  /**
873
+ * `body`, but leaving it before its end releases the call.
874
+ *
875
+ * 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.
882
+ */
883
+ async function* releasingOnExit(body, release) {
884
+ let completed = false;
885
+ try {
886
+ yield* body;
887
+ completed = true;
888
+ } finally {
889
+ if (!completed) await release();
890
+ }
891
+ }
892
+ /**
863
893
  * Wrap an HTTP handler as a `Duplex` so it can be registered with any
864
894
  * `webrun-streams-*` adapter's `serve`.
865
895
  */
@@ -882,7 +912,15 @@ function httpServe(handler, options = {}) {
882
912
  yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
883
913
  return;
884
914
  }
885
- yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
915
+ let completed = false;
916
+ try {
917
+ yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
918
+ completed = true;
919
+ } finally {
920
+ if (!completed) try {
921
+ await result.release?.();
922
+ } catch {}
923
+ }
886
924
  };
887
925
  }
888
926
  //#endregion
@@ -1140,14 +1178,14 @@ async function fetchOverDuplex(call, request, options = {}) {
1140
1178
  const buffered = new Uint8Array(await request.arrayBuffer());
1141
1179
  if (buffered.byteLength > 0) body = [buffered];
1142
1180
  }
1143
- const { envelope, body: respBody } = await httpFetch(call, env, body, options);
1181
+ const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
1144
1182
  const responseInit = {
1145
1183
  status: envelope.status,
1146
1184
  statusText: envelope.statusText,
1147
1185
  headers: forwardableHeaders(envelope.headers)
1148
1186
  };
1149
1187
  if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
1150
- await respBody.return?.();
1188
+ await release?.();
1151
1189
  return new Response(null, responseInit);
1152
1190
  }
1153
1191
  return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
@@ -1183,7 +1221,8 @@ function serveFetchOverDuplex(handler, options = {}) {
1183
1221
  }
1184
1222
  return {
1185
1223
  envelope: respEnv,
1186
- body: response.body ? readableToAsyncIterable(response.body) : void 0
1224
+ body: response.body ? readableToAsyncIterable(response.body) : void 0,
1225
+ release: () => response.body?.cancel()
1187
1226
  };
1188
1227
  }, options);
1189
1228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-streams",
3
- "version": "0.2.5",
3
+ "version": "0.3.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
@@ -126,46 +126,21 @@ export async function fetchOverDuplex(
126
126
  const buffered = new Uint8Array(await request.arrayBuffer());
127
127
  if (buffered.byteLength > 0) body = [buffered];
128
128
  }
129
- const { envelope, body: respBody } = await httpFetch(call, env, body, options);
129
+ const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
130
130
  const responseInit: ResponseInit = {
131
131
  status: envelope.status,
132
132
  statusText: envelope.statusText,
133
133
  headers: forwardableHeaders(envelope.headers),
134
134
  };
135
135
  // `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 try to
137
- // let go of the body we were handed.
138
- //
139
- // KNOWN GAP — this `.return()` does not actually release the producer, and
140
- // `discard` would not fix it either. Do not read it as a working drain.
141
- //
142
- // `respBody` is *not* the generator the codec pulled from. `decodeResponse`
143
- // pulls from its `input` (the `output` generator returned by `call(...)`) to
144
- // read the head, and hands back a freshly constructed one —
145
- // `convertBodyErrors(readBody(...))` in `http1/decode.ts`. Nobody has pulled
146
- // from that, so it is in suspended start and `.return()` on it is a no-op,
147
- // exactly as in the two sites `http-stubs.ts` fixed. Measured: the producer's
148
- // `finally` does not run for 204, 205, 304, HEAD+200 or OPTIONS+200.
149
- //
150
- // `discard` does not help because the release has to reach further down.
151
- // `ByteReader` never propagates `.return()` to its own `#iter`, so only
152
- // `await output.return()` frees the underlying call — and `fetchOverDuplex`
153
- // has no handle on `output`, since `httpFetch` returns only `{envelope,
154
- // body}`. Closing this needs `httpFetch` to expose the call for cancellation;
155
- // that is a design change, not a patch here.
156
- //
157
- // Worth knowing which shapes leak a *populated* body rather than an empty
158
- // one: `decodeResponse`'s own bodyless test covers status < 200, 204, 304 and
159
- // HEAD, so for 205 and for OPTIONS the decoder frames a real body that is
160
- // then abandoned. `http-data.ts` (see `throwIfPeerError`) spells out the cost
161
- // on a mux transport — one stream-table slot per call, until `maxStreams`.
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.
162
138
  if (
163
139
  request.method === "HEAD" ||
164
140
  request.method === "OPTIONS" ||
165
141
  NULL_BODY_STATUSES.has(envelope.status)
166
142
  ) {
167
- const returnable = respBody as AsyncIterable<Uint8Array> & { return?: () => unknown };
168
- await returnable.return?.();
143
+ await release?.();
169
144
  return new Response(null, responseInit);
170
145
  }
171
146
  return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
@@ -225,6 +200,10 @@ export function serveFetchOverDuplex(
225
200
  return {
226
201
  envelope: respEnv,
227
202
  body: response.body ? readableToAsyncIterable(response.body) : undefined,
203
+ // A body the codec never started reading has no reader to cancel
204
+ // through; cancel the stream itself. Once read, it is locked and this
205
+ // rejects, harmlessly: the reader's own cancellation has run.
206
+ release: () => response.body?.cancel(),
228
207
  };
229
208
  }, options);
230
209
  }
package/src/http-data.ts CHANGED
@@ -22,11 +22,26 @@ export type HttpDataOptions = {
22
22
  export interface HttpFetchResult {
23
23
  envelope: ResponseEnvelope;
24
24
  body: AsyncIterable<Uint8Array>;
25
+ /**
26
+ * Abandons the call: the peer's producer is released (its `finally` runs).
27
+ * Leaving `body` early — `break`, `.return()`, a cancelled stream — does
28
+ * the same; this is for a caller that never starts reading it, such as a
29
+ * bodyless response. Optional, so that results built elsewhere (mocks,
30
+ * custom transports) still type-check; `httpFetch` always sets it.
31
+ */
32
+ release?(): Promise<void>;
25
33
  }
26
34
 
27
35
  export interface HttpDataHandlerResult {
28
36
  envelope: ResponseEnvelope;
29
37
  body?: ByteSource;
38
+ /**
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.
43
+ */
44
+ release?: () => unknown;
30
45
  }
31
46
 
32
47
  /**
@@ -152,11 +167,47 @@ export async function httpFetch(
152
167
  ): Promise<HttpFetchResult> {
153
168
  const codec = options.codec ?? defaultCodec;
154
169
  const output = call(codec.encodeRequest(env, body));
155
- const result = await codec.decodeResponse(output, { method: env.method });
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 */
176
+ }
177
+ };
178
+ const result: HttpFetchResult = {
179
+ envelope: decoded.envelope,
180
+ body: releasingOnExit(decoded.body, release),
181
+ release,
182
+ };
156
183
  await throwIfPeerError(result, output);
157
184
  return result;
158
185
  }
159
186
 
187
+ /**
188
+ * `body`, but leaving it before its end releases the call.
189
+ *
190
+ * 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.
197
+ */
198
+ async function* releasingOnExit(
199
+ body: AsyncIterable<Uint8Array>,
200
+ release: () => Promise<void>,
201
+ ): AsyncGenerator<Uint8Array> {
202
+ let completed = false;
203
+ try {
204
+ yield* body;
205
+ completed = true;
206
+ } finally {
207
+ if (!completed) await release();
208
+ }
209
+ }
210
+
160
211
  /**
161
212
  * Wrap an HTTP handler as a `Duplex` so it can be registered with any
162
213
  * `webrun-streams-*` adapter's `serve`.
@@ -197,8 +248,20 @@ export function httpServe(handler: HttpDataHandler, options: HttpDataOptions = {
197
248
  yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
198
249
  return;
199
250
  }
200
- yield* replyCodec.encodeResponse(result.envelope, result.body, {
201
- method: decoded.envelope.method,
202
- });
251
+ let completed = false;
252
+ try {
253
+ yield* replyCodec.encodeResponse(result.envelope, result.body, {
254
+ method: decoded.envelope.method,
255
+ });
256
+ completed = true;
257
+ } finally {
258
+ if (!completed) {
259
+ try {
260
+ await result.release?.();
261
+ } catch {
262
+ /* already released through the body, or nothing to release */
263
+ }
264
+ }
265
+ }
203
266
  };
204
267
  }