@statewalker/webrun-http-proxy 0.1.0 → 0.2.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 +69 -91
- package/dist/index.d.ts +28 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -70
- package/dist/proxy.d.ts +6 -23
- package/dist/proxy.d.ts.map +1 -1
- package/package.json +4 -10
- package/src/index.ts +28 -24
- package/src/proxy.ts +19 -95
- package/dist/browser.d.ts +0 -11
- package/dist/browser.d.ts.map +0 -1
- package/dist/browser.js +0 -30
- package/dist/node.d.ts +0 -11
- package/dist/node.d.ts.map +0 -1
- package/dist/node.js +0 -33
- package/dist/routes.d.ts +0 -31
- package/dist/routes.d.ts.map +0 -1
- package/dist/store-BwcCz3M9.js +0 -45
- package/dist/store.d.ts +0 -72
- package/dist/store.d.ts.map +0 -1
- package/src/browser.ts +0 -33
- package/src/node.ts +0 -34
- package/src/routes.ts +0 -48
- package/src/store.ts +0 -111
package/README.md
CHANGED
|
@@ -1,80 +1,69 @@
|
|
|
1
1
|
# @statewalker/webrun-http-proxy
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
in-process handler, or a remote origin.
|
|
3
|
+
Re-issue an HTTP request to an outside origin, safely. **One function.**
|
|
5
4
|
|
|
6
|
-
**Zero runtime dependencies.**
|
|
7
|
-
|
|
8
|
-
**The reverse proxy and "expose a local app" are one mechanism.** Twelve
|
|
9
|
-
scenarios establish it: only the last step differs — a local handler is
|
|
10
|
-
*called*, a URL upstream is *re-issued*. Matching, rewriting, the listing, the
|
|
11
|
-
marker header and streaming are shared.
|
|
12
|
-
|
|
13
|
-
The scenarios moved here with the code rather than being rewritten, because a
|
|
14
|
-
list that changes when it moves proves nothing about the move. **They run in
|
|
15
|
-
Node here, and only Node.** The prototype also ran them in Chromium, which is
|
|
16
|
-
what established that both upstream kinds behave identically on both platforms
|
|
17
|
-
and that exactly one row cannot pass in a browser (below). That second column
|
|
18
|
-
needs a bundler and a browser and does not exist in this package yet;
|
|
19
|
-
`tests/scenarios.test.ts` says so in its own header.
|
|
5
|
+
**Zero runtime dependencies. One entry point.**
|
|
20
6
|
|
|
21
7
|
```ts
|
|
22
|
-
import {
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
import { urlUpstream } from "@statewalker/webrun-http-proxy";
|
|
23
10
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{ prefix: "/local", describe: "in-process", upstream: myHandler },
|
|
28
|
-
],
|
|
11
|
+
const openai = urlUpstream({
|
|
12
|
+
base: "https://api.openai.com/v1",
|
|
13
|
+
credential: () => ({ authorization: `Bearer ${apiKey()}` }),
|
|
29
14
|
});
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## `routes` may be a thunk, and that is load-bearing
|
|
33
|
-
|
|
34
|
-
The proxy page edits routes and types credentials **while traffic flows**. The
|
|
35
|
-
table is re-read per request for exactly that reason; a snapshot taken at
|
|
36
|
-
construction would serve the old table until something restarted it.
|
|
37
|
-
|
|
38
|
-
## Secrets are never persisted
|
|
39
|
-
|
|
40
|
-
A route may carry a credential. The header's **name** is configuration and is
|
|
41
|
-
saved; the header's **value** lives in memory and is merged per request.
|
|
42
|
-
|
|
43
|
-
`StoredRoute` has no field a value fits in, and `save()` **throws** rather than
|
|
44
|
-
dropping one quietly — a silent drop means a route that worked before a reload
|
|
45
|
-
and 401s after it. `assertNoSecrets` is exported so an adapter written
|
|
46
|
-
elsewhere enforces the same rule instead of inventing its own idea of what a
|
|
47
|
-
secret looks like.
|
|
48
|
-
|
|
49
|
-
This is not hypothetical: an earlier shape of this API stored a whole `Route`,
|
|
50
|
-
and building the proxy page on it would have written bearer keys into
|
|
51
|
-
`localStorage` — where they survive a reload, a shared machine, and anyone who
|
|
52
|
-
opens devtools.
|
|
53
15
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
Two measured rows force the asymmetry:
|
|
61
|
-
|
|
62
|
-
- Re-issuing to a third party **consumes** the caller's `authorization`, the
|
|
63
|
-
way `Proxy-Authorization` is consumed by the proxy it names. Forwarding it
|
|
64
|
-
handed a bearer token to an upstream that echoed it straight back.
|
|
65
|
-
- Calling a **local** handler must not strip it, because a handler on this side
|
|
66
|
-
of the proxy still needs to know who is calling.
|
|
67
|
-
|
|
68
|
-
Same rule, `stripRequestHeaders` for anything else your system treats as
|
|
69
|
-
identity.
|
|
70
|
-
|
|
71
|
-
## Two shipping defects, fixed and pinned
|
|
16
|
+
const app = new Hono();
|
|
17
|
+
app.all("/openai/:rest{.*}", (c) => {
|
|
18
|
+
const { pathname, search } = new URL(c.req.url);
|
|
19
|
+
return openai(new Request(`http://upstream${pathname.slice("/openai".length)}${search}`, c.req.raw));
|
|
20
|
+
});
|
|
21
|
+
```
|
|
72
22
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
78
67
|
|
|
79
68
|
## One row a browser cannot pass
|
|
80
69
|
|
|
@@ -83,31 +72,20 @@ and the browser drops it with no error. ADR-0015 has an intermediary announce
|
|
|
83
72
|
itself with `Via`, so that part is unimplementable in a browser-hosted
|
|
84
73
|
intermediary. A fact about the platform, not about this code.
|
|
85
74
|
|
|
86
|
-
##
|
|
75
|
+
## No platform
|
|
87
76
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
| `./browser` | `localStorageRouteStore(key?)` |
|
|
93
|
-
|
|
94
|
-
No transport, no crypto, no platform at the root — proxying needs none of
|
|
95
|
-
them, which is why a proxy **page** and a Node process share this package.
|
|
96
|
-
`tests/boundary.test.ts` asserts it, and the dependency list is **empty**.
|
|
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**.
|
|
97
81
|
|
|
98
82
|
## Where it came from
|
|
99
83
|
|
|
100
84
|
Extracted from `@statewalker/httpeers-expose`, where it was a mesh concept by
|
|
101
|
-
accident of where it was written. Nothing in it is about peers
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
The single import it carried was `FetchHandler`, a one-line type. Extracting it
|
|
109
|
-
therefore *dropped* a dependency rather than moving one.
|
|
110
|
-
|
|
111
|
-
**22 tests**, and the twelve scenarios run inside two of them — the suite
|
|
112
|
-
asserts that every scenario ran (guarding against an empty list) and that
|
|
113
|
-
none failed.
|
|
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.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Re-issuing a request to an outside origin, safely. That is the whole
|
|
3
|
+
* package.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* It used to ship a route table too — prefix matching, a listing, a marker on
|
|
6
|
+
* unmatched paths. All of that is what a ROUTER does, and every caller already
|
|
7
|
+
* has one: the twelve scenarios that established this proxy's behaviour now
|
|
8
|
+
* run against a plain Hono router and pass unchanged, which is the evidence
|
|
9
|
+
* that removing it lost nothing.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* What is genuinely this package's own is `urlUpstream`, and it is not
|
|
12
|
+
* obvious:
|
|
13
|
+
*
|
|
14
|
+
* - `stripRequestHeaders` names the caller's own credential and whatever
|
|
15
|
+
* else its system treats as identity; those are CONSUMED by this hop, the
|
|
16
|
+
* way `Proxy-Authorization` is consumed by the proxy it names, because a
|
|
17
|
+
* third-party origin has no business seeing them. `authorization` is not
|
|
18
|
+
* special: it belongs to the application calling the upstream and is
|
|
19
|
+
* forwarded unless named;
|
|
20
|
+
* - hop-by-hop headers (RFC 9110 §7.6.1) are dropped;
|
|
21
|
+
* - the credential is read at REQUEST time, so it can be typed while traffic
|
|
22
|
+
* flows;
|
|
23
|
+
* - the body streams rather than buffering, and the caller's `signal` is
|
|
24
|
+
* forwarded so an abandoned request abandons the upstream call;
|
|
25
|
+
* - an opaque redirect (status 0) is reported as a redirect rather than as
|
|
26
|
+
* `502 upstream-unreachable`, which is what constructing a `Response` with
|
|
27
|
+
* status 0 throwing inside a `try` used to produce.
|
|
28
|
+
*
|
|
29
|
+
* Persisting route configuration went with the router, for the same reason:
|
|
30
|
+
* the shape of that configuration belongs to whoever defines the routes.
|
|
14
31
|
*/
|
|
15
|
-
export { type FetchHandler, MARKER, type
|
|
16
|
-
export { matchRoute, type ProxyRoute, upstreamUrl } from "./routes.js";
|
|
17
|
-
export { assertNoSecrets, rehydrate, type RehydrateInit, type RouteStore, SecretNotPersistableError, type StoredRoute, toStoredRoute, } from "./store.js";
|
|
32
|
+
export { type FetchHandler, MARKER, type Upstream, type UrlUpstreamInit, urlUpstream, } from "./proxy.js";
|
|
18
33
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EACL,KAAK,YAAY,EACjB,MAAM,EACN,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,WAAW,GACZ,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,75 +1,7 @@
|
|
|
1
|
-
import { i as toStoredRoute, n as assertNoSecrets, r as rehydrate, t as SecretNotPersistableError } from "./store-BwcCz3M9.js";
|
|
2
|
-
//#region src/routes.ts
|
|
3
|
-
/**
|
|
4
|
-
* The route owning `pathname`, and what follows its prefix.
|
|
5
|
-
*
|
|
6
|
-
* LONGEST PREFIX WINS, so `/openai/admin` can go somewhere other than
|
|
7
|
-
* `/openai`, and the answer does not depend on the order routes were added --
|
|
8
|
-
* an ordering dependency would make the UI's list order silently meaningful.
|
|
9
|
-
*/
|
|
10
|
-
function matchRoute(routes, pathname) {
|
|
11
|
-
let best = null;
|
|
12
|
-
for (const route of routes) {
|
|
13
|
-
if (!pathname.startsWith(route.prefix)) continue;
|
|
14
|
-
const rest = pathname.slice(route.prefix.length);
|
|
15
|
-
if (rest !== "" && !rest.startsWith("/")) continue;
|
|
16
|
-
if (best == null || route.prefix.length > best.route.prefix.length) best = {
|
|
17
|
-
route,
|
|
18
|
-
rest
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
return best;
|
|
22
|
-
}
|
|
23
|
-
/** The absolute upstream URL for a matched route, carrying `search` unchanged. */
|
|
24
|
-
function upstreamUrl(route, rest, search) {
|
|
25
|
-
return `${route.upstream.endsWith("/") ? route.upstream.slice(0, -1) : route.upstream}${rest}${search}`;
|
|
26
|
-
}
|
|
27
|
-
//#endregion
|
|
28
1
|
//#region src/proxy.ts
|
|
29
2
|
/** Marks this layer's OWN responses, so they are never mistaken for an upstream's. Same header the proxy already uses. */
|
|
30
3
|
const MARKER = "x-webrun-proxy";
|
|
31
4
|
/**
|
|
32
|
-
* The mount: match, strip, delegate. Identical on both platforms — it touches
|
|
33
|
-
* no DOM, no `node:` module and no network of its own.
|
|
34
|
-
*/
|
|
35
|
-
function routeTable(init) {
|
|
36
|
-
const mountPrefix = init.mountPrefix ?? "/proxy";
|
|
37
|
-
return async (request) => {
|
|
38
|
-
const url = new URL(request.url);
|
|
39
|
-
const pathname = url.pathname.startsWith(mountPrefix) ? url.pathname.slice(mountPrefix.length) : url.pathname;
|
|
40
|
-
if ((pathname === "" || pathname === "/") && request.method === "GET") {
|
|
41
|
-
const body = JSON.stringify({ routes: init.routes().map((r) => ({
|
|
42
|
-
prefix: r.prefix,
|
|
43
|
-
upstream: r.describe
|
|
44
|
-
})) });
|
|
45
|
-
return new Response(body, { headers: { "content-type": "application/json" } });
|
|
46
|
-
}
|
|
47
|
-
const found = matchRoute(init.routes().map((r) => ({
|
|
48
|
-
prefix: r.prefix,
|
|
49
|
-
upstream: r.describe,
|
|
50
|
-
headers: {}
|
|
51
|
-
})), pathname);
|
|
52
|
-
if (found == null) return new Response(`no route for ${pathname}`, {
|
|
53
|
-
status: 404,
|
|
54
|
-
headers: { [MARKER]: "no-route" }
|
|
55
|
-
});
|
|
56
|
-
const route = init.routes().find((r) => r.prefix === found.route.prefix);
|
|
57
|
-
if (route == null) return new Response(`no route for ${pathname}`, {
|
|
58
|
-
status: 404,
|
|
59
|
-
headers: { [MARKER]: "no-route" }
|
|
60
|
-
});
|
|
61
|
-
const rewritten = new URL(`${found.rest || "/"}${url.search}`, url.origin);
|
|
62
|
-
const forwarded = new Request(rewritten, {
|
|
63
|
-
method: request.method,
|
|
64
|
-
headers: request.headers,
|
|
65
|
-
body: request.body,
|
|
66
|
-
...request.body != null ? { duplex: "half" } : {},
|
|
67
|
-
signal: request.signal
|
|
68
|
-
});
|
|
69
|
-
return route.upstream(forwarded);
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
5
|
* Re-issue the request to a URL — the "reverse proxy" half.
|
|
74
6
|
*
|
|
75
7
|
* THE HYGIENE IS HERE AND NOT IN THE TABLE, because it applies only when a
|
|
@@ -82,7 +14,6 @@ function urlUpstream(init) {
|
|
|
82
14
|
const from = new URL(request.url);
|
|
83
15
|
const target = new URL(`.${from.pathname}${from.search}`, ensureSlash(init.base));
|
|
84
16
|
const headers = new Headers(request.headers);
|
|
85
|
-
headers.delete("authorization");
|
|
86
17
|
for (const name of init.stripRequestHeaders ?? []) headers.delete(name);
|
|
87
18
|
for (const hop of HOP_BY_HOP) headers.delete(hop);
|
|
88
19
|
if (init.via != null) headers.set("via", init.via);
|
|
@@ -130,4 +61,4 @@ function ensureSlash(base) {
|
|
|
130
61
|
return base.endsWith("/") ? base : `${base}/`;
|
|
131
62
|
}
|
|
132
63
|
//#endregion
|
|
133
|
-
export { MARKER,
|
|
64
|
+
export { MARKER, urlUpstream };
|
package/dist/proxy.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Everything except the upstream kind is lifted from `services/proxy.ts` and
|
|
11
11
|
* `services/proxy-routes.ts`, which already run in the mesh: longest-prefix
|
|
12
12
|
* matching on segment boundaries, the listing at the mount root, the marker
|
|
13
|
-
* header, the
|
|
13
|
+
* header, the credential-stripping rule, route headers applied last, and the body
|
|
14
14
|
* handed on unread.
|
|
15
15
|
*
|
|
16
16
|
* TWO DEFECTS OF THE PROVEN CODE ARE FIXED HERE, both measured in the tests:
|
|
@@ -36,31 +36,14 @@ export declare const MARKER = "x-webrun-proxy";
|
|
|
36
36
|
* remote origin and another peer are all `(Request) => Promise<Response>`.
|
|
37
37
|
*/
|
|
38
38
|
export type Upstream = FetchHandler;
|
|
39
|
-
export interface Route {
|
|
40
|
-
/** Path prefix, matched on segment boundaries, longest first. */
|
|
41
|
-
prefix: string;
|
|
42
|
-
/** What this route reaches, for the listing at the mount root. Never a credential. */
|
|
43
|
-
describe: string;
|
|
44
|
-
upstream: Upstream;
|
|
45
|
-
}
|
|
46
|
-
export interface RouteTableInit {
|
|
47
|
-
/** Read per request, never snapshotted, so routes can be edited live. */
|
|
48
|
-
routes: () => readonly Route[];
|
|
49
|
-
/** Where this table is mounted; stripped before matching. */
|
|
50
|
-
mountPrefix?: string;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* The mount: match, strip, delegate. Identical on both platforms — it touches
|
|
54
|
-
* no DOM, no `node:` module and no network of its own.
|
|
55
|
-
*/
|
|
56
|
-
export declare function routeTable(init: RouteTableInit): FetchHandler;
|
|
57
39
|
export interface UrlUpstreamInit {
|
|
58
40
|
/**
|
|
59
|
-
* Request headers to drop before re-issuing upstream, beyond the
|
|
60
|
-
* proxy always drops
|
|
41
|
+
* Request headers to drop before re-issuing upstream, beyond the hop-by-hop
|
|
42
|
+
* set this proxy always drops.
|
|
61
43
|
*
|
|
62
|
-
* For
|
|
63
|
-
* third-party origin has no business seeing.
|
|
44
|
+
* For the surrounding system's own credential and anything it treats as
|
|
45
|
+
* proven identity -- what a third-party origin has no business seeing.
|
|
46
|
+
* `authorization` is forwarded unless it is named here.
|
|
64
47
|
*/
|
|
65
48
|
stripRequestHeaders?: readonly string[];
|
|
66
49
|
/** Origin (and optional base path) every request is re-issued against. */
|
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;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEnE,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,CA+D3D"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-http-proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Re-issue an HTTP request to an outside origin, safely: credentials consumed, hop-by-hop headers dropped, body streamed.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -15,14 +15,6 @@
|
|
|
15
15
|
".": {
|
|
16
16
|
"types": "./dist/index.d.ts",
|
|
17
17
|
"import": "./dist/index.js"
|
|
18
|
-
},
|
|
19
|
-
"./node": {
|
|
20
|
-
"types": "./dist/node.d.ts",
|
|
21
|
-
"import": "./dist/node.js"
|
|
22
|
-
},
|
|
23
|
-
"./browser": {
|
|
24
|
-
"types": "./dist/browser.d.ts",
|
|
25
|
-
"import": "./dist/browser.js"
|
|
26
18
|
}
|
|
27
19
|
},
|
|
28
20
|
"files": [
|
|
@@ -32,7 +24,9 @@
|
|
|
32
24
|
"sideEffects": false,
|
|
33
25
|
"dependencies": {},
|
|
34
26
|
"devDependencies": {
|
|
27
|
+
"@biomejs/biome": "^2.5.8",
|
|
35
28
|
"@types/node": "^26.2.0",
|
|
29
|
+
"hono": "^4.13.2",
|
|
36
30
|
"rimraf": "^6.1.3",
|
|
37
31
|
"rolldown": "^1.2.4",
|
|
38
32
|
"typescript": "^7.0.2",
|
package/src/index.ts
CHANGED
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Re-issuing a request to an outside origin, safely. That is the whole
|
|
3
|
+
* package.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* It used to ship a route table too — prefix matching, a listing, a marker on
|
|
6
|
+
* unmatched paths. All of that is what a ROUTER does, and every caller already
|
|
7
|
+
* has one: the twelve scenarios that established this proxy's behaviour now
|
|
8
|
+
* run against a plain Hono router and pass unchanged, which is the evidence
|
|
9
|
+
* that removing it lost nothing.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* What is genuinely this package's own is `urlUpstream`, and it is not
|
|
12
|
+
* obvious:
|
|
13
|
+
*
|
|
14
|
+
* - `stripRequestHeaders` names the caller's own credential and whatever
|
|
15
|
+
* else its system treats as identity; those are CONSUMED by this hop, the
|
|
16
|
+
* way `Proxy-Authorization` is consumed by the proxy it names, because a
|
|
17
|
+
* third-party origin has no business seeing them. `authorization` is not
|
|
18
|
+
* special: it belongs to the application calling the upstream and is
|
|
19
|
+
* forwarded unless named;
|
|
20
|
+
* - hop-by-hop headers (RFC 9110 §7.6.1) are dropped;
|
|
21
|
+
* - the credential is read at REQUEST time, so it can be typed while traffic
|
|
22
|
+
* flows;
|
|
23
|
+
* - the body streams rather than buffering, and the caller's `signal` is
|
|
24
|
+
* forwarded so an abandoned request abandons the upstream call;
|
|
25
|
+
* - an opaque redirect (status 0) is reported as a redirect rather than as
|
|
26
|
+
* `502 upstream-unreachable`, which is what constructing a `Response` with
|
|
27
|
+
* status 0 throwing inside a `try` used to produce.
|
|
28
|
+
*
|
|
29
|
+
* Persisting route configuration went with the router, for the same reason:
|
|
30
|
+
* the shape of that configuration belongs to whoever defines the routes.
|
|
14
31
|
*/
|
|
15
32
|
|
|
16
33
|
export {
|
|
17
34
|
type FetchHandler,
|
|
18
35
|
MARKER,
|
|
19
|
-
type Route,
|
|
20
|
-
routeTable,
|
|
21
|
-
type RouteTableInit,
|
|
22
36
|
type Upstream,
|
|
23
|
-
urlUpstream,
|
|
24
37
|
type UrlUpstreamInit,
|
|
38
|
+
urlUpstream,
|
|
25
39
|
} from "./proxy.js";
|
|
26
|
-
export { matchRoute, type ProxyRoute, upstreamUrl } from "./routes.js";
|
|
27
|
-
export {
|
|
28
|
-
assertNoSecrets,
|
|
29
|
-
rehydrate,
|
|
30
|
-
type RehydrateInit,
|
|
31
|
-
type RouteStore,
|
|
32
|
-
SecretNotPersistableError,
|
|
33
|
-
type StoredRoute,
|
|
34
|
-
toStoredRoute,
|
|
35
|
-
} from "./store.js";
|
package/src/proxy.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Everything except the upstream kind is lifted from `services/proxy.ts` and
|
|
11
11
|
* `services/proxy-routes.ts`, which already run in the mesh: longest-prefix
|
|
12
12
|
* matching on segment boundaries, the listing at the mount root, the marker
|
|
13
|
-
* header, the
|
|
13
|
+
* header, the credential-stripping rule, route headers applied last, and the body
|
|
14
14
|
* handed on unread.
|
|
15
15
|
*
|
|
16
16
|
* TWO DEFECTS OF THE PROVEN CODE ARE FIXED HERE, both measured in the tests:
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
* package for it would give a proxy a dependency it has no other use for.
|
|
31
31
|
*/
|
|
32
32
|
export type FetchHandler = (request: Request) => Promise<Response>;
|
|
33
|
-
import { matchRoute } from "./routes.js";
|
|
34
33
|
|
|
35
34
|
/** Marks this layer's OWN responses, so they are never mistaken for an upstream's. Same header the proxy already uses. */
|
|
36
35
|
export const MARKER = "x-webrun-proxy";
|
|
@@ -41,91 +40,14 @@ export const MARKER = "x-webrun-proxy";
|
|
|
41
40
|
*/
|
|
42
41
|
export type Upstream = FetchHandler;
|
|
43
42
|
|
|
44
|
-
export interface Route {
|
|
45
|
-
/** Path prefix, matched on segment boundaries, longest first. */
|
|
46
|
-
prefix: string;
|
|
47
|
-
/** What this route reaches, for the listing at the mount root. Never a credential. */
|
|
48
|
-
describe: string;
|
|
49
|
-
upstream: Upstream;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface RouteTableInit {
|
|
53
|
-
/** Read per request, never snapshotted, so routes can be edited live. */
|
|
54
|
-
routes: () => readonly Route[];
|
|
55
|
-
/** Where this table is mounted; stripped before matching. */
|
|
56
|
-
mountPrefix?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The mount: match, strip, delegate. Identical on both platforms — it touches
|
|
61
|
-
* no DOM, no `node:` module and no network of its own.
|
|
62
|
-
*/
|
|
63
|
-
export function routeTable(init: RouteTableInit): FetchHandler {
|
|
64
|
-
const mountPrefix = init.mountPrefix ?? "/proxy";
|
|
65
|
-
|
|
66
|
-
return async (request: Request): Promise<Response> => {
|
|
67
|
-
const url = new URL(request.url);
|
|
68
|
-
const pathname = url.pathname.startsWith(mountPrefix)
|
|
69
|
-
? url.pathname.slice(mountPrefix.length)
|
|
70
|
-
: url.pathname;
|
|
71
|
-
|
|
72
|
-
// The listing: prefixes and descriptions, never headers — a header value
|
|
73
|
-
// is a credential and every member of the mesh can read this.
|
|
74
|
-
if ((pathname === "" || pathname === "/") && request.method === "GET") {
|
|
75
|
-
const body = JSON.stringify({
|
|
76
|
-
routes: init.routes().map((r) => ({ prefix: r.prefix, upstream: r.describe })),
|
|
77
|
-
});
|
|
78
|
-
return new Response(body, { headers: { "content-type": "application/json" } });
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// `matchRoute` is the PROVEN matcher, imported rather than copied: it is
|
|
82
|
-
// what enforces segment boundaries, so `/files` does not swallow
|
|
83
|
-
// `/filesystem`. It expects `{prefix, upstream, headers}`-shaped records,
|
|
84
|
-
// so the route is presented to it with `upstream` as its description.
|
|
85
|
-
const shaped = init.routes().map((r) => ({
|
|
86
|
-
prefix: r.prefix,
|
|
87
|
-
upstream: r.describe,
|
|
88
|
-
headers: {},
|
|
89
|
-
}));
|
|
90
|
-
const found = matchRoute(shaped, pathname);
|
|
91
|
-
if (found == null) {
|
|
92
|
-
return new Response(`no route for ${pathname}`, {
|
|
93
|
-
status: 404,
|
|
94
|
-
headers: { [MARKER]: "no-route" },
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const route = init.routes().find((r) => r.prefix === found.route.prefix);
|
|
99
|
-
if (route == null) {
|
|
100
|
-
return new Response(`no route for ${pathname}`, {
|
|
101
|
-
status: 404,
|
|
102
|
-
headers: { [MARKER]: "no-route" },
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// The rest of the path, plus the query, as a request the upstream sees.
|
|
107
|
-
// The URL's origin is carried over so a handler upstream still gets a
|
|
108
|
-
// well-formed absolute URL; a `urlUpstream` replaces it entirely.
|
|
109
|
-
const rewritten = new URL(`${found.rest || "/"}${url.search}`, url.origin);
|
|
110
|
-
const forwarded = new Request(rewritten, {
|
|
111
|
-
method: request.method,
|
|
112
|
-
headers: request.headers,
|
|
113
|
-
body: request.body,
|
|
114
|
-
...(request.body != null ? { duplex: "half" as const } : {}),
|
|
115
|
-
signal: request.signal,
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
return route.upstream(forwarded);
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
43
|
export interface UrlUpstreamInit {
|
|
123
44
|
/**
|
|
124
|
-
* Request headers to drop before re-issuing upstream, beyond the
|
|
125
|
-
* proxy always drops
|
|
45
|
+
* Request headers to drop before re-issuing upstream, beyond the hop-by-hop
|
|
46
|
+
* set this proxy always drops.
|
|
126
47
|
*
|
|
127
|
-
* For
|
|
128
|
-
* third-party origin has no business seeing.
|
|
48
|
+
* For the surrounding system's own credential and anything it treats as
|
|
49
|
+
* proven identity -- what a third-party origin has no business seeing.
|
|
50
|
+
* `authorization` is forwarded unless it is named here.
|
|
129
51
|
*/
|
|
130
52
|
stripRequestHeaders?: readonly string[];
|
|
131
53
|
|
|
@@ -156,17 +78,19 @@ export function urlUpstream(init: UrlUpstreamInit): Upstream {
|
|
|
156
78
|
const target = new URL(`.${from.pathname}${from.search}`, ensureSlash(init.base));
|
|
157
79
|
|
|
158
80
|
const headers = new Headers(request.headers);
|
|
159
|
-
//
|
|
160
|
-
// `Proxy-Authorization` is consumed by the
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
headers
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
81
|
+
// WHATEVER THE CALLER'S SYSTEM TREATS AS ITS OWN CREDENTIAL OR IDENTITY
|
|
82
|
+
// is consumed by this hop, the way `Proxy-Authorization` is consumed by the
|
|
83
|
+
// proxy it names. This proxy knows nothing about the caller's trust model,
|
|
84
|
+
// so the names are configuration: httpeers passes its membership-token and
|
|
85
|
+
// proven-peer headers here, because re-issuing to a third party must hand
|
|
86
|
+
// it neither a mesh token (an upstream once echoed one back) nor which
|
|
87
|
+
// mesh peer called. Without this a caller would have to post-process the
|
|
88
|
+
// request, by which point it has already gone.
|
|
89
|
+
//
|
|
90
|
+
// `authorization` is NOT on any built-in list. It belongs to the
|
|
91
|
+
// application talking to the upstream -- a page calling an API with that
|
|
92
|
+
// API's own key -- and dropping it made such a call impossible through the
|
|
93
|
+
// proxy. A system that does put its credential there names it here.
|
|
170
94
|
for (const name of init.stripRequestHeaders ?? []) headers.delete(name);
|
|
171
95
|
// Hop-by-hop headers (RFC 9110 §7.6.1) are not the upstream's business.
|
|
172
96
|
for (const hop of HOP_BY_HOP) headers.delete(hop);
|
package/dist/browser.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The browser profile: routes in `localStorage`.
|
|
3
|
-
*
|
|
4
|
-
* `localStorage` IS the reason `assertNoSecrets` exists. It survives a reload,
|
|
5
|
-
* a shared machine and anyone who opens devtools, so a credential written here
|
|
6
|
-
* is a credential leaked — which is exactly what building the proxy page on an
|
|
7
|
-
* earlier shape of this API would have done.
|
|
8
|
-
*/
|
|
9
|
-
import { type RouteStore } from "./store.js";
|
|
10
|
-
export declare function localStorageRouteStore(key?: string): RouteStore;
|
|
11
|
-
//# sourceMappingURL=browser.d.ts.map
|
package/dist/browser.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAmB,KAAK,UAAU,EAAoB,MAAM,YAAY,CAAC;AAIhF,wBAAgB,sBAAsB,CAAC,GAAG,GAAE,MAAoB,GAAG,UAAU,CAmB5E"}
|
package/dist/browser.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { n as assertNoSecrets } from "./store-BwcCz3M9.js";
|
|
2
|
-
//#region src/browser.ts
|
|
3
|
-
/**
|
|
4
|
-
* The browser profile: routes in `localStorage`.
|
|
5
|
-
*
|
|
6
|
-
* `localStorage` IS the reason `assertNoSecrets` exists. It survives a reload,
|
|
7
|
-
* a shared machine and anyone who opens devtools, so a credential written here
|
|
8
|
-
* is a credential leaked — which is exactly what building the proxy page on an
|
|
9
|
-
* earlier shape of this API would have done.
|
|
10
|
-
*/
|
|
11
|
-
const DEFAULT_KEY = "webrun:proxy:routes";
|
|
12
|
-
function localStorageRouteStore(key = DEFAULT_KEY) {
|
|
13
|
-
return {
|
|
14
|
-
async load() {
|
|
15
|
-
const raw = globalThis.localStorage?.getItem(key);
|
|
16
|
-
if (raw == null) return void 0;
|
|
17
|
-
try {
|
|
18
|
-
return JSON.parse(raw);
|
|
19
|
-
} catch {
|
|
20
|
-
return [];
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
async save(routes) {
|
|
24
|
-
assertNoSecrets(routes);
|
|
25
|
-
globalThis.localStorage?.setItem(key, JSON.stringify(routes));
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
//#endregion
|
|
30
|
-
export { localStorageRouteStore };
|
package/dist/node.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Node profile: routes on disk.
|
|
3
|
-
*
|
|
4
|
-
* Write-then-rename, for the same reason the identity store does it — a
|
|
5
|
-
* truncated file is not a corrupt file somebody notices, it is a table that
|
|
6
|
-
* silently loses routes. Here the cost is smaller than a lost mesh identity,
|
|
7
|
-
* but the fix is two lines and the failure is equally quiet.
|
|
8
|
-
*/
|
|
9
|
-
import { type RouteStore } from "./store.js";
|
|
10
|
-
export declare function fileRouteStore(path: string): RouteStore;
|
|
11
|
-
//# sourceMappingURL=node.d.ts.map
|
package/dist/node.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAmB,KAAK,UAAU,EAAoB,MAAM,YAAY,CAAC;AAEhF,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAoBvD"}
|
package/dist/node.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { n as assertNoSecrets } from "./store-BwcCz3M9.js";
|
|
2
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname } from "node:path";
|
|
4
|
-
//#region src/node.ts
|
|
5
|
-
/**
|
|
6
|
-
* The Node profile: routes on disk.
|
|
7
|
-
*
|
|
8
|
-
* Write-then-rename, for the same reason the identity store does it — a
|
|
9
|
-
* truncated file is not a corrupt file somebody notices, it is a table that
|
|
10
|
-
* silently loses routes. Here the cost is smaller than a lost mesh identity,
|
|
11
|
-
* but the fix is two lines and the failure is equally quiet.
|
|
12
|
-
*/
|
|
13
|
-
function fileRouteStore(path) {
|
|
14
|
-
return {
|
|
15
|
-
async load() {
|
|
16
|
-
try {
|
|
17
|
-
return JSON.parse(await readFile(path, "utf8"));
|
|
18
|
-
} catch (error) {
|
|
19
|
-
if (error.code === "ENOENT") return void 0;
|
|
20
|
-
throw error;
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
async save(routes) {
|
|
24
|
-
assertNoSecrets(routes);
|
|
25
|
-
await mkdir(dirname(path), { recursive: true });
|
|
26
|
-
const staging = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
27
|
-
await writeFile(staging, JSON.stringify(routes, null, 2), { mode: 384 });
|
|
28
|
-
await rename(staging, path);
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
//#endregion
|
|
33
|
-
export { fileRouteStore };
|
package/dist/routes.d.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which configured upstream a mesh-side path belongs to.
|
|
3
|
-
*
|
|
4
|
-
* THE UPSTREAM IS NEVER TAKEN FROM THE REQUEST. `cors-anywhere` puts the
|
|
5
|
-
* destination in the URL (`/https://example.com/`), which is why its public
|
|
6
|
-
* instance is permanently abused. Here a caller selects among upstreams the
|
|
7
|
-
* operator configured and can reach nothing else, so this module must never
|
|
8
|
-
* gain a wildcard or passthrough mode.
|
|
9
|
-
*/
|
|
10
|
-
export interface ProxyRoute {
|
|
11
|
-
/** Mesh-side prefix, leading slash, no trailing slash: `/openai`. */
|
|
12
|
-
prefix: string;
|
|
13
|
-
/** Upstream base: `https://api.openai.com/v1`. */
|
|
14
|
-
upstream: string;
|
|
15
|
-
/** Applied to every forwarded request, last, so operator config wins. */
|
|
16
|
-
headers: Record<string, string>;
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* The route owning `pathname`, and what follows its prefix.
|
|
20
|
-
*
|
|
21
|
-
* LONGEST PREFIX WINS, so `/openai/admin` can go somewhere other than
|
|
22
|
-
* `/openai`, and the answer does not depend on the order routes were added --
|
|
23
|
-
* an ordering dependency would make the UI's list order silently meaningful.
|
|
24
|
-
*/
|
|
25
|
-
export declare function matchRoute(routes: readonly ProxyRoute[], pathname: string): {
|
|
26
|
-
route: ProxyRoute;
|
|
27
|
-
rest: string;
|
|
28
|
-
} | null;
|
|
29
|
-
/** The absolute upstream URL for a matched route, carrying `search` unchanged. */
|
|
30
|
-
export declare function upstreamUrl(route: ProxyRoute, rest: string, search: string): string;
|
|
31
|
-
//# sourceMappingURL=routes.d.ts.map
|
package/dist/routes.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,UAAU;IACzB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,SAAS,UAAU,EAAE,EAC7B,QAAQ,EAAE,MAAM,GACf;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAY5C;AAED,kFAAkF;AAClF,wBAAgB,WAAW,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAGnF"}
|
package/dist/store-BwcCz3M9.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
//#region src/store.ts
|
|
2
|
-
/** Thrown when a caller tries to persist something that looks like a secret. */
|
|
3
|
-
var SecretNotPersistableError = class extends Error {
|
|
4
|
-
prefix;
|
|
5
|
-
constructor(prefix) {
|
|
6
|
-
super(`webrun-http-proxy: refusing to persist a credential value for route ${JSON.stringify(prefix)}. Store the header NAME (\`secretHeader\`) and keep the value in memory — a persisted credential survives a reload, a shared machine, and devtools.`);
|
|
7
|
-
this.prefix = prefix;
|
|
8
|
-
this.name = "SecretNotPersistableError";
|
|
9
|
-
}
|
|
10
|
-
};
|
|
11
|
-
/**
|
|
12
|
-
* The check `save()` implementations run. Exported so an adapter written
|
|
13
|
-
* elsewhere enforces the same rule rather than reimplementing its own idea of
|
|
14
|
-
* what a secret looks like.
|
|
15
|
-
*/
|
|
16
|
-
function assertNoSecrets(routes) {
|
|
17
|
-
for (const route of routes) {
|
|
18
|
-
const carrier = route;
|
|
19
|
-
if (typeof carrier.secret === "string" || typeof carrier.secretValue === "string" || carrier.headers != null && typeof carrier.headers === "object") throw new SecretNotPersistableError(route.prefix);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
/** Keep only the persistable fields, dropping anything else a caller passed. */
|
|
23
|
-
function toStoredRoute(route) {
|
|
24
|
-
return {
|
|
25
|
-
prefix: route.prefix,
|
|
26
|
-
upstream: route.upstream,
|
|
27
|
-
describe: route.describe,
|
|
28
|
-
secretHeader: route.secretHeader ?? null
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Stored routes back into live ones.
|
|
33
|
-
*
|
|
34
|
-
* The secret re-enters here, from wherever the caller keeps it in memory —
|
|
35
|
-
* which is the only place it ever was.
|
|
36
|
-
*/
|
|
37
|
-
function rehydrate(stored, init) {
|
|
38
|
-
return stored.map((route) => ({
|
|
39
|
-
prefix: route.prefix,
|
|
40
|
-
describe: route.describe,
|
|
41
|
-
upstream: init.upstreamFor(route)
|
|
42
|
-
}));
|
|
43
|
-
}
|
|
44
|
-
//#endregion
|
|
45
|
-
export { toStoredRoute as i, assertNoSecrets as n, rehydrate as r, SecretNotPersistableError as t };
|
package/dist/store.d.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persisting routes — and refusing, mechanically, to persist a secret.
|
|
3
|
-
*
|
|
4
|
-
* THE RULE THIS FILE EXISTS TO ENFORCE. A route may carry a credential: an
|
|
5
|
-
* upstream API key, a bearer token, whatever the operator typed into the proxy
|
|
6
|
-
* page. The header's NAME is configuration and is saved. The header's VALUE is
|
|
7
|
-
* a secret, lives in memory, and is merged per request.
|
|
8
|
-
*
|
|
9
|
-
* That distinction cannot be left to callers. An earlier shape of this API
|
|
10
|
-
* stored a whole `Route`, and building the proxy page on it would have written
|
|
11
|
-
* bearer keys into `localStorage` — where they survive a reload, a shared
|
|
12
|
-
* machine, and anyone who opens devtools. So `StoredRoute` has no field a
|
|
13
|
-
* value could go in, and `save()` throws rather than silently dropping one:
|
|
14
|
-
* silently dropping would mean a route that worked before a reload and
|
|
15
|
-
* mysteriously 401s after it.
|
|
16
|
-
*
|
|
17
|
-
* `load()` RETURNS `undefined` FOR "NEVER WRITTEN", which is not the same as
|
|
18
|
-
* an empty array and the difference is visible to a user. A first visit should
|
|
19
|
-
* seed the demo routes; a visit after the operator deleted all of them should
|
|
20
|
-
* not bring them back. One value distinguishes the two.
|
|
21
|
-
*/
|
|
22
|
-
import type { Route, Upstream } from "./proxy.js";
|
|
23
|
-
/**
|
|
24
|
-
* A route as it is persisted: the upstream as a URL, and the header NAME only.
|
|
25
|
-
*
|
|
26
|
-
* There is deliberately no field for a header value. The type is the
|
|
27
|
-
* enforcement; `save()`'s check is the belt to its braces.
|
|
28
|
-
*/
|
|
29
|
-
export interface StoredRoute {
|
|
30
|
-
prefix: string;
|
|
31
|
-
/** The upstream base URL. A route whose upstream is a live handler cannot be stored. */
|
|
32
|
-
upstream: string;
|
|
33
|
-
describe: string;
|
|
34
|
-
/** The name of the header carrying a credential, or `null` for none. */
|
|
35
|
-
secretHeader: string | null;
|
|
36
|
-
}
|
|
37
|
-
export interface RouteStore {
|
|
38
|
-
/** `undefined` means NEVER WRITTEN — distinct from `[]`, which means "the operator deleted them all". */
|
|
39
|
-
load(): Promise<StoredRoute[] | undefined>;
|
|
40
|
-
/** Throws if any route carries a credential VALUE. */
|
|
41
|
-
save(routes: StoredRoute[]): Promise<void>;
|
|
42
|
-
}
|
|
43
|
-
/** Thrown when a caller tries to persist something that looks like a secret. */
|
|
44
|
-
export declare class SecretNotPersistableError extends Error {
|
|
45
|
-
readonly prefix: string;
|
|
46
|
-
constructor(prefix: string);
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* The check `save()` implementations run. Exported so an adapter written
|
|
50
|
-
* elsewhere enforces the same rule rather than reimplementing its own idea of
|
|
51
|
-
* what a secret looks like.
|
|
52
|
-
*/
|
|
53
|
-
export declare function assertNoSecrets(routes: readonly StoredRoute[]): void;
|
|
54
|
-
/** Keep only the persistable fields, dropping anything else a caller passed. */
|
|
55
|
-
export declare function toStoredRoute(route: {
|
|
56
|
-
prefix: string;
|
|
57
|
-
upstream: string;
|
|
58
|
-
describe: string;
|
|
59
|
-
secretHeader?: string | null;
|
|
60
|
-
}): StoredRoute;
|
|
61
|
-
export interface RehydrateInit {
|
|
62
|
-
/** Build the live upstream for a stored route. Where the secret VALUE is re-supplied. */
|
|
63
|
-
upstreamFor: (stored: StoredRoute) => Upstream;
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Stored routes back into live ones.
|
|
67
|
-
*
|
|
68
|
-
* The secret re-enters here, from wherever the caller keeps it in memory —
|
|
69
|
-
* which is the only place it ever was.
|
|
70
|
-
*/
|
|
71
|
-
export declare function rehydrate(stored: readonly StoredRoute[], init: RehydrateInit): Route[];
|
|
72
|
-
//# sourceMappingURL=store.d.ts.map
|
package/dist/store.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAElD;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,wFAAwF;IACxF,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,UAAU;IACzB,yGAAyG;IACzG,IAAI,IAAI,OAAO,CAAC,WAAW,EAAE,GAAG,SAAS,CAAC,CAAC;IAC3C,sDAAsD;IACtD,IAAI,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5C;AAED,gFAAgF;AAChF,qBAAa,yBAA0B,SAAQ,KAAK;aACtB,MAAM,EAAE,MAAM;IAA1C,YAA4B,MAAM,EAAE,MAAM,EAOzC;CACF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,GAAG,IAAI,CAapE;AAED,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,KAAK,EAAE;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B,GAAG,WAAW,CAOd;AAED,MAAM,WAAW,aAAa;IAC5B,yFAAyF;IACzF,WAAW,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,QAAQ,CAAC;CAChD;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,EAAE,IAAI,EAAE,aAAa,GAAG,KAAK,EAAE,CAMtF"}
|
package/src/browser.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The browser profile: routes in `localStorage`.
|
|
3
|
-
*
|
|
4
|
-
* `localStorage` IS the reason `assertNoSecrets` exists. It survives a reload,
|
|
5
|
-
* a shared machine and anyone who opens devtools, so a credential written here
|
|
6
|
-
* is a credential leaked — which is exactly what building the proxy page on an
|
|
7
|
-
* earlier shape of this API would have done.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { assertNoSecrets, type RouteStore, type StoredRoute } from "./store.js";
|
|
11
|
-
|
|
12
|
-
const DEFAULT_KEY = "webrun:proxy:routes";
|
|
13
|
-
|
|
14
|
-
export function localStorageRouteStore(key: string = DEFAULT_KEY): RouteStore {
|
|
15
|
-
return {
|
|
16
|
-
async load() {
|
|
17
|
-
const raw = globalThis.localStorage?.getItem(key);
|
|
18
|
-
// `null` from `getItem` means never written. Distinct from `"[]"`.
|
|
19
|
-
if (raw == null) return undefined;
|
|
20
|
-
try {
|
|
21
|
-
return JSON.parse(raw) as StoredRoute[];
|
|
22
|
-
} catch {
|
|
23
|
-
// Corrupt is not "never written": resurrecting defaults over a table
|
|
24
|
-
// somebody edited would be worse than starting empty and saying so.
|
|
25
|
-
return [];
|
|
26
|
-
}
|
|
27
|
-
},
|
|
28
|
-
async save(routes) {
|
|
29
|
-
assertNoSecrets(routes);
|
|
30
|
-
globalThis.localStorage?.setItem(key, JSON.stringify(routes));
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
}
|
package/src/node.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Node profile: routes on disk.
|
|
3
|
-
*
|
|
4
|
-
* Write-then-rename, for the same reason the identity store does it — a
|
|
5
|
-
* truncated file is not a corrupt file somebody notices, it is a table that
|
|
6
|
-
* silently loses routes. Here the cost is smaller than a lost mesh identity,
|
|
7
|
-
* but the fix is two lines and the failure is equally quiet.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
11
|
-
import { dirname } from "node:path";
|
|
12
|
-
import { assertNoSecrets, type RouteStore, type StoredRoute } from "./store.js";
|
|
13
|
-
|
|
14
|
-
export function fileRouteStore(path: string): RouteStore {
|
|
15
|
-
return {
|
|
16
|
-
async load() {
|
|
17
|
-
try {
|
|
18
|
-
return JSON.parse(await readFile(path, "utf8")) as StoredRoute[];
|
|
19
|
-
} catch (error) {
|
|
20
|
-
// NEVER WRITTEN, not empty. A first run seeds its defaults; a run
|
|
21
|
-
// after the operator deleted every route must not resurrect them.
|
|
22
|
-
if ((error as { code?: string }).code === "ENOENT") return undefined;
|
|
23
|
-
throw error;
|
|
24
|
-
}
|
|
25
|
-
},
|
|
26
|
-
async save(routes) {
|
|
27
|
-
assertNoSecrets(routes);
|
|
28
|
-
await mkdir(dirname(path), { recursive: true });
|
|
29
|
-
const staging = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
30
|
-
await writeFile(staging, JSON.stringify(routes, null, 2), { mode: 0o600 });
|
|
31
|
-
await rename(staging, path);
|
|
32
|
-
},
|
|
33
|
-
};
|
|
34
|
-
}
|
package/src/routes.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which configured upstream a mesh-side path belongs to.
|
|
3
|
-
*
|
|
4
|
-
* THE UPSTREAM IS NEVER TAKEN FROM THE REQUEST. `cors-anywhere` puts the
|
|
5
|
-
* destination in the URL (`/https://example.com/`), which is why its public
|
|
6
|
-
* instance is permanently abused. Here a caller selects among upstreams the
|
|
7
|
-
* operator configured and can reach nothing else, so this module must never
|
|
8
|
-
* gain a wildcard or passthrough mode.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export interface ProxyRoute {
|
|
12
|
-
/** Mesh-side prefix, leading slash, no trailing slash: `/openai`. */
|
|
13
|
-
prefix: string;
|
|
14
|
-
/** Upstream base: `https://api.openai.com/v1`. */
|
|
15
|
-
upstream: string;
|
|
16
|
-
/** Applied to every forwarded request, last, so operator config wins. */
|
|
17
|
-
headers: Record<string, string>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The route owning `pathname`, and what follows its prefix.
|
|
22
|
-
*
|
|
23
|
-
* LONGEST PREFIX WINS, so `/openai/admin` can go somewhere other than
|
|
24
|
-
* `/openai`, and the answer does not depend on the order routes were added --
|
|
25
|
-
* an ordering dependency would make the UI's list order silently meaningful.
|
|
26
|
-
*/
|
|
27
|
-
export function matchRoute(
|
|
28
|
-
routes: readonly ProxyRoute[],
|
|
29
|
-
pathname: string,
|
|
30
|
-
): { route: ProxyRoute; rest: string } | null {
|
|
31
|
-
let best: { route: ProxyRoute; rest: string } | null = null;
|
|
32
|
-
for (const route of routes) {
|
|
33
|
-
if (!pathname.startsWith(route.prefix)) continue;
|
|
34
|
-
const rest = pathname.slice(route.prefix.length);
|
|
35
|
-
// A prefix is a SEGMENT, not a string prefix: `/open` must not swallow
|
|
36
|
-
// `/openai/models`, or adding a short route would silently capture longer
|
|
37
|
-
// unrelated ones.
|
|
38
|
-
if (rest !== "" && !rest.startsWith("/")) continue;
|
|
39
|
-
if (best == null || route.prefix.length > best.route.prefix.length) best = { route, rest };
|
|
40
|
-
}
|
|
41
|
-
return best;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** The absolute upstream URL for a matched route, carrying `search` unchanged. */
|
|
45
|
-
export function upstreamUrl(route: ProxyRoute, rest: string, search: string): string {
|
|
46
|
-
const base = route.upstream.endsWith("/") ? route.upstream.slice(0, -1) : route.upstream;
|
|
47
|
-
return `${base}${rest}${search}`;
|
|
48
|
-
}
|
package/src/store.ts
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persisting routes — and refusing, mechanically, to persist a secret.
|
|
3
|
-
*
|
|
4
|
-
* THE RULE THIS FILE EXISTS TO ENFORCE. A route may carry a credential: an
|
|
5
|
-
* upstream API key, a bearer token, whatever the operator typed into the proxy
|
|
6
|
-
* page. The header's NAME is configuration and is saved. The header's VALUE is
|
|
7
|
-
* a secret, lives in memory, and is merged per request.
|
|
8
|
-
*
|
|
9
|
-
* That distinction cannot be left to callers. An earlier shape of this API
|
|
10
|
-
* stored a whole `Route`, and building the proxy page on it would have written
|
|
11
|
-
* bearer keys into `localStorage` — where they survive a reload, a shared
|
|
12
|
-
* machine, and anyone who opens devtools. So `StoredRoute` has no field a
|
|
13
|
-
* value could go in, and `save()` throws rather than silently dropping one:
|
|
14
|
-
* silently dropping would mean a route that worked before a reload and
|
|
15
|
-
* mysteriously 401s after it.
|
|
16
|
-
*
|
|
17
|
-
* `load()` RETURNS `undefined` FOR "NEVER WRITTEN", which is not the same as
|
|
18
|
-
* an empty array and the difference is visible to a user. A first visit should
|
|
19
|
-
* seed the demo routes; a visit after the operator deleted all of them should
|
|
20
|
-
* not bring them back. One value distinguishes the two.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import type { Route, Upstream } from "./proxy.js";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* A route as it is persisted: the upstream as a URL, and the header NAME only.
|
|
27
|
-
*
|
|
28
|
-
* There is deliberately no field for a header value. The type is the
|
|
29
|
-
* enforcement; `save()`'s check is the belt to its braces.
|
|
30
|
-
*/
|
|
31
|
-
export interface StoredRoute {
|
|
32
|
-
prefix: string;
|
|
33
|
-
/** The upstream base URL. A route whose upstream is a live handler cannot be stored. */
|
|
34
|
-
upstream: string;
|
|
35
|
-
describe: string;
|
|
36
|
-
/** The name of the header carrying a credential, or `null` for none. */
|
|
37
|
-
secretHeader: string | null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface RouteStore {
|
|
41
|
-
/** `undefined` means NEVER WRITTEN — distinct from `[]`, which means "the operator deleted them all". */
|
|
42
|
-
load(): Promise<StoredRoute[] | undefined>;
|
|
43
|
-
/** Throws if any route carries a credential VALUE. */
|
|
44
|
-
save(routes: StoredRoute[]): Promise<void>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Thrown when a caller tries to persist something that looks like a secret. */
|
|
48
|
-
export class SecretNotPersistableError extends Error {
|
|
49
|
-
constructor(public readonly prefix: string) {
|
|
50
|
-
super(
|
|
51
|
-
`webrun-http-proxy: refusing to persist a credential value for route ${JSON.stringify(prefix)}. ` +
|
|
52
|
-
"Store the header NAME (`secretHeader`) and keep the value in memory — a persisted " +
|
|
53
|
-
"credential survives a reload, a shared machine, and devtools.",
|
|
54
|
-
);
|
|
55
|
-
this.name = "SecretNotPersistableError";
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The check `save()` implementations run. Exported so an adapter written
|
|
61
|
-
* elsewhere enforces the same rule rather than reimplementing its own idea of
|
|
62
|
-
* what a secret looks like.
|
|
63
|
-
*/
|
|
64
|
-
export function assertNoSecrets(routes: readonly StoredRoute[]): void {
|
|
65
|
-
for (const route of routes) {
|
|
66
|
-
const carrier = route as unknown as Record<string, unknown>;
|
|
67
|
-
// Any shape a value could arrive in. A caller passing a whole `Route`
|
|
68
|
-
// through by mistake is the case this catches, and it is the likely one.
|
|
69
|
-
if (
|
|
70
|
-
typeof carrier.secret === "string" ||
|
|
71
|
-
typeof carrier.secretValue === "string" ||
|
|
72
|
-
(carrier.headers != null && typeof carrier.headers === "object")
|
|
73
|
-
) {
|
|
74
|
-
throw new SecretNotPersistableError(route.prefix);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Keep only the persistable fields, dropping anything else a caller passed. */
|
|
80
|
-
export function toStoredRoute(route: {
|
|
81
|
-
prefix: string;
|
|
82
|
-
upstream: string;
|
|
83
|
-
describe: string;
|
|
84
|
-
secretHeader?: string | null;
|
|
85
|
-
}): StoredRoute {
|
|
86
|
-
return {
|
|
87
|
-
prefix: route.prefix,
|
|
88
|
-
upstream: route.upstream,
|
|
89
|
-
describe: route.describe,
|
|
90
|
-
secretHeader: route.secretHeader ?? null,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export interface RehydrateInit {
|
|
95
|
-
/** Build the live upstream for a stored route. Where the secret VALUE is re-supplied. */
|
|
96
|
-
upstreamFor: (stored: StoredRoute) => Upstream;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Stored routes back into live ones.
|
|
101
|
-
*
|
|
102
|
-
* The secret re-enters here, from wherever the caller keeps it in memory —
|
|
103
|
-
* which is the only place it ever was.
|
|
104
|
-
*/
|
|
105
|
-
export function rehydrate(stored: readonly StoredRoute[], init: RehydrateInit): Route[] {
|
|
106
|
-
return stored.map((route) => ({
|
|
107
|
-
prefix: route.prefix,
|
|
108
|
-
describe: route.describe,
|
|
109
|
-
upstream: init.upstreamFor(route),
|
|
110
|
-
}));
|
|
111
|
-
}
|