@statewalker/webrun-http-proxy 0.2.2 → 0.2.3

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 +163 -71
  2. package/package.json +6 -2
package/README.md CHANGED
@@ -1,8 +1,59 @@
1
1
  # @statewalker/webrun-http-proxy
2
2
 
3
- Re-issue an HTTP request to an outside origin, safely. **One function.**
3
+ ## What it is
4
4
 
5
- **Zero runtime dependencies. One entry point.**
5
+ One function, `urlUpstream`, that turns a base URL into a
6
+ `(Request) => Promise<Response>` handler. The handler re-issues each incoming
7
+ request to that outside origin: it drops the headers that must not leave this
8
+ hop, adds the destination's own headers and credential, streams the body, and
9
+ reports failures as `502` responses instead of exceptions.
10
+
11
+ ## Why it exists
12
+
13
+ Forwarding a request to a third-party origin looks like one line,
14
+ `fetch(new Request(target, request))`, and is wrong in several quiet ways. The
15
+ caller's own credential leaks to the upstream. Hop-by-hop headers go along. In
16
+ Firefox the body is silently dropped. An upstream redirect throws inside the
17
+ `try` and is reported as unreachable. An abandoned caller leaves the upstream
18
+ call running. `urlUpstream` handles each of these, and a browser page and a
19
+ Node process use the same code.
20
+
21
+ Routing is not part of it. Prefix matching and path rewriting are what any
22
+ router (Hono, a `URLPattern` table, a `switch`) already does, so the package
23
+ takes a request that is already addressed and leaves routing to the caller.
24
+
25
+ ## How to use
26
+
27
+ ```sh
28
+ pnpm add @statewalker/webrun-http-proxy
29
+ ```
30
+
31
+ - **Peer dependencies:** none. **Runtime dependencies:** none.
32
+ - **Entry point:** one, `.`. ESM only: `dist/index.js` with `dist/index.d.ts`.
33
+ The `source` export condition points at `src/index.ts`.
34
+ - **Environment:** any runtime with `fetch`, `Request`, `Response` and
35
+ `Headers`: Node, browsers, workers, Deno, Bun. No DOM API and no `node:`
36
+ import; `tests/boundary.test.ts` enforces that.
37
+
38
+ | Export | What it is |
39
+ | --- | --- |
40
+ | `urlUpstream(init)` | Returns an `Upstream` that re-issues requests against `init.base`. |
41
+ | `UrlUpstreamInit` | Its options (below). |
42
+ | `Upstream`, `FetchHandler` | Both `(request: Request) => Promise<Response>`. |
43
+ | `MARKER` | `"x-webrun-proxy"`, the header on responses this package makes itself. |
44
+
45
+ | `UrlUpstreamInit` field | Default | Meaning |
46
+ | --- | --- | --- |
47
+ | `base` | required | Origin and optional base path. The request's path and query are appended to it. |
48
+ | `stripRequestHeaders` | `[]` | Request headers to drop before re-issuing, beyond the hop-by-hop set. |
49
+ | `headers` | none | Static headers for this destination, set after the caller's. |
50
+ | `credential` | none | `() => Record<string, string> \| undefined`, read on every request and set last. |
51
+ | `via` | none | `Via` header value; omitted, no `Via` is added. |
52
+ | `fetchImpl` | `globalThis.fetch` | Injected `fetch`, for tests. |
53
+
54
+ ## Examples
55
+
56
+ ### Behind a Hono route
6
57
 
7
58
  ```ts
8
59
  import { Hono } from "hono";
@@ -20,72 +71,113 @@ app.all("/openai/:rest{.*}", (c) => {
20
71
  });
21
72
  ```
22
73
 
