@statewalker/webrun-http-streams 0.2.4 → 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.
package/README.md CHANGED
@@ -1,80 +1,98 @@
1
1
  # @statewalker/webrun-http-streams
2
2
 
3
- HTTP request / response over a `Duplex` from any `webrun-streams-*` adapter.
4
- Replaces the retired `webrun-http` + `webrun-http-port` packages.
3
+ ## What it is
5
4
 
6
- ## Install
5
+ HTTP request/response over a `Duplex` from `@statewalker/webrun-streams`. A
6
+ caller sends a standard `Request` (or a plain envelope plus body bytes) into a
7
+ `Duplex` call and gets a `Response` back. A server wraps a
8
+ `(Request) => Promise<Response>` handler as a `Duplex` that any transport
9
+ adapter can serve. The bytes on the `Duplex` are conforming HTTP/1.1 by
10
+ default. The package also ships a pair of transport-agnostic stubs that move a
11
+ structured-clonable envelope instead of bytes.
12
+
13
+ ## Why it exists
14
+
15
+ A `Duplex` is one call that streams bytes in and bytes out, and every transport
16
+ adapter in this repository produces one: `@statewalker/webrun-rpc` over a
17
+ `MessagePort` or worker, `@statewalker/webrun-streams-ws`,
18
+ `@statewalker/webrun-streams-webrtc`, `@statewalker/webrun-streams-libp2p`,
19
+ `@statewalker/webrun-streams-livekit`, `@statewalker/webrun-streams-peerjs`.
20
+ This package puts HTTP on top of that seam, so an HTTP handler or a fetch-style
21
+ caller works over any of those transports without change.
22
+
23
+ The default wire format is real HTTP/1.1, not a private encoding. A peer that
24
+ is not webrun at all, such as `node:http`, can read what this package writes
25
+ and write what it reads.
26
+
27
+ ## How to use
7
28
 
8
29
  ```sh
9
- npm install @statewalker/webrun-http-streams
30
+ pnpm add @statewalker/webrun-http-streams
10
31
  ```
11
32
 
