@statewalker/webrun-http-proxy 0.2.2 → 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 +163 -71
- package/dist/proxy.d.ts +7 -5
- package/dist/proxy.d.ts.map +1 -1
- package/package.json +7 -3
- package/src/proxy.ts +7 -5
package/README.md
CHANGED
|
@@ -1,8 +1,59 @@
|
|
|
1
1
|
# @statewalker/webrun-http-proxy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
4
|
|
|
5
|
-
|
|
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) => Response \| Promise<Response>`: the same type as `FetchHandler` in `@statewalker/webrun-http-streams`, declared here to keep zero dependencies. |
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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/dist/proxy.d.ts
CHANGED
|
@@ -23,17 +23,19 @@
|
|
|
23
23
|
* upstream call running. It is forwarded.
|
|
24
24
|
*/
|
|
25
25
|
/**
|
|
26
|
-
* A handler,
|
|
26
|
+
* A handler: sync or async, as a Service Worker's or a Durable Object's `fetch`.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* The same type as `FetchHandler` in `@statewalker/webrun-http-streams` (a type
|
|
29
|
+
* test there keeps them identical). Declared here rather than imported: it is
|
|
30
|
+
* one line, and depending on another package for it would give a proxy a
|
|
31
|
+
* dependency it has no other use for.
|
|
30
32
|
*/
|
|
31
|
-
export type FetchHandler = (request: Request) => Promise<Response>;
|
|
33
|
+
export type FetchHandler = (request: Request) => Response | Promise<Response>;
|
|
32
34
|
/** Marks this layer's OWN responses, so they are never mistaken for an upstream's. Same header the proxy already uses. */
|
|
33
35
|
export declare const MARKER = "x-webrun-proxy";
|
|
34
36
|
/**
|
|
35
37
|
* An upstream is just a handler. That is the whole merge: a local service, a
|
|
36
|
-
* remote origin and another peer are all `
|
|
38
|
+
* remote origin and another peer are all a `FetchHandler`.
|
|
37
39
|
*/
|
|
38
40
|
export type Upstream = FetchHandler;
|
|
39
41
|
export interface UrlUpstreamInit {
|
package/dist/proxy.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE9E,0HAA0H;AAC1H,eAAO,MAAM,MAAM,mBAAmB,CAAC;AAEvC;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,YAAY,CAAC;AAEpC,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAExC,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,+FAA+F;IAC/F,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IACtD,qDAAqD;IACrD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,GAAG,QAAQ,CAkH3D"}
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-http-proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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+
|
|
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",
|
|
@@ -31,7 +35,7 @@
|
|
|
31
35
|
"rimraf": "^6.1.3",
|
|
32
36
|
"rolldown": "^1.2.12",
|
|
33
37
|
"typescript": "^7.0.2",
|
|
34
|
-
"vitest": "^5.0.
|
|
38
|
+
"vitest": "^5.0.5"
|
|
35
39
|
},
|
|
36
40
|
"publishConfig": {
|
|
37
41
|
"access": "public"
|
package/src/proxy.ts
CHANGED
|
@@ -24,19 +24,21 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* A handler,
|
|
27
|
+
* A handler: sync or async, as a Service Worker's or a Durable Object's `fetch`.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* The same type as `FetchHandler` in `@statewalker/webrun-http-streams` (a type
|
|
30
|
+
* test there keeps them identical). Declared here rather than imported: it is
|
|
31
|
+
* one line, and depending on another package for it would give a proxy a
|
|
32
|
+
* dependency it has no other use for.
|
|
31
33
|
*/
|
|
32
|
-
export type FetchHandler = (request: Request) => Promise<Response>;
|
|
34
|
+
export type FetchHandler = (request: Request) => Response | Promise<Response>;
|
|
33
35
|
|
|
34
36
|
/** Marks this layer's OWN responses, so they are never mistaken for an upstream's. Same header the proxy already uses. */
|
|
35
37
|
export const MARKER = "x-webrun-proxy";
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* An upstream is just a handler. That is the whole merge: a local service, a
|
|
39
|
-
* remote origin and another peer are all `
|
|
41
|
+
* remote origin and another peer are all a `FetchHandler`.
|
|
40
42
|
*/
|
|
41
43
|
export type Upstream = FetchHandler;
|
|
42
44
|
|