23
- ## Bring your own router
24
-
25
- This package used to ship a route table as well — prefix matching, path
26
- rewriting, a listing endpoint, a marker header on unmatched paths, a route
27
- store. That turned out to be the uninteresting half: it is what a router does,
28
- and every caller already has one.
29
-
30
- The evidence is in `tests/scenarios.ts`. Twelve scenarios established this
31
- proxy's behaviour in the prototype; they now run against **a plain Hono
32
- router** and pass unchanged. Nothing was lost with the table, and the segment
33
- matching got better — Hono matches on segment boundaries, so `/open` no longer
34
- swallows `/openai`, which the hand-written table had to special-case.
35
-
36
- Persisting route configuration went with the router, for the same reason: the
37
- shape of that configuration belongs to whoever defines the routes. If you store
38
- routes, store the credential's header **name** and never its **value** — the
39
- value belongs in memory, merged per request through `credential`. (An earlier
40
- shape of this API stored a whole route, and building a proxy page on it would
41
- have written bearer keys into `localStorage`.)
42
-
43
- ## What `urlUpstream` actually does
44
-
45
- None of this is obvious, and all of it was found by measurement:
46
-
47
- - Whatever `stripRequestHeaders` names — the caller's own credential and
48
- whatever else its system treats as proven identity — is **consumed by this
49
- hop**, the way `Proxy-Authorization` is consumed by the proxy it names. A
50
- third-party origin has no business seeing it: forwarding a mesh token once
51
- handed it to an upstream that echoed it straight back.
52
- - `authorization` is **not** special. It belongs to the application calling
53
- the upstream (a page calling an API with that API's key) and is forwarded
54
- unless you name it in `stripRequestHeaders`. `credential` still wins over it.
55
- - Hop-by-hop headers (RFC 9110 §7.6.1) are dropped.
56
- - `credential` is read at **request** time, so a key can be typed while traffic
57
- flows.
58
- - The body **streams** rather than buffering, and the caller's `signal` is
59
- forwarded, so an abandoned request abandons the upstream call.
60
- - An **opaque redirect** is reported as a redirect. It has status `0`, and
61
- constructing a `Response` with status 0 throws *inside* the `try` — which
62
- reported a redirecting upstream as `502 upstream-unreachable`.
63
-
64
- A **local** handler is just a `FetchHandler` you route to directly: do not put
65
- it behind this. Calling a handler on this side of the proxy must **not** strip
66
- the caller's credential, because it still needs to know who is calling.
67
-
68
- ## One row a browser cannot pass
69
-
70
- `Via` is a forbidden header name under the Fetch spec — a page may not set it,
71
- and the browser drops it with no error. ADR-0015 has an intermediary announce
72
- itself with `Via`, so that part is unimplementable in a browser-hosted
73
- intermediary. A fact about the platform, not about this code.
74
-
75
- ## No platform
76
-
77
- No transport, no crypto, no platform code at all — re-issuing a request needs
78
- none of it, which is why a proxy **page** and a Node process use the same entry
79
- point. `tests/boundary.test.ts` asserts there is no platform entry point left
80
- to exempt, and the dependency list is **empty**.
81
-
82
- ## Where it came from
83
-
84
- Extracted from `@statewalker/httpeers-expose`, where it was a mesh concept by
85
- accident of where it was written. Nothing in it is about peers. The one place
86
- the old package knew about meshes is now `stripRequestHeaders`: httpeers passes
87
- its membership-token and proven-peer headers there.
88
-
89
- **9 tests**, and the twelve scenarios run inside two of them — the suite
90
- asserts that every scenario ran (guarding against an empty list) and that none
91
- failed.
74
+ Only the path and query of the incoming URL are used, so the host in
75
+ `http://upstream…` is irrelevant. `POST /openai/chat/completions?x=1` is
76
+ re-issued as `POST https://api.openai.com/v1/chat/completions?x=1` with the
77
+ body streamed and `authorization` set from `credential`.
78
+
79
+ ### Keeping your own credential off the upstream
80
+
81
+ ```ts
82
+ import { urlUpstream } from "@statewalker/webrun-http-proxy";
83
+
84
+ const upstream = urlUpstream({
85
+ base: "https://example.org/api",
86
+ stripRequestHeaders: ["x-mesh-token", "x-proven-peer"],
87
+ });
88
+ ```
89
+
90
+ ## Internals
91
+
92
+ ### What the proxy does to a request
93
+
94
+ ```
95
+ incoming Request
96
+ ├─ delete stripRequestHeaders (caller's own identity)
97
+ ├─ delete hop-by-hop headers (RFC 9110 §7.6.1)
98
+ ├─ set via (if configured)
99
+ ├─ set headers (operator configuration)
100
+ ├─ set credential() (read now, wins over everything)
101
+ ├─ body: stream, or buffer on Firefox (never for GET/HEAD)
102
+ └─ fetch(base + path + query, { signal, redirect: "manual" })
103
+ ├─ opaque redirect -> 502, x-webrun-proxy: upstream-redirect
104
+ ├─ throws -> 502, x-webrun-proxy: upstream-unreachable
105
+ └─ otherwise -> upstream status, headers and streamed body
106
+ ```
107
+
108
+ ### Your credential is consumed by this hop
109
+
110
+ Whatever `stripRequestHeaders` names is removed before the request leaves, the
111
+ way `Proxy-Authorization` is consumed by the proxy it names. That is where a
112
+ system puts its own credential and anything it treats as proven identity: a
113
+ third-party origin has no business seeing them, and an upstream that echoes
114
+ headers back would hand them to whoever reads the response.
115
+
116
+ `authorization` is not on any built-in list. It usually belongs to the
117
+ application talking to the upstream (a page calling an API with that API's
118
+ key), and dropping it would make that call impossible through the proxy. A
119
+ system that keeps its own credential in `authorization` names it in
120
+ `stripRequestHeaders`. `credential` still overrides whatever the caller sent.
121
+
122
+ Hop-by-hop headers are always dropped: `connection`, `keep-alive`,
123
+ `proxy-authenticate`, `proxy-authorization`, `te`, `trailer`,
124
+ `transfer-encoding`, `upgrade`.
125
+
126
+ `credential` is called per request, so a key can be entered or rotated while
127
+ traffic flows, and it is never stored in the proxy's configuration. If you
128
+ persist proxy routes, store the credential header's name, never its value.
129
+
130
+ ### The body streams, except where the platform cannot
131
+
132
+ `GET` and `HEAD` never carry a body; the Fetch spec forbids one. Otherwise, if
133
+ `request.body` is a stream it is passed on with `duplex: "half"`, so a large
134
+ upload is never held in memory. Firefox (checked against 155) has no
135
+ `Request.prototype.body`; there `request.body` reads `undefined` even for a real
136
+ payload, and passing it on would send nothing. The proxy then buffers with
137
+ `request.arrayBuffer()`. An empty buffer is treated as no body. `duplex` is set
138
+ from the branch taken, not from `body instanceof ReadableStream`, because a
139
+ stream from another realm fails that check and `new Request` would then throw
140
+ `duplex option is required`.
141
+
142
+ The caller's `signal` is forwarded, so an abandoned request abandons the
143
+ upstream call.
144
+
145
+ ### Failures are responses, not exceptions
146
+
147
+ | Situation | Response |
148
+ | --- | --- |
149
+ | Upstream answers with a redirect | `502`, `x-webrun-proxy: upstream-redirect`, body `upstream redirected; the proxy does not follow redirects` |
150
+ | Network error, CORS refusal, body already read, any other throw | `502`, `x-webrun-proxy: upstream-unreachable`, body `upstream <base> could not be reached: <message>` and a line suggesting the upstream may not permit cross-origin requests |
151
+
152
+ Redirects are not followed (`redirect: "manual"`). In a browser that yields an
153
+ opaque redirect with status `0`, and `new Response(body, { status: 0 })`
154
+ throws, which would otherwise surface as `upstream-unreachable`. The proxy
155
+ checks for it first and reports it as a redirect.
156
+
157
+ The `x-webrun-proxy` header marks responses the proxy made itself, so they are
158
+ never mistaken for the upstream's.
159
+
160
+ ### `Via` does not work in a browser
161
+
162
+ `Via` is a forbidden request header under the Fetch spec. A page may not set
163
+ it, and the browser drops it without an error. The `via` option therefore only
164
+ takes effect outside a browser.
165
+
166
+ ### Dependencies
167
+
168
+ Zero runtime dependencies. `hono` is a dev dependency: `tests/scenarios.ts`
169
+ runs its scenarios through a plain Hono router to show the proxy needs no
170
+ router of its own.
171
+
172
+ ### Scripts
173
+
174
+ ```sh
175
+ pnpm test # vitest run
176
+ pnpm run build # rolldown + tsc --emitDeclarationOnly
177
+ pnpm run typecheck # tsc --noEmit
178
+ pnpm run lint # biome check src tests
179
+ ```
180
+
181
+ ## License
182
+
183
+ MIT
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-proxy",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Re-issue an HTTP request to an outside origin, safely: credentials consumed, hop-by-hop headers dropped, body streamed.",
6
+ "bugs": {
7
+ "url": "https://github.com/statewalker/webrun-wire/issues"
8
+ },
9
+ "homepage": "https://github.com/statewalker/webrun-wire/tree/main/packages/webrun-http-proxy#readme",
6
10
  "license": "MIT",
7
11
  "repository": {
8
12
  "type": "git",
9
- "url": "git+ssh://git@github.com/statewalker/webrun-wire.git",
13
+ "url": "git+https://github.com/statewalker/webrun-wire.git",
10
14
  "directory": "packages/webrun-http-proxy"
11
15
  },
12
16
  "main": "./dist/index.js",