@statewalker/webrun-http-proxy 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022-2026 statewalker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # @statewalker/webrun-http-proxy
2
+
3
+ A reverse proxy as a fetch handler: one route table, two kinds of upstream — an
4
+ in-process handler, or a remote origin.
5
+
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.
20
+
21
+ ```ts
22
+ import { routeTable, urlUpstream } from "@statewalker/webrun-http-proxy";
23
+
24
+ const handler = routeTable({
25
+ routes: () => [
26
+ { prefix: "/openai", describe: "OpenAI", upstream: urlUpstream({ base: "https://api.openai.com/v1" }) },
27
+ { prefix: "/local", describe: "in-process", upstream: myHandler },
28
+ ],
29
+ });
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
+
54
+ `load()` returns `undefined` for **never written**, which is not the same as
55
+ `[]`. A first visit seeds its defaults; a visit after the operator deleted
56
+ every route must not bring them back.
57
+
58
+ ## Hygiene belongs to the URL upstream, not to the table
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
72
+
73
+ - A **redirecting upstream** was reported as `502 upstream-unreachable`: an
74
+ opaque redirect has status `0`, and constructing a `Response` with it throws
75
+ *inside* the `try`.
76
+ - The outbound request carried **no `signal`**, so an aborted caller left the
77
+ upstream call running.
78
+
79
+ ## One row a browser cannot pass
80
+
81
+ `Via` is a forbidden header name under the Fetch spec — a page may not set it,
82
+ and the browser drops it with no error. ADR-0015 has an intermediary announce
83
+ itself with `Via`, so that part is unimplementable in a browser-hosted
84
+ intermediary. A fact about the platform, not about this code.
85
+
86
+ ## Entry points
87
+
88
+ | Import | Holds |
89
+ |---|---|
90
+ | `.` | `routeTable`, `urlUpstream`, `RouteStore`, `assertNoSecrets`, `rehydrate` |
91
+ | `./node` | `fileRouteStore(path)` — write-then-rename |
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**.
97
+
98
+ ## Where it came from
99
+
100
+ 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: it moves a
102
+ `Request` to an upstream and a `Response` back. The one place the old package
103
+ knew about meshes is now **`stripRequestHeaders`** — request headers to drop
104
+ before re-issuing upstream, beyond the `authorization` and hop-by-hop sets it
105
+ always drops. httpeers passes its proven-peer header there, because re-issuing
106
+ to a third party must not tell an outside origin which mesh peer called.
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.
@@ -0,0 +1,11 @@
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
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,30 @@
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 };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A reverse proxy as a fetch handler.
3
+ *
4
+ * "Reverse proxy" and "expose a local app" are ONE MECHANISM, and twelve
5
+ * scenarios establish it: only the last step differs — a local handler is
6
+ * *called*, a URL upstream is *re-issued*. Matching, rewriting, the listing,
7
+ * the marker header and streaming are shared.
8
+ *
9
+ * EXTRACTED FROM `@statewalker/httpeers-expose`, where it was a mesh concept
10
+ * by accident of where it was written. Nothing in it is about peers: it moves
11
+ * a `Request` to an upstream and a `Response` back. The one place the old
12
+ * package knew about meshes is now `stripRequestHeaders`, which any caller
13
+ * uses for whatever its own system treats as proven identity.
14
+ */
15
+ export { type FetchHandler, MARKER, type Route, routeTable, type RouteTableInit, type Upstream, urlUpstream, type UrlUpstreamInit, } from "./proxy.js";
16
+ export { matchRoute, type ProxyRoute, upstreamUrl } from "./routes.js";
17
+ export { assertNoSecrets, rehydrate, type RehydrateInit, type RouteStore, SecretNotPersistableError, type StoredRoute, toStoredRoute, } from "./store.js";
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,KAAK,YAAY,EACjB,MAAM,EACN,KAAK,KAAK,EACV,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,eAAe,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,EACL,eAAe,EACf,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,yBAAyB,EACzB,KAAK,WAAW,EAChB,aAAa,GACd,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,133 @@
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
+ //#region src/proxy.ts
29
+ /** Marks this layer's OWN responses, so they are never mistaken for an upstream's. Same header the proxy already uses. */
30
+ const MARKER = "x-webrun-proxy";
31
+ /**
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
+ * Re-issue the request to a URL — the "reverse proxy" half.
74
+ *
75
+ * THE HYGIENE IS HERE AND NOT IN THE TABLE, because it applies only when a
76
+ * request LEAVES the mesh. Calling a local handler must not strip the caller's
77
+ * identity; re-issuing to a third party must.
78
+ */
79
+ function urlUpstream(init) {
80
+ const doFetch = init.fetchImpl ?? globalThis.fetch;
81
+ return async (request) => {
82
+ const from = new URL(request.url);
83
+ const target = new URL(`.${from.pathname}${from.search}`, ensureSlash(init.base));
84
+ const headers = new Headers(request.headers);
85
+ headers.delete("authorization");
86
+ for (const name of init.stripRequestHeaders ?? []) headers.delete(name);
87
+ for (const hop of HOP_BY_HOP) headers.delete(hop);
88
+ if (init.via != null) headers.set("via", init.via);
89
+ for (const [name, value] of Object.entries(init.headers ?? {})) headers.set(name, value);
90
+ for (const [name, value] of Object.entries(init.credential?.() ?? {})) headers.set(name, value);
91
+ const outbound = new Request(target, {
92
+ method: request.method,
93
+ headers,
94
+ body: request.body,
95
+ ...request.body != null ? { duplex: "half" } : {},
96
+ signal: request.signal,
97
+ redirect: "manual"
98
+ });
99
+ try {
100
+ const upstream = await doFetch(outbound);
101
+ if (upstream.type === "opaqueredirect" || upstream.status === 0) return new Response("upstream redirected; the proxy does not follow redirects", {
102
+ status: 502,
103
+ headers: { [MARKER]: "upstream-redirect" }
104
+ });
105
+ return new Response(upstream.body, {
106
+ status: upstream.status,
107
+ statusText: upstream.statusText,
108
+ headers: upstream.headers
109
+ });
110
+ } catch (err) {
111
+ const message = err instanceof Error ? err.message : String(err);
112
+ return new Response(`upstream ${init.base} could not be reached: ${message}\nIf the upstream is up, it most likely does not permit cross-origin requests.`, {
113
+ status: 502,
114
+ headers: { [MARKER]: "upstream-unreachable" }
115
+ });
116
+ }
117
+ };
118
+ }
119
+ const HOP_BY_HOP = [
120
+ "connection",
121
+ "keep-alive",
122
+ "proxy-authenticate",
123
+ "proxy-authorization",
124
+ "te",
125
+ "trailer",
126
+ "transfer-encoding",
127
+ "upgrade"
128
+ ];
129
+ function ensureSlash(base) {
130
+ return base.endsWith("/") ? base : `${base}/`;
131
+ }
132
+ //#endregion
133
+ export { MARKER, SecretNotPersistableError, assertNoSecrets, matchRoute, rehydrate, routeTable, toStoredRoute, upstreamUrl, urlUpstream };
package/dist/node.d.ts ADDED
@@ -0,0 +1,11 @@
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
@@ -0,0 +1 @@
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 ADDED
@@ -0,0 +1,33 @@
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 };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The candidate `expose` package: ONE route table, two kinds of upstream.
3
+ *
4
+ * THE CLAIM THIS TESTS. "Reverse proxy" and "expose a local service" are the
5
+ * same mechanism — match a prefix, rewrite the path, stream the result — and
6
+ * differ only in the last step: an in-process handler is CALLED, a URL
7
+ * upstream is RE-ISSUED. If that is true, `routeTable` below serves both and
8
+ * the two features are one package.
9
+ *
10
+ * Everything except the upstream kind is lifted from `services/proxy.ts` and
11
+ * `services/proxy-routes.ts`, which already run in the mesh: longest-prefix
12
+ * matching on segment boundaries, the listing at the mount root, the marker
13
+ * header, the `authorization` rule, route headers applied last, and the body
14
+ * handed on unread.
15
+ *
16
+ * TWO DEFECTS OF THE PROVEN CODE ARE FIXED HERE, both measured in the tests:
17
+ *
18
+ * 1. `redirect: "manual"` makes a browser return an opaque response whose
19
+ * status is 0, and `new Response(body, { status: 0 })` THROWS — inside
20
+ * the try, so a redirecting upstream is reported as
21
+ * `502 upstream-unreachable`. `urlUpstream` handles redirects explicitly.
22
+ * 2. The outbound request carried no `signal`, so an aborted caller left the
23
+ * upstream call running. It is forwarded.
24
+ */
25
+ /**
26
+ * A handler, in the only shape this package needs.
27
+ *
28
+ * Declared here rather than imported: it is one line, and depending on another
29
+ * package for it would give a proxy a dependency it has no other use for.
30
+ */
31
+ export type FetchHandler = (request: Request) => Promise<Response>;
32
+ /** Marks this layer's OWN responses, so they are never mistaken for an upstream's. Same header the proxy already uses. */
33
+ export declare const MARKER = "x-webrun-proxy";
34
+ /**
35
+ * An upstream is just a handler. That is the whole merge: a local service, a
36
+ * remote origin and another peer are all `(Request) => Promise<Response>`.
37
+ */
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
+ export interface UrlUpstreamInit {
58
+ /**
59
+ * Request headers to drop before re-issuing upstream, beyond the ones this
60
+ * proxy always drops (`authorization` and the hop-by-hop set).
61
+ *
62
+ * For anything the surrounding system treats as proven identity and that a
63
+ * third-party origin has no business seeing.
64
+ */
65
+ stripRequestHeaders?: readonly string[];
66
+ /** Origin (and optional base path) every request is re-issued against. */
67
+ base: string;
68
+ /** Static headers for this destination, applied after the caller's. */
69
+ headers?: Record<string, string>;
70
+ /** A credential for this destination, read per request so it need not be held in the route. */
71
+ credential?: () => Record<string, string> | undefined;
72
+ /** Injected by tests; defaults to the platform's. */
73
+ fetchImpl?: typeof fetch;
74
+ /** `Via` value; omitted, no `Via` is added. */
75
+ via?: string;
76
+ }
77
+ /**
78
+ * Re-issue the request to a URL — the "reverse proxy" half.
79
+ *
80
+ * THE HYGIENE IS HERE AND NOT IN THE TABLE, because it applies only when a
81
+ * request LEAVES the mesh. Calling a local handler must not strip the caller's
82
+ * identity; re-issuing to a third party must.
83
+ */
84
+ export declare function urlUpstream(init: UrlUpstreamInit): Upstream;
85
+ //# sourceMappingURL=proxy.d.ts.map
@@ -0,0 +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;AAGnE,0HAA0H;AAC1H,eAAO,MAAM,MAAM,mBAAmB,CAAC;AAEvC;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,YAAY,CAAC;AAEpC,MAAM,WAAW,KAAK;IACpB,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;IACf,sFAAsF;IACtF,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,QAAQ,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,MAAM,EAAE,MAAM,SAAS,KAAK,EAAE,CAAC;IAC/B,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,YAAY,CAyD7D;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;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,CA6D3D"}
@@ -0,0 +1,31 @@
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
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,45 @@
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 };
@@ -0,0 +1,72 @@
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
@@ -0,0 +1 @@
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/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@statewalker/webrun-http-proxy",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A reverse proxy as a fetch handler: one route table, two kinds of upstream — a local handler or a remote origin.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+ssh://git@github.com/statewalker/webrun-wire.git",
10
+ "directory": "packages/webrun-http-proxy"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
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
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src"
31
+ ],
32
+ "sideEffects": false,
33
+ "dependencies": {},
34
+ "devDependencies": {
35
+ "@types/node": "^26.2.0",
36
+ "rimraf": "^6.1.3",
37
+ "rolldown": "^1.2.4",
38
+ "typescript": "^7.0.2",
39
+ "vitest": "^4.1.10"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
46
+ "test": "vitest run",
47
+ "typecheck": "tsc -p tsconfig.json --noEmit",
48
+ "lint": "biome check src tests"
49
+ }
50
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,33 @@
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/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A reverse proxy as a fetch handler.
3
+ *
4
+ * "Reverse proxy" and "expose a local app" are ONE MECHANISM, and twelve
5
+ * scenarios establish it: only the last step differs — a local handler is
6
+ * *called*, a URL upstream is *re-issued*. Matching, rewriting, the listing,
7
+ * the marker header and streaming are shared.
8
+ *
9
+ * EXTRACTED FROM `@statewalker/httpeers-expose`, where it was a mesh concept
10
+ * by accident of where it was written. Nothing in it is about peers: it moves
11
+ * a `Request` to an upstream and a `Response` back. The one place the old
12
+ * package knew about meshes is now `stripRequestHeaders`, which any caller
13
+ * uses for whatever its own system treats as proven identity.
14
+ */
15
+
16
+ export {
17
+ type FetchHandler,
18
+ MARKER,
19
+ type Route,
20
+ routeTable,
21
+ type RouteTableInit,
22
+ type Upstream,
23
+ urlUpstream,
24
+ type UrlUpstreamInit,
25
+ } 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/node.ts ADDED
@@ -0,0 +1,34 @@
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/proxy.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The candidate `expose` package: ONE route table, two kinds of upstream.
3
+ *
4
+ * THE CLAIM THIS TESTS. "Reverse proxy" and "expose a local service" are the
5
+ * same mechanism — match a prefix, rewrite the path, stream the result — and
6
+ * differ only in the last step: an in-process handler is CALLED, a URL
7
+ * upstream is RE-ISSUED. If that is true, `routeTable` below serves both and
8
+ * the two features are one package.
9
+ *
10
+ * Everything except the upstream kind is lifted from `services/proxy.ts` and
11
+ * `services/proxy-routes.ts`, which already run in the mesh: longest-prefix
12
+ * matching on segment boundaries, the listing at the mount root, the marker
13
+ * header, the `authorization` rule, route headers applied last, and the body
14
+ * handed on unread.
15
+ *
16
+ * TWO DEFECTS OF THE PROVEN CODE ARE FIXED HERE, both measured in the tests:
17
+ *
18
+ * 1. `redirect: "manual"` makes a browser return an opaque response whose
19
+ * status is 0, and `new Response(body, { status: 0 })` THROWS — inside
20
+ * the try, so a redirecting upstream is reported as
21
+ * `502 upstream-unreachable`. `urlUpstream` handles redirects explicitly.
22
+ * 2. The outbound request carried no `signal`, so an aborted caller left the
23
+ * upstream call running. It is forwarded.
24
+ */
25
+
26
+ /**
27
+ * A handler, in the only shape this package needs.
28
+ *
29
+ * Declared here rather than imported: it is one line, and depending on another
30
+ * package for it would give a proxy a dependency it has no other use for.
31
+ */
32
+ export type FetchHandler = (request: Request) => Promise<Response>;
33
+ import { matchRoute } from "./routes.js";
34
+
35
+ /** Marks this layer's OWN responses, so they are never mistaken for an upstream's. Same header the proxy already uses. */
36
+ export const MARKER = "x-webrun-proxy";
37
+
38
+ /**
39
+ * An upstream is just a handler. That is the whole merge: a local service, a
40
+ * remote origin and another peer are all `(Request) => Promise<Response>`.
41
+ */
42
+ export type Upstream = FetchHandler;
43
+
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
+ export interface UrlUpstreamInit {
123
+ /**
124
+ * Request headers to drop before re-issuing upstream, beyond the ones this
125
+ * proxy always drops (`authorization` and the hop-by-hop set).
126
+ *
127
+ * For anything the surrounding system treats as proven identity and that a
128
+ * third-party origin has no business seeing.
129
+ */
130
+ stripRequestHeaders?: readonly string[];
131
+
132
+ /** Origin (and optional base path) every request is re-issued against. */
133
+ base: string;
134
+ /** Static headers for this destination, applied after the caller's. */
135
+ headers?: Record<string, string>;
136
+ /** A credential for this destination, read per request so it need not be held in the route. */
137
+ credential?: () => Record<string, string> | undefined;
138
+ /** Injected by tests; defaults to the platform's. */
139
+ fetchImpl?: typeof fetch;
140
+ /** `Via` value; omitted, no `Via` is added. */
141
+ via?: string;
142
+ }
143
+
144
+ /**
145
+ * Re-issue the request to a URL — the "reverse proxy" half.
146
+ *
147
+ * THE HYGIENE IS HERE AND NOT IN THE TABLE, because it applies only when a
148
+ * request LEAVES the mesh. Calling a local handler must not strip the caller's
149
+ * identity; re-issuing to a third party must.
150
+ */
151
+ export function urlUpstream(init: UrlUpstreamInit): Upstream {
152
+ const doFetch = init.fetchImpl ?? globalThis.fetch;
153
+
154
+ return async (request: Request): Promise<Response> => {
155
+ const from = new URL(request.url);
156
+ const target = new URL(`.${from.pathname}${from.search}`, ensureSlash(init.base));
157
+
158
+ const headers = new Headers(request.headers);
159
+ // The mesh's own credential is consumed by this hop, the way
160
+ // `Proxy-Authorization` is consumed by the proxy it names. Forwarding it
161
+ // handed mesh tokens to third parties (an upstream echoed one back) and
162
+ // turned every call into a preflighted one.
163
+ headers.delete("authorization");
164
+ // WHATEVER ELSE THE CALLER'S SYSTEM TREATS AS IDENTITY. This proxy knows
165
+ // nothing about the caller's trust model, so the names are configuration:
166
+ // httpeers passes its proven-peer header here, because re-issuing to a
167
+ // third party must not tell an outside origin which mesh peer called.
168
+ // Without this a caller would have to post-process the request, by which
169
+ // point it has already gone.
170
+ for (const name of init.stripRequestHeaders ?? []) headers.delete(name);
171
+ // Hop-by-hop headers (RFC 9110 §7.6.1) are not the upstream's business.
172
+ for (const hop of HOP_BY_HOP) headers.delete(hop);
173
+ if (init.via != null) headers.set("via", init.via);
174
+ // Operator configuration last, so it wins over anything a caller sent.
175
+ for (const [name, value] of Object.entries(init.headers ?? {})) headers.set(name, value);
176
+ for (const [name, value] of Object.entries(init.credential?.() ?? {})) headers.set(name, value);
177
+
178
+ const outbound = new Request(target, {
179
+ method: request.method,
180
+ headers,
181
+ body: request.body,
182
+ ...(request.body != null ? { duplex: "half" as const } : {}),
183
+ signal: request.signal,
184
+ redirect: "manual",
185
+ });
186
+
187
+ try {
188
+ const upstream = await doFetch(outbound);
189
+ // AN OPAQUE REDIRECT HAS STATUS 0, and `new Response(body, {status: 0})`
190
+ // throws — which is how the shipping proxy reports a redirecting
191
+ // upstream as `502 upstream-unreachable`. Reported as what it is.
192
+ if (upstream.type === "opaqueredirect" || upstream.status === 0) {
193
+ return new Response("upstream redirected; the proxy does not follow redirects", {
194
+ status: 502,
195
+ headers: { [MARKER]: "upstream-redirect" },
196
+ });
197
+ }
198
+ return new Response(upstream.body, {
199
+ status: upstream.status,
200
+ statusText: upstream.statusText,
201
+ headers: upstream.headers,
202
+ });
203
+ } catch (err) {
204
+ const message = err instanceof Error ? err.message : String(err);
205
+ return new Response(
206
+ `upstream ${init.base} could not be reached: ${message}\n` +
207
+ "If the upstream is up, it most likely does not permit cross-origin requests.",
208
+ { status: 502, headers: { [MARKER]: "upstream-unreachable" } },
209
+ );
210
+ }
211
+ };
212
+ }
213
+
214
+ const HOP_BY_HOP = [
215
+ "connection",
216
+ "keep-alive",
217
+ "proxy-authenticate",
218
+ "proxy-authorization",
219
+ "te",
220
+ "trailer",
221
+ "transfer-encoding",
222
+ "upgrade",
223
+ ];
224
+
225
+ function ensureSlash(base: string): string {
226
+ return base.endsWith("/") ? base : `${base}/`;
227
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,48 @@
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 ADDED
@@ -0,0 +1,111 @@
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
+ }