@statewalker/webrun-http-streams 0.2.4 → 0.2.5

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.
Files changed (2) hide show
  1. package/README.md +280 -246
  2. package/package.json +9 -5
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
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.2.5",
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",