12
- One runtime dependency, [`@statewalker/webrun-streams`](../webrun-streams); no
13
- peer dependencies. ESM only (`"type": "module"`). Needs standard `Request`,
14
- `Response`, `ReadableStream`, `TextEncoder` and `TextDecoder` — present in
15
- browsers, Node 18+, Deno, Bun and Cloudflare Workers.
33
+ - **Peer dependencies:** none.
34
+ - **Runtime dependency:** `@statewalker/webrun-streams` (the `Duplex` type,
35
+ stream helpers, error serialisation).
36
+ - **Entry point:** one, `.`. ESM only: `dist/index.js` with `dist/index.d.ts`.
37
+ The `source` export condition points at `src/index.ts`.
38
+ - **Environment:** anything with `Request`, `Response`, `ReadableStream`,
39
+ `TextEncoder` and `TextDecoder`: browsers, workers, service workers, Node,
40
+ Deno, Bun. Firefox loses request-body streaming; see
41
+ [Firefox buffers request bodies](#firefox-buffers-request-bodies).
16
42
 
17
- Pair it with a transport adapter to get a `Duplex`:
18
- [`-ws`](../webrun-streams-ws), [`webrun-rpc`](../webrun-rpc),
19
- [`-webrtc`](../webrun-streams-webrtc), [`-libp2p`](../webrun-streams-libp2p),
20
- [`-livekit`](../webrun-streams-livekit), [`-peerjs`](../webrun-streams-peerjs).
43
+ The API comes in three layers on the `Duplex` seam, plus the stubs:
21
44
 
22
- ## Getting started
45
+ | Layer | Exports | Shape |
46
+ | --- | --- | --- |
47
+ | Data | `httpFetch`, `httpServe` | Envelope + `AsyncIterable<Uint8Array>` body. No `Request`/`Response`. |
48
+ | Fetch | `fetchOverDuplex`, `serveFetchOverDuplex` | Standard `Request` / `Response`. |
49
+ | Site host | `DuplexSiteBuilder` | Hands a `(Request) => Promise<Response>` handler to an adapter's `serve`. |
50
+ | Stubs (no `Duplex`) | `newHttpClientStub`, `newHttpServerStub` | `SerializedHttpEnvelope` over any `send` function. |
23
51
 
24
- Pick a transport, then move standard `Request` / `Response` objects across it.
25
- Here the transport is an in-process `MessageChannel`, so the whole exchange runs
26
- in one process with no network:
52
+ Happy path, with `@statewalker/webrun-rpc` as the transport over an in-process
53
+ `MessageChannel`:
27
54
 
28
55
  ```ts
29
- import { connect, serve } from "@statewalker/webrun-rpc";
56
+ import { connect, overPipe, serve, structuredCodec } from "@statewalker/webrun-rpc";
30
57
  import { fetchOverDuplex, serveFetchOverDuplex } from "@statewalker/webrun-http-streams";
31
58
 
32
- // --- server side ---
33
59
  const channel = new MessageChannel();
60
+
61
+ // server side
34
62
  const stop = await serve(
35
- { port: channel.port2 },
63
+ { mux: overPipe(channel.port2, { codec: structuredCodec, side: "responder" }) },
36
64
  serveFetchOverDuplex(async (request) => {
37
65
  const url = new URL(request.url);
38
66
  return Response.json({ path: url.pathname, method: request.method });
39
67
  }),
40
68
  );
41
69
 
42
- // --- client side ---
43
- const { call, close } = await connect({ port: channel.port1 });
70
+ // client side
71
+ const { call, close } = await connect({
72
+ mux: overPipe(channel.port1, { codec: structuredCodec, side: "initiator" }),
73
+ });
44
74
  const response = await fetchOverDuplex(call, new Request("http://local/api/todo/7"));
45
75
 
46
- console.log(response.status); // 200
47
- console.log(await response.json()); // { path: "/api/todo/7", method: "GET" }
76
+ console.log(response.status); // 200
77
+ console.log(await response.json()); // { path: "/api/todo/7", method: "GET" }
48
78
 
49
79
  await close();
50
80
  await stop();
51
81
  ```
52
82
 
53
- Swap `@statewalker/webrun-rpc` for
54
- [`-ws`](../webrun-streams-ws), [`-webrtc`](../webrun-streams-webrtc),
55
- [`-libp2p`](../webrun-streams-libp2p), [`-livekit`](../webrun-streams-livekit)
56
- or [`-peerjs`](../webrun-streams-peerjs) and nothing else changes — that is the
57
- whole point of the `Duplex` seam.
58
-
59
- Streaming bodies work as you would expect: a `Response` whose body is a
60
- `ReadableStream` streams across the transport chunk by chunk, which is what
61
- makes server-sent events work over a `MessagePort` or a WebRTC link.
83
+ Swap the transport adapter and nothing else changes. A `Response` whose body is
84
+ a `ReadableStream` streams across chunk by chunk, so server-sent events work
85
+ over a `MessagePort` or a WebRTC link.
62
86
 
63
- ## Layers
87
+ ## Examples
64
88
 
65
- Three of them sit on the `Duplex` seam, plus one older transport-agnostic pair
66
- that predates it and is kept for `webrun-http-browser`.
67
-
68
- ### Data layer — `httpFetch` / `httpServe`
69
-
70
- Envelopes and body iterators, no `Request`/`Response` involved.
89
+ ### Data layer: `httpFetch` / `httpServe`
71
90
 
72
91
  ```ts
73
92
  import { httpFetch, httpServe } from "@statewalker/webrun-http-streams";
74
93
 
75
- // `call` is a Duplex. In-process it can be the handler itself; over a
76
- // transport it comes from an adapter's `connect`:
77
- // const { call } = await connect({ url }); // webrun-streams-ws
94
+ // A Duplex is `(input) => AsyncGenerator<Uint8Array>`, so the server side is a
95
+ // usable `call` with no transport at all: the cheapest way to test a handler.
78
96
  const call = httpServe(async (env, body) => {
79
97
  for await (const _chunk of body) {
80
98
  /* drain the request body */
@@ -93,20 +111,17 @@ const { envelope, body } = await httpFetch(call, {
93
111
  // envelope.status === 200; `body` is an AsyncIterable<Uint8Array>
94
112
  ```
95
113
 
96
- `httpServe(handler)` returns a `Duplex` you can hand to any adapter's
97
- `serve(...)`. Because a `Duplex` is just `(input) => AsyncGenerator<Uint8Array>`,
98
- the handler side *is* a usable `call` with no transport at all — that is what
99
- the snippet above does, and it is the cheapest way to test a handler.
114
+ Signatures: `httpFetch(call, env, body?, { codec? })` and
115
+ `httpServe(handler, { codec? })`. Over a real transport, `call` comes from an
116
+ adapter's `connect` and `httpServe(...)` goes to its `serve`.
100
117
 
101
- A relative `url` in the envelope works, but note it does not survive the round
102
- trip verbatim: HTTP/1.1 origin-form carries no scheme or authority, so the
103
- decoder rebuilds an absolute url from the codec's `scheme`/`host` options.
104
- `url: "/api/time"` arrives at the handler as `http://localhost/api/time` unless
105
- you configure the codec (see below).
118
+ A relative `url` works but does not survive the round trip verbatim. HTTP/1.1
119
+ origin-form carries no scheme or authority, so the decoder rebuilds an
120
+ absolute url from the codec's `scheme` and `host` options: `url: "/api/time"`
121
+ reaches the handler as `http://localhost/api/time` unless you configure the
122
+ codec.
106
123
 
107
- ### Fetch layer — `fetchOverDuplex` / `serveFetchOverDuplex`
108
-
109
- The same thing in terms of standard `Request` / `Response`.
124
+ ### Fetch layer: `fetchOverDuplex` / `serveFetchOverDuplex`
110
125
 
111
126
  ```ts
112
127
  import { fetchOverDuplex, serveFetchOverDuplex } from "@statewalker/webrun-http-streams";
@@ -118,44 +133,42 @@ const response = await fetchOverDuplex(call, new Request("http://peer.test/api/t
118
133
  await response.text(); // "hello /api/time"
119
134
  ```
120
135
 
121
- Hop-by-hop headers (`connection`, `transfer-encoding`, `host`, …) are stripped
122
- in both directions: the codec surfaces them verbatim, but they are meaningless
123
- to a `Request`/`Response` and re-emitting them from a relay would corrupt its
124
- framing.
125
-
126
- `fetchOverDuplex` plumbs `request.signal` into body iteration, so aborting the
127
- signal terminates the call.
136
+ Both take an optional last argument `{ codec }`. `fetchOverDuplex` plumbs
137
+ `request.signal` into body iteration, so aborting the signal ends the call. An
138
+ already-aborted signal rejects before anything is sent.
128
139
 
129
- ### Site host — `DuplexSiteBuilder`
140
+ ### Site host: `DuplexSiteBuilder`
130
141
 
131
142
  ```ts
132
143
  import { DuplexSiteBuilder } from "@statewalker/webrun-http-streams";
133
- import { serve } from "@statewalker/webrun-rpc";
144
+ import { overPipe, serve, structuredCodec } from "@statewalker/webrun-rpc";
145
+
146
+ const siteHandler = async (request: Request) =>
147
+ new Response(`site ${new URL(request.url).pathname}`);
148
+ const { port2 } = new MessageChannel();
134
149
 
135
150
  const stop = await new DuplexSiteBuilder()
136
- .setHandler(siteHandler) // (Request) => Promise<Response>
137
- .start(serve, { port });
151
+ .setHandler(siteHandler)
152
+ .start(serve, { mux: overPipe(port2, { codec: structuredCodec, side: "responder" }) });
138
153
  // later: await stop();
139
154
  ```
140
155
 
141
- `.setCodec(codec)` pins the wire format. `DuplexSiteBuilder` is the
142
- cross-platform sibling of `HostedSiteBuilder` (browser + ServiceWorker) — same
143
- `SiteHandler` seam, different transport. It holds no site configuration of its
144
- own; endpoints, files and auth belong to the `SiteHandler` producer (typically
145
- `SiteBuilder`).
146
-
147
- > Not runnable as written above: it needs a live `MessagePort` and
148
- > `@statewalker/webrun-rpc`, which this package does not depend on.
149
- > `start` accepts any `Serve<P>`, so an in-process one is enough to exercise
150
- > it. Calling `start()` before `setHandler()` throws.
156
+ `start(serve, params)` accepts any adapter's `Serve<P>` together with its
157
+ params. `.setCodec(codec)` pins the wire format. The builder holds no site
158
+ configuration: endpoints, files and auth belong to whatever produced the
159
+ handler. `SiteHandler` is exported as the structural type
160
+ `(request: Request) => Promise<Response>`, so this package does not depend on
161
+ any site-builder package. Calling `start()` before `setHandler()` rejects with
162
+ `DuplexSiteBuilder.start: setHandler(handler) must be called before start()`.
151
163
 
152
- ### Transport-agnostic stubs — `newHttpClientStub` / `newHttpServerStub`
164
+ ### Transport-agnostic stubs: `newHttpClientStub` / `newHttpServerStub`
153
165
 
154
- Predates the `Duplex` seam and does **not** use it. Instead of bytes on a wire,
155
- these move a `SerializedHttpEnvelope` — a plain options object plus a body
156
- iterable — over whatever `send` function you give them. `webrun-http-browser`
157
- is built on this pair, because a MessagePort can structured-clone the envelope
158
- directly and never needs a byte encoding.
166
+ These do not use the `Duplex` seam. They move a `SerializedHttpEnvelope`
167
+ (`{ options, content }`: plain request or response options plus a body
168
+ iterable) over whatever `send` function you give them. A `MessagePort` can
169
+ structured-clone that envelope directly, so it never needs a byte encoding.
170
+ `@statewalker/webrun-http-browser` builds its ServiceWorker transport on this
171
+ pair.
159
172
 
160
173
  ```ts
161
174
  import { newHttpClientStub, newHttpServerStub } from "@statewalker/webrun-http-streams";
@@ -167,56 +180,8 @@ const res = await client(new Request("http://peer.test/x", { method: "POST", bod
167
180
  await res.text(); // "echo hi"
168
181
  ```
169
182
 
170
- A `send` that resolves `undefined` — no service registered for this call —
171
- becomes a `404` on the client side. `SerializedHttpEnvelope.content` must be
172
- *productive*: it has to yield or finish on its own, because a body neither stub
173
- is allowed to read is released by draining it, not by a bare `.return()`.
174
-
175
- Use the `Duplex` layers for anything new. These stay because the browser
176
- package's MessagePort transport is built around them.
177
-
178
- ## Wire format
179
-
180
- Conforming **HTTP/1.1** by default — a `Duplex` carries bytes a real HTTP
181
- implementation can parse, and accepts bytes a real HTTP implementation
182
- produces. Verified against `node:http` in both directions.
183
-
184
- ```
185
- POST /api?a=1 HTTP/1.1
186
- Host: peer.test
187
- Connection: close
188
- Transfer-Encoding: chunked
189
-
190
- 5
191
- hello
192
- 0
193
-
194
- ```
195
-
196
- One message per `Duplex` call: `Connection: close` is always emitted, and bytes
197
- after a complete message are an error. Bodies use `Content-Length` when the
198
- caller declares one and chunked transfer coding otherwise; a message declaring
199
- both is refused.
200
-
201
- The codec is deliberately strict: every ambiguity is a refusal rather than a
202
- guess. One consequence worth calling out explicitly — RFC 9112 §2.2 permits a
203
- recipient to *tolerate* a single leading CRLF sent before the request-line
204
- ("SHOULD ignore"). This codec declines that leniency and refuses it as a
205
- malformed request line; see ADR-0006.
206
-
207
- ### Bodyless messages
208
-
209
- Responses with a null-body status — **204, 205, 304** — and responses to `HEAD`
210
- or `OPTIONS` put no bytes on the wire, in both directions, even if the
211
- handler's `Response` carries a body stream. The handler's stream is cancelled
212
- rather than sent. On the reading side you get `new Response(null, init)`, whose
213
- `.body` is `null`.
214
-
215
- The internal null-body set also lists 101 and 103, but neither is reachable
216
- through the fetch layer: `ResponseInit.status` must be 200–599, so
217
- `fetchOverDuplex` throws a `TypeError` from the `Response` constructor if a
218
- peer answers with one. Use the data layer (`httpFetch`) if you need to see an
219
- informational status.
183
+ A `send` that resolves `undefined` (no service for this call) becomes a `404`
184
+ on the client side.
220
185
 
221
186
  ### Choosing a codec
222
187
 
@@ -224,104 +189,41 @@ informational status.
224
189
  import {
225
190
  defaultCodec,
226
191
  httpCodec,
192
+ httpFetch,
227
193
  jsonEnvelopeCodec,
228
194
  newHttpCodec,
229
195
  newSniffingCodec,
230
196
  } from "@statewalker/webrun-http-streams";
231
197
 
232
- // default: writes HTTP/1.1, accepts HTTP/1.1 or the legacy JSON envelope
198
+ // default: writes HTTP/1.1, accepts HTTP/1.1 or the JSON envelope
233
199
  await httpFetch(call, env);
234
200
 
235
201
  // pinned
236
- await httpFetch(call, env, body, { codec: httpCodec });
202
+ await httpFetch(call, env, undefined, { codec: httpCodec });
237
203
 
238
204
  // the scheme and authority are not on the HTTP/1.1 wire; supply them here
239
205
  const codec = newHttpCodec({ scheme: "https", host: "peer.test" });
206
+
207
+ // your own write/accept combination
208
+ const strict = newSniffingCodec({ write: httpCodec, accept: [httpCodec] });
240
209
  ```
241
210
 
242
211
  | `newHttpCodec` option | Default | Meaning |
243
212
  | --- | --- | --- |
244
- | `scheme` | `"http"` | Scheme used to rebuild an absolute url on decode. Configuration, not wire data — a peer configured `http` rebuilds an `https` url as `http`. |
213
+ | `scheme` | `"http"` | Scheme used to rebuild an absolute url on decode. Configuration, not wire data: a peer configured `http` rebuilds an `https` url as `http`. |
245
214
  | `host` | `"localhost"` | Authority used when a url carries none. Also fills the mandatory `Host` header. |
246
215
  | `maxHeaderBytes` | `65536` | Bound on the whole head section, start line included. |
247
216
 
248
- `httpCodec` is `newHttpCodec()` with those defaults. `defaultCodec` — used
249
- whenever no `codec` option is supplied — is
217
+ `httpCodec` is `newHttpCodec()` with those defaults. `defaultCodec`, used
218
+ whenever no `codec` option is given, is
250
219
  `newSniffingCodec({ write: httpCodec, accept: [httpCodec, jsonEnvelopeCodec] })`.
251
220
 
252
- The previous format — `<JSON.stringify(envelope)>` + newline + body bytes —
253
- remains available as `jsonEnvelopeCodec` (and as the raw `encodeMessage` /
254
- `decodeMessage` pair), and readers accept it automatically, so the two ends of
255
- a peer pair can be upgraded in either order. A server answers in whichever
256
- format read the request. Sniffing needs no handshake because the formats are
257
- self-identifying: a JSON envelope always begins `{`, which is not a token
258
- character and so can never begin an HTTP start-line. See ADR-0006.
259
-
260
- ## Browser support
261
-
262
- Everything here is built on `Request`, `Response`, `ReadableStream`,
263
- `TextEncoder` and `TextDecoder`. Browsers do **not** behave uniformly, and one
264
- difference is large enough to change what this package can do.
265
-
266
- **Firefox does not implement `Request.prototype.body`** (checked against
267
- Firefox 146). `Object.getOwnPropertyDescriptor(Request.prototype, "body")` is
268
- `null` — the accessor is genuinely absent — and because a `ReadableStream` is
269
- then not a recognised `BodyInit`, `new Request(url, { body: stream })` falls
270
- through to the string branch and stores the literal text
271
- `[object ReadableStream]`.
272
-
273
- Both directions have a fallback, and both cost the same thing:
274
-
275
- - **Sending** (`fetchOverDuplex`, `newHttpClientStub`) — with no
276
- `request.body` to stream from, the whole payload is buffered with
277
- `request.arrayBuffer()` before it goes on the wire.
278
- - **Receiving** (`serveFetchOverDuplex`, `newHttpServerStub`) — the request
279
- body is drained into one contiguous buffer before the `Request` handed to
280
- your handler is constructed. Without this the handler would silently read the
281
- string `[object ReadableStream]` and answer `200` with corrupt data.
282
-
283
- So on Firefox: **request streaming does not happen**. A 1 GiB upload from a
284
- Firefox page is a 1 GiB allocation, where Chromium and Node stream it chunk by
285
- chunk. Response streaming is unaffected — `Response.body` exists everywhere.
286
-
287
- The check is a capability probe, not a user-agent test, so it flips on its own
288
- the day Firefox ships request streams. Safari is untested and is the case to
289
- look at first if a report arrives: it has had `Request.body` since 11.1 but
290
- only accepts a stream as `init.body` from Technology Preview 250, so a shipping
291
- Safari has the reader half without the upload half and takes the streaming
292
- branch here.
293
-
294
- One further divergence, on the sending side only: buffering cannot distinguish
295
- an absent body from an empty one. Chromium's
296
- `new Request(url, { method: "POST", body: "" })` yields a non-null empty
297
- stream, so an empty body is framed on the wire; on the Firefox path a
298
- zero-length `arrayBuffer()` is indistinguishable from no body and none is sent.
299
- Both decode to the same empty body at the far end, so only the framing differs.
300
- This does not arise for the stubs, whose envelope always carries a `content`
301
- iterable.
302
-
303
- ## Errors
304
-
305
- The codec answers on the wire wherever it can, because a real HTTP peer cannot
306
- receive a JavaScript exception — only a response, or a connection that ends
307
- with no status.
308
-
309
- | Situation | On the wire | On a webrun caller |
310
- | --- | --- | --- |
311
- | Handler throws | `500 Internal Server Error`, message in the body | `httpFetch` rejects with the peer's error, stack and custom fields preserved |
312
- | Request cannot be parsed | `400 Bad Request`, reason in the body | `httpFetch` rejects with the parse error |
313
- | Transport fails | nothing — the failure is not ours to answer | the error propagates unchanged |
314
-
315
- Both error responses carry the serialized error in an `x-webrun-error` header
316
- (exported as `PEER_ERROR_HEADER`), which is what lets a webrun peer re-throw a
317
- real `Error` rather than a status code. A third party just reads a conforming
318
- response and ignores the header. The header value is bounded at 4096
319
- characters, so a very large error message is truncated rather than blowing past
320
- `maxHeaderBytes` at the reader.
221
+ `jsonEnvelopeCodec` is a second format: `JSON.stringify(envelope)`, a newline,
222
+ then the body bytes. `encodeMessage` / `decodeMessage` expose that framing for
223
+ any envelope type. A server answers in whichever format read the request, so a
224
+ peer that speaks only the JSON envelope is answered as a JSON envelope.
321
225
 
322
- Refusals are always `HttpParseError`, whichever codec read the message, so a
323
- consumer can tell "the peer sent something malformed" from "the transport
324
- broke". **Check `err.name`, not `instanceof`** — the two cases differ:
226
+ ### Telling a malformed message from a broken transport
325
227
 
326
228
  ```ts
327
229
  import { httpFetch } from "@statewalker/webrun-http-streams";
@@ -330,69 +232,201 @@ try {
330
232
  await httpFetch(call, env);
331
233
  } catch (err) {
332
234
  if ((err as Error).name === "HttpParseError") {
333
- // malformed bytes — one side or an intermediary is at fault
235
+ // malformed bytes: one side or an intermediary is at fault
334
236
  } else {
335
237
  throw err; // transport failure, or the peer handler's own error
336
238
  }
337
239
  }
338
240
  ```
339
241
 
340
- `instanceof HttpParseError` holds only when *this* side's decoder refused what
341
- the peer sent. When the peer refuses **your** request, its 400 travels back
342
- through the `x-webrun-error` header and is rehydrated by `deserializeError`,
343
- which builds a plain `Error` carrying the original `name`, `message`, `stack`
344
- and custom fields — not the original class. So `err.name` is
345
- `"HttpParseError"` in both directions, and `instanceof` is true in only one.
346
- The same applies to every error class crossing this boundary, including your
347
- own handler's `Error` subclasses.
348
-
349
- A refusal is answered in whatever format the peer was speaking, so a caller
350
- pinned to the legacy envelope receives its 400 as an envelope rather than as
351
- HTTP/1.1.
352
-
353
- `HttpError` is a separate, unrelated helper for handlers that want to raise a
354
- status deliberately (`HttpError.errorResourceNotFound()` and friends). It is
355
- not wired into the wire format: throwing one produces a 500 like any other
356
- exception.
242
+ Check `err.name`, not `instanceof`; see
243
+ [Errors cross the wire as responses](#errors-cross-the-wire-as-responses).
357
244
 
358
- ## Exports
245
+ ### Exports
359
246
 
360
247
  | Export | What it is |
361
248
  | --- | --- |
362
249
  | `httpFetch`, `httpServe` | Data layer. Types: `HttpDataHandler`, `HttpDataHandlerResult`, `HttpDataOptions`, `HttpFetchResult`. |
363
250
  | `fetchOverDuplex`, `serveFetchOverDuplex` | Fetch layer. |
364
- | `DuplexSiteBuilder`, `SiteHandler` | Site host over a `Connect`/`Serve` pair. |
251
+ | `DuplexSiteBuilder`, `SiteHandler` | Site host over an adapter's `Serve<P>`. |
365
252
  | `newHttpClientStub`, `newHttpServerStub` | Transport-agnostic stubs. Types: `HttpHandler`, `SerializedHttpEnvelope`, `SerializedHttpRequest`, `SerializedHttpResponse`. |
366
253
  | `defaultCodec`, `httpCodec`, `newHttpCodec`, `HttpCodecOptions` | HTTP/1.1 codec and the default sniffing codec. |
367
- | `jsonEnvelopeCodec`, `encodeMessage`, `decodeMessage` | Legacy JSON-envelope format. |
368
- | `newSniffingCodec`, `SniffingCodecOptions` | Build your own write/accept combination. |
254
+ | `jsonEnvelopeCodec`, `encodeMessage`, `decodeMessage` | JSON-envelope format. |
255
+ | `newSniffingCodec`, `SniffingCodecOptions` | Build a write/accept combination. |
369
256
  | `HttpParseError` | Every codec refusal. |
370
- | `HttpError`, `HttpErrorOptions` | Status-raising helper for handlers; not wired into the wire format. |
257
+ | `HttpError`, `HttpErrorOptions` | Status-carrying error for handlers; not wired into the wire format. |
371
258
  | `PEER_ERROR_HEADER` | `"x-webrun-error"`. |
372
259
  | `MessageCodec`, `ByteSource`, `RequestEnvelope`, `ResponseEnvelope`, `DecodedRequest`, `DecodedResponse`, `ResponseCodecOptions` | The codec seam's types. |
373
260
 
374
- ## Dependencies
261
+ ## Internals
375
262
 
376
- | Dependency | Kind | Why |
377
- | --- | --- | --- |
378
- | [`@statewalker/webrun-streams`](../webrun-streams) | runtime | The `Duplex` seam, chunk collectors and error serialisation. |
263
+ ### One HTTP/1.1 message per `Duplex` call
264
+
265
+ ```
266
+ caller Duplex server
267
+ Request -> envelope+body -> encodeRequest ==bytes==> decodeRequest -> envelope+body -> handler
268
+ Response <- envelope+body <- decodeResponse <==bytes== encodeResponse <- envelope+body <-'
269
+ ```
270
+
271
+ ```
272
+ POST /api?a=1 HTTP/1.1
273
+ Host: peer.test
274
+ Connection: close
275
+ Transfer-Encoding: chunked
379
276
 
380
- No peer dependencies and no runtime dependencies outside the workspace. Relies
381
- on the platform for `Request`, `Response`, `ReadableStream`, `TextEncoder` and
382
- `TextDecoder`.
277
+ 5
278
+ hello
279
+ 0
383
280
 
384
- Downstream consumers in this workspace:
385
- [`webrun-http-browser`](../webrun-http-browser) and
386
- [`webrun-rpc-http`](../webrun-rpc-http).
281
+ ```
387
282
 
388
- ## Scripts
283
+ `Connection: close` is always emitted, and bytes after a complete message are
284
+ an error (`N trailing bytes after a complete message`). Running several calls
285
+ at once is the transport adapter's job, not the codec's. Bodies use
286
+ `Content-Length` when the caller declares one and chunked transfer coding
287
+ otherwise. The codec is tested against `node:http` in both directions
288
+ (`tests/http1-node-interop.test.ts`).
289
+
290
+ ### The codec refuses instead of guessing
291
+
292
+ Every ambiguity is an `HttpParseError`. Some that a reader meets in practice:
293
+
294
+ - a message with both `Content-Length` and `Transfer-Encoding`:
295
+ `message declares both Content-Length and Transfer-Encoding; refusing (request smuggling)`;
296
+ - a head larger than `maxHeaderBytes`: `head section exceeds 65536 bytes`;
297
+ - obsolete line folding: `obs-fold header continuation is not accepted`;
298
+ - an HTTP/1.1 request without `Host`: `HTTP/1.1 request has no Host header`;
299
+ - a header value that is not latin-1, or that contains CR, LF or a control
300
+ character.
301
+
302
+ RFC 9112 §2.2 lets a recipient ignore a single CRLF before the request line.
303
+ This codec does not: it answers `400 Bad Request` with
304
+ `malformed request line`.
305
+
306
+ ### Format sniffing needs no handshake
307
+
308
+ The formats identify themselves by their first byte. A JSON envelope always
309
+ begins with `{`, which is not an HTTP token character, and every HTTP start
310
+ line (a method, or `HTTP/1.1`) begins with one. The sniffing codec peeks one
311
+ byte and picks the first accepted codec that claims it. An empty stream fails
312
+ with `sniff: stream ended before any bytes arrived`; an unknown first byte
313
+ fails with `sniff: no accepted codec recognises a message starting with byte 0x..`.
314
+
315
+ ### Bodyless responses carry no bytes
316
+
317
+ Responses with a null-body status (204, 205, 304) and responses to `HEAD` or
318
+ `OPTIONS` put no body bytes on the wire, even if the handler's `Response` has a
319
+ body stream; the handler's stream is cancelled. The reader gets
320
+ `new Response(null, init)`, whose `.body` is `null`.
321
+
322
+ The null-body set also contains 101 and 103, but `fetchOverDuplex` cannot
323
+ return them: `ResponseInit.status` must be 200–599, so the `Response`
324
+ constructor throws a `RangeError` (`init["status"] must be in the range of 200
325
+ to 599, inclusive.` in Node) if a peer answers with one. Use `httpFetch` to see
326
+ an informational status.
327
+
328
+ Known gap: for these bodyless responses `fetchOverDuplex` does not release the
329
+ underlying `Duplex` call. `httpFetch` returns only `{ envelope, body }`, and
330
+ `.return()` on a body generator nobody has pulled from is a no-op, so the
331
+ producer's `finally` does not run. On a transport that multiplexes calls with a
332
+ bounded stream table this holds one slot per such call. Closing it needs
333
+ `httpFetch` to expose the call for cancellation.
334
+
335
+ ### Hop-by-hop headers are dropped at the fetch layer
336
+
337
+ The codec surfaces every header verbatim. The fetch layer strips `connection`,
338
+ `host`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `te`,
339
+ `trailer`, `transfer-encoding` and `upgrade` in both directions. They mean
340
+ nothing to a `Request`/`Response`, and a relay that re-emitted them would
341
+ corrupt its own framing.
342
+
343
+ ### Errors cross the wire as responses
344
+
345
+ A real HTTP peer cannot receive a JavaScript exception, only a response or a
346
+ connection that ends with no status. So the server answers on the wire wherever
347
+ it can:
348
+
349
+ | Situation | On the wire | What a webrun caller sees |
350
+ | --- | --- | --- |
351
+ | Handler throws | `500 Internal Server Error`, message in the body | `httpFetch` rejects with the peer's error; `name`, `message`, `stack` and custom fields preserved |
352
+ | Request cannot be parsed | `400 Bad Request`, reason in the body | `httpFetch` rejects with the parse error |
353
+ | Transport fails | nothing | the transport error propagates unchanged |
354
+
355
+ Both error responses carry the serialised error in the `x-webrun-error` header
356
+ (`PEER_ERROR_HEADER`). A webrun peer re-throws it; any other client reads a
357
+ normal response. The header value is escaped to printable ASCII and capped at
358
+ 4096 characters. Without the cap a huge message would push the head past the
359
+ reader's `maxHeaderBytes`, and the caller would see
360
+ `head section exceeds 65536 bytes` instead of the real error. On this path the
361
+ caller also cancels the `Duplex` output, which frees the call's slot on a
362
+ multiplexing transport.
363
+
364
+ `instanceof HttpParseError` holds only when this side's decoder refused the
365
+ peer's bytes. When the peer refuses your request, its 400 comes back through
366
+ the header, and `deserializeError` builds a plain `Error` with the original
367
+ `name`, `message`, `stack` and fields, not the original class. The same is true
368
+ of every error class crossing this boundary, including your handler's own
369
+ `Error` subclasses. A refusal is answered in the format the peer was speaking.
370
+
371
+ `HttpError` is a separate helper for handlers that want to carry a status
372
+ (`HttpError.errorResourceNotFound()`, `errorForbidden()`, `errorResourceGone()`,
373
+ `errorInternalError()`, `fromError()`). The wire format does not read it:
374
+ throwing one produces a 500 like any other exception.
375
+
376
+ ### Firefox buffers request bodies
377
+
378
+ Firefox does not implement `Request.prototype.body` (checked against Firefox
379
+ 146). Without it, `new Request(url, { body: stream })` does not reject a
380
+ `ReadableStream`; it stores the literal text `[object ReadableStream]`. The
381
+ package detects this with a capability probe
382
+ (`Object.getOwnPropertyDescriptor(Request.prototype, "body")`), evaluated per
383
+ call, not a user-agent test.
384
+
385
+ - **Sending** (`fetchOverDuplex`, `newHttpClientStub`): the whole payload is
386
+ read with `request.arrayBuffer()` before it goes on the wire.
387
+ - **Receiving** (`serveFetchOverDuplex`, `newHttpServerStub`): the request body
388
+ is drained into one buffer before the handler's `Request` is built. Without
389
+ this the handler would read `[object ReadableStream]` and answer 200 with
390
+ corrupt data.
391
+
392
+ So on Firefox a 1 GiB upload is a 1 GiB allocation, where Chromium and Node
393
+ stream it. Response streaming is unaffected. Safari is untested: it has
394
+ `Request.body`, but only accepts a stream as `init.body` from Technology
395
+ Preview 250, so a shipping Safari takes the streaming branch here.
396
+
397
+ Buffering cannot tell an absent body from an empty one. Chromium frames an
398
+ empty POST body on the wire; the Firefox path sends none. Both decode to the
399
+ same empty body at the far end.
400
+
401
+ ### Stub bodies must be productive
402
+
403
+ `SerializedHttpEnvelope.content` must yield or finish on its own. A body neither
404
+ stub may read (a GET/HEAD/OPTIONS request, a null-body-status response) is
405
+ released by pulling once and then calling `.return()`, because `.return()` on a
406
+ generator that has not started is a no-op and would leak the producer. A
407
+ `content` that blocks forever without yielding makes the stub block with it.
408
+ A `content` that waits on a peer that may never send should carry its own
409
+ timeout.
410
+
411
+ ### Dependencies
412
+
413
+ One runtime dependency, `@statewalker/webrun-streams`, for the `Duplex` and
414
+ `Serve` types, `fromReadableStream` / `toReadableStream`, and
415
+ `serializeError` / `deserializeError`. No peer dependencies. Everything else is
416
+ platform API.
417
+
418
+ ### Scripts
389
419
 
390
420
  ```sh
391
- pnpm test # vitest run
392
- pnpm run build # rolldown + tsc --emitDeclarationOnly
393
- pnpm lint # biome check src tests
421
+ pnpm test # vitest run
422
+ pnpm run build # rolldown + tsc --emitDeclarationOnly
423
+ pnpm run typecheck # tsc --noEmit
424
+ pnpm run lint # biome check src tests
394
425
  ```
395
426
 
427
+ `tests/readme-examples.test.ts` runs the snippets in this file against the
428
+ source.
429
+
396
430
  ## License
397
431
 
398
- MIT © statewalker — see [LICENSE](../../LICENSE).
432
+ MIT
@@ -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,10 +1,13 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-streams",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "HTTP request/response over Duplex — merge of webrun-http + webrun-http-port",
7
- "homepage": "https://github.com/statewalker/webrun-wire",
6
+ "description": "HTTP request/response over a webrun-streams Duplex, encoded as HTTP/1.1, plus transport-agnostic fetch stubs",
7
+ "bugs": {
8
+ "url": "https://github.com/statewalker/webrun-wire/issues"
9
+ },
10
+ "homepage": "https://github.com/statewalker/webrun-wire/tree/main/packages/webrun-http-streams#readme",
8
11
  "author": {
9
12
  "name": "Mikhail Kotelnikov",
10
13
  "email": "mikhail.kotelnikov@gmail.com"
@@ -12,7 +15,8 @@
12
15
  "license": "MIT",
13
16
  "repository": {
14
17
  "type": "git",
15
- "url": "git@github.com:statewalker/webrun-wire.git"
18
+ "url": "git+https://github.com/statewalker/webrun-wire.git",
19
+ "directory": "packages/webrun-http-streams"
16
20
  },
17
21
  "exports": {
18
22
  ".": {
@@ -26,7 +30,7 @@
26
30
  "src"
27
31
  ],
28
32
  "dependencies": {
29
- "@statewalker/webrun-streams": "^0.2.1"
33
+ "@statewalker/webrun-streams": "^0.2.2"
30
34
  },
31
35
  "devDependencies": {
32
36
  "@biomejs/biome": "^2.5.15",
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
  }