@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 CHANGED
@@ -1,80 +1,69 @@
1
1
  # @statewalker/webrun-http-proxy
2
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.
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 { routeTable, urlUpstream } from "@statewalker/webrun-http-proxy";
8
+ import { Hono } from "hono";
9
+ import { urlUpstream } from "@statewalker/webrun-http-proxy";
23
10
 
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
- ],
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
- `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
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
- - 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.
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
- ## Entry points
75
+ ## No platform
87
76
 
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**.
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: 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.
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
- * A reverse proxy as a fetch handler.
2
+ * Re-issuing a request to an outside origin, safely. That is the whole
3
+ * package.
3
4
  *
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.
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
- * 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.
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 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";
32
+ export { type FetchHandler, MARKER, type Upstream, type UrlUpstreamInit, urlUpstream, } from "./proxy.js";
18
33
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
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, SecretNotPersistableError, assertNoSecrets, matchRoute, rehydrate, routeTable, toStoredRoute, upstreamUrl, urlUpstream };
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 `authorization` rule, route headers applied last, and the body
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 ones this
60
- * proxy always drops (`authorization` and the hop-by-hop set).
41
+ * Request headers to drop before re-issuing upstream, beyond the hop-by-hop
42
+ * set this proxy always drops.
61
43
  *
62
- * For anything the surrounding system treats as proven identity and that a
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. */
@@ -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;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"}
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.1.0",
3
+ "version": "0.2.0",
4
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.",
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
- * A reverse proxy as a fetch handler.
2
+ * Re-issuing a request to an outside origin, safely. That is the whole
3
+ * package.
3
4
  *
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.
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
- * 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.
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 `authorization` rule, route headers applied last, and the body
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 ones this
125
- * proxy always drops (`authorization` and the hop-by-hop set).
45
+ * Request headers to drop before re-issuing upstream, beyond the hop-by-hop
46
+ * set this proxy always drops.
126
47
  *
127
- * For anything the surrounding system treats as proven identity and that a
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
- // 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.
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
@@ -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
@@ -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
@@ -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"}
@@ -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
@@ -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
- }