@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.
- package/README.md +280 -246
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -1,80 +1,98 @@
|
|
|
1
1
|
# @statewalker/webrun-http-streams
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Replaces the retired `webrun-http` + `webrun-http-port` packages.
|
|
3
|
+
## What it is
|
|
5
4
|
|
|
6
|
-
|
|
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
|
-
|
|
30
|
+
pnpm add @statewalker/webrun-http-streams
|
|
10
31
|
```
|
|
11
32
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
43
|
-
const { call, close } = await connect({
|
|
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);
|
|
47
|
-
console.log(await response.json());
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
##
|
|
87
|
+
## Examples
|
|
64
88
|
|
|
65
|
-
|
|
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
|
-
//
|
|
76
|
-
// transport
|
|
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
|
-
`
|
|
97
|
-
`
|
|
98
|
-
|
|
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`
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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)
|
|
137
|
-
.start(serve, {
|
|
151
|
+
.setHandler(siteHandler)
|
|
152
|
+
.start(serve, { mux: overPipe(port2, { codec: structuredCodec, side: "responder" }) });
|
|
138
153
|
// later: await stop();
|
|
139
154
|
```
|
|
140
155
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
`
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
164
|
+
### Transport-agnostic stubs: `newHttpClientStub` / `newHttpServerStub`
|
|
153
165
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
iterable
|
|
157
|
-
|
|
158
|
-
|
|
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`
|
|
171
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
249
|
-
whenever no `codec` option 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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
|
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` |
|
|
368
|
-
| `newSniffingCodec`, `SniffingCodecOptions` | Build
|
|
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-
|
|
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
|
-
##
|
|
261
|
+
## Internals
|
|
375
262
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
277
|
+
5
|
|
278
|
+
hello
|
|
279
|
+
0
|
|
383
280
|
|
|
384
|
-
|
|
385
|
-
[`webrun-http-browser`](../webrun-http-browser) and
|
|
386
|
-
[`webrun-rpc-http`](../webrun-rpc-http).
|
|
281
|
+
```
|
|
387
282
|
|
|
388
|
-
|
|
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
|
|
392
|
-
pnpm run build
|
|
393
|
-
pnpm
|
|
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
|
|
432
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-http-streams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "HTTP request/response over Duplex
|
|
7
|
-
"
|
|
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
|
|
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.
|
|
33
|
+
"@statewalker/webrun-streams": "^0.2.2"
|
|
30
34
|
},
|
|
31
35
|
"devDependencies": {
|
|
32
36
|
"@biomejs/biome": "^2.5.15",
|