@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 +21 -0
- package/README.md +113 -0
- package/dist/browser.d.ts +11 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +30 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/node.d.ts +11 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +33 -0
- package/dist/proxy.d.ts +85 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/routes.d.ts +31 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/store-BwcCz3M9.js +45 -0
- package/dist/store.d.ts +72 -0
- package/dist/store.d.ts.map +1 -0
- package/package.json +50 -0
- package/src/browser.ts +33 -0
- package/src/index.ts +35 -0
- package/src/node.ts +34 -0
- package/src/proxy.ts +227 -0
- package/src/routes.ts +48 -0
- package/src/store.ts +111 -0
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"}
|
package/dist/browser.js
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|
package/dist/proxy.d.ts
ADDED
|
@@ -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"}
|
package/dist/routes.d.ts
ADDED
|
@@ -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 };
|
package/dist/store.d.ts
ADDED
|
@@ -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
|
+
}
|