@somewhatintelligent/bouncer 0.0.1-beta.1783705886.297c5be
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/package.json +35 -0
- package/src/env.ts +39 -0
- package/src/envelope.ts +29 -0
- package/src/index.ts +228 -0
- package/src/proxy.ts +804 -0
- package/src/routes.ts +328 -0
- package/src/session.ts +70 -0
- package/src/version.gen.ts +7 -0
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
import { getRequestLog } from "@somewhatintelligent/kit/log";
|
|
2
|
+
import { getRequestId } from "@somewhatintelligent/kit/request-context";
|
|
3
|
+
import { PLATFORM_HEADERS, stampPlatformHeaders, type EnvelopeActor } from "@somewhatintelligent/auth";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stamp the platform's privileged header contract on a request before
|
|
7
|
+
* forwarding upstream. Delegates to the shared `stampPlatformHeaders`
|
|
8
|
+
* helper so dev-direct app workers can apply the same contract to
|
|
9
|
+
* self-minted envelopes (see `packages/kit/src/react-start/dev-envelope.ts`).
|
|
10
|
+
*
|
|
11
|
+
* `caller` is always `"bouncer"` at this boundary; the request id comes
|
|
12
|
+
* from the bouncer-side request-context ALS opened at the entry handler.
|
|
13
|
+
*/
|
|
14
|
+
export function stampUpstreamHeaders(
|
|
15
|
+
request: Request,
|
|
16
|
+
envelope: string,
|
|
17
|
+
actor: EnvelopeActor | null,
|
|
18
|
+
): Request {
|
|
19
|
+
return stampPlatformHeaders(request, {
|
|
20
|
+
envelope,
|
|
21
|
+
actor,
|
|
22
|
+
requestId: getRequestId() ?? "",
|
|
23
|
+
caller: "bouncer",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Strip `x-platform-att` from an upstream response before it leaves
|
|
29
|
+
* bouncer. The envelope is internal-only; it must never leak to a browser.
|
|
30
|
+
* Apps shouldn't echo it (they don't, in practice), but defense in depth.
|
|
31
|
+
*/
|
|
32
|
+
export function stripPlatformResponseHeaders(response: Response): Response {
|
|
33
|
+
if (!response.headers.has(PLATFORM_HEADERS.att)) return response;
|
|
34
|
+
const headers = new Headers(response.headers);
|
|
35
|
+
headers.delete(PLATFORM_HEADERS.att);
|
|
36
|
+
return new Response(response.body, {
|
|
37
|
+
status: response.status,
|
|
38
|
+
statusText: response.statusText,
|
|
39
|
+
headers,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Passthrough dispatch — bouncer is a transparent reverse proxy.
|
|
45
|
+
*
|
|
46
|
+
* The upstream owns its host fully; bouncer forwards the request as-is,
|
|
47
|
+
* captures observability fields (upstream status, content-type,
|
|
48
|
+
* redirect_location on 3xx), and returns the response verbatim. No URL
|
|
49
|
+
* rewriting, no asset rewriting, no cookie path scoping, no Location
|
|
50
|
+
* header rewriting. Header stamping happens upstream of this in
|
|
51
|
+
* `stampUpstreamHeaders`.
|
|
52
|
+
*
|
|
53
|
+
* Used for mounts whose upstream is mount-aware (knows it owns the mount
|
|
54
|
+
* and emits its own canonical URLs) — guestlist's `/api` mount fits this
|
|
55
|
+
* contract. Compare `handleMountedApp` below, used for mount-naive upstreams
|
|
56
|
+
* (e.g. identity's `/account` vmf mount) that need path/asset/redirect/
|
|
57
|
+
* cookie rewriting.
|
|
58
|
+
*/
|
|
59
|
+
export async function handlePassthrough(request: Request, upstream: Fetcher): Promise<Response> {
|
|
60
|
+
const log = getRequestLog();
|
|
61
|
+
const resp = await upstream.fetch(request);
|
|
62
|
+
log?.add({
|
|
63
|
+
upstream_status: resp.status,
|
|
64
|
+
upstream_content_type: resp.headers.get("content-type") ?? undefined,
|
|
65
|
+
});
|
|
66
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
67
|
+
log?.add({ redirect_location: resp.headers.get("location") ?? undefined });
|
|
68
|
+
}
|
|
69
|
+
return resp;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Upstream-app proxy + URL/cookie/redirect rewriting machinery.
|
|
74
|
+
*
|
|
75
|
+
* The host-aware `RouteConfig`/`compileRoutes` surface lives in `./routes.ts`;
|
|
76
|
+
* the entry `fetch` handler + dispatch lives in `./index.ts`. This file owns
|
|
77
|
+
* only `handleMountedApp` and its supporting utilities. `env` is threaded
|
|
78
|
+
* explicitly through `buildAssetPrefixes(envObj)` from the entry handler.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Default asset path prefixes that trigger URL rewriting in HTML and CSS.
|
|
83
|
+
*
|
|
84
|
+
* These defaults are always included. Additional custom prefixes can be added
|
|
85
|
+
* via the ASSET_PREFIXES environment variable (JSON array of strings).
|
|
86
|
+
*/
|
|
87
|
+
const DEFAULT_ASSET_PREFIXES = ["/assets/", "/static/", "/build/", "/_astro/", "/_next", "/fonts/"];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Builds the complete list of asset prefixes by merging defaults with custom prefixes from environment.
|
|
91
|
+
*
|
|
92
|
+
* Reads the ASSET_PREFIXES environment variable (optional JSON array) and merges it with
|
|
93
|
+
* the default prefixes. Duplicates are removed, and all prefixes are normalized to start with "/" and end with "/".
|
|
94
|
+
*
|
|
95
|
+
* @param env - Worker env; `ASSET_PREFIXES` is optional, JSON-encoded string.
|
|
96
|
+
* @returns Array of normalized asset prefixes
|
|
97
|
+
*/
|
|
98
|
+
export function buildAssetPrefixes(env: object): string[] {
|
|
99
|
+
const defaults = [...DEFAULT_ASSET_PREFIXES];
|
|
100
|
+
|
|
101
|
+
// Custom prefixes are an optional, JSON-encoded var. `Reflect.get` keeps
|
|
102
|
+
// the dynamic read honest — no cast, no widening of the worker Env type.
|
|
103
|
+
const raw: unknown = Reflect.get(env, "ASSET_PREFIXES");
|
|
104
|
+
if (typeof raw === "string") {
|
|
105
|
+
try {
|
|
106
|
+
const custom: unknown = JSON.parse(raw);
|
|
107
|
+
if (Array.isArray(custom)) {
|
|
108
|
+
// Normalize custom prefixes: ensure they start and end with "/"
|
|
109
|
+
const normalized = custom
|
|
110
|
+
.filter((p): p is string => typeof p === "string" && p.trim() !== "")
|
|
111
|
+
.map((p) => {
|
|
112
|
+
let normalized = p.trim();
|
|
113
|
+
if (!normalized.startsWith("/")) normalized = "/" + normalized;
|
|
114
|
+
if (!normalized.endsWith("/")) normalized = normalized + "/";
|
|
115
|
+
return normalized;
|
|
116
|
+
});
|
|
117
|
+
// Merge with defaults and remove duplicates
|
|
118
|
+
const all = [...defaults, ...normalized];
|
|
119
|
+
return [...new Set(all)]; // Remove duplicates using Set
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// If parsing fails, just use defaults (don't throw - fail gracefully)
|
|
123
|
+
console.warn(
|
|
124
|
+
`Failed to parse ASSET_PREFIXES environment variable: ${e instanceof Error ? e.message : String(e)}. Using defaults only.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return defaults;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ----------------------------- utilities ----------------------------- */
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Checks if a path starts with any of the supported asset prefixes.
|
|
136
|
+
* Used to determine if a URL should be rewritten.
|
|
137
|
+
*
|
|
138
|
+
* @param path - Path to check
|
|
139
|
+
* @param assetPrefixes - Array of asset prefixes to check against
|
|
140
|
+
* @returns True if path starts with any of the prefixes
|
|
141
|
+
*/
|
|
142
|
+
function hasAssetPrefix(path: string, assetPrefixes: string[]): boolean {
|
|
143
|
+
return assetPrefixes.some((p) => path.startsWith(p));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Normalizes a path string to a consistent format:
|
|
148
|
+
* - Ensures it starts with "/"
|
|
149
|
+
* - Removes trailing "/" (except for root "/")
|
|
150
|
+
*
|
|
151
|
+
* Examples:
|
|
152
|
+
* - "docs" → "/docs"
|
|
153
|
+
* - "/docs/" → "/docs"
|
|
154
|
+
* - "/" → "/"
|
|
155
|
+
*/
|
|
156
|
+
export function normalizePath(path: string): string {
|
|
157
|
+
if (!path.startsWith("/")) path = "/" + path;
|
|
158
|
+
if (path !== "/" && path.endsWith("/")) path = path.slice(0, -1);
|
|
159
|
+
return path;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Route-compile helpers (escapeRegexLiteral, unescapePathLiterals,
|
|
163
|
+
// segmentToRegex, computeBaseSpecificity, compilePathExpr) live in
|
|
164
|
+
// `./routes.ts`.
|
|
165
|
+
|
|
166
|
+
/* ---------------------- HTML rewriting + injection ---------------------- */
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* HTMLRewriter handler that rewrites asset URLs in HTML element attributes.
|
|
170
|
+
*
|
|
171
|
+
* Processes elements and rewrites absolute paths in various attributes (href, src, etc.)
|
|
172
|
+
* that match asset prefixes, prepending the mount prefix to maintain correct asset resolution.
|
|
173
|
+
*
|
|
174
|
+
* Special handling:
|
|
175
|
+
* - Root mount ("/") is treated specially - paths are not modified
|
|
176
|
+
* - Only rewrites absolute paths (starting with "/")
|
|
177
|
+
* - Only rewrites paths matching asset prefixes (unless it's a favicon link)
|
|
178
|
+
* - Skips paths already scoped to the mount
|
|
179
|
+
*/
|
|
180
|
+
class AllAttributesRewriter {
|
|
181
|
+
constructor(
|
|
182
|
+
private mount: string,
|
|
183
|
+
private assetPrefixes: string[],
|
|
184
|
+
) {
|
|
185
|
+
this.mount = normalizePath(mount);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Prepends the mount prefix to a path, handling root mount ("/") specially.
|
|
190
|
+
* When mount is "/", returns the path unchanged (treating root as no prefix).
|
|
191
|
+
*/
|
|
192
|
+
private prependMount(path: string): string {
|
|
193
|
+
return this.mount === "/" ? path : this.mount + path;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Checks if a path is already scoped to the mount prefix.
|
|
198
|
+
* When mount is "/", all absolute paths are considered scoped (no rewriting needed).
|
|
199
|
+
*/
|
|
200
|
+
private isScopedToMount(path: string): boolean {
|
|
201
|
+
// When mount is "/", all absolute paths are already at root, so they're "scoped"
|
|
202
|
+
// and don't need rewriting (prependMount would return them unchanged anyway)
|
|
203
|
+
if (this.mount === "/") return true;
|
|
204
|
+
return path.startsWith(this.mount + "/");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
element(el: Element) {
|
|
208
|
+
const tagName = el.tagName?.toLowerCase();
|
|
209
|
+
|
|
210
|
+
// Favicon/link icon rewrite even if it doesn't match asset prefixes (always rewrite icons).
|
|
211
|
+
if (tagName === "link") {
|
|
212
|
+
const rel = el.getAttribute("rel")?.toLowerCase();
|
|
213
|
+
const href = el.getAttribute("href");
|
|
214
|
+
if (rel && (rel.includes("icon") || rel.includes("shortcut")) && href) {
|
|
215
|
+
if (href.startsWith("/") && !this.isScopedToMount(href)) {
|
|
216
|
+
el.setAttribute("href", this.prependMount(href));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const commonAttrs = [
|
|
222
|
+
"href",
|
|
223
|
+
"src",
|
|
224
|
+
"poster",
|
|
225
|
+
"content",
|
|
226
|
+
"action",
|
|
227
|
+
"cite",
|
|
228
|
+
"formaction",
|
|
229
|
+
"manifest",
|
|
230
|
+
"ping",
|
|
231
|
+
"archive",
|
|
232
|
+
"code",
|
|
233
|
+
"codebase",
|
|
234
|
+
"data",
|
|
235
|
+
"url",
|
|
236
|
+
"srcset",
|
|
237
|
+
|
|
238
|
+
// data attrs
|
|
239
|
+
"data-src",
|
|
240
|
+
"data-href",
|
|
241
|
+
"data-url",
|
|
242
|
+
"data-srcset",
|
|
243
|
+
"data-background",
|
|
244
|
+
"data-image",
|
|
245
|
+
"data-link",
|
|
246
|
+
"data-poster",
|
|
247
|
+
"data-video",
|
|
248
|
+
"data-audio",
|
|
249
|
+
|
|
250
|
+
// framework-ish
|
|
251
|
+
"component-url",
|
|
252
|
+
"astro-component-url",
|
|
253
|
+
"sveltekit-url",
|
|
254
|
+
"renderer-url",
|
|
255
|
+
|
|
256
|
+
// misc
|
|
257
|
+
"background",
|
|
258
|
+
"xlink:href",
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
for (const attrName of commonAttrs) {
|
|
262
|
+
const val = el.getAttribute(attrName);
|
|
263
|
+
if (!val) continue;
|
|
264
|
+
|
|
265
|
+
// srcset contains multiple URLs
|
|
266
|
+
if (attrName === "srcset") {
|
|
267
|
+
const rewritten = val
|
|
268
|
+
.split(",")
|
|
269
|
+
.map((src) => {
|
|
270
|
+
const trimmed = src.trim();
|
|
271
|
+
const parts = trimmed.split(/\s+/);
|
|
272
|
+
const url = parts[0] ?? "";
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
url.startsWith("/") &&
|
|
276
|
+
!this.isScopedToMount(url) &&
|
|
277
|
+
hasAssetPrefix(url, this.assetPrefixes)
|
|
278
|
+
) {
|
|
279
|
+
return this.prependMount(url) + (parts[1] ? " " + parts[1] : "");
|
|
280
|
+
}
|
|
281
|
+
return trimmed;
|
|
282
|
+
})
|
|
283
|
+
.join(", ");
|
|
284
|
+
|
|
285
|
+
if (rewritten !== val) el.setAttribute(attrName, rewritten);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// absolute-only
|
|
290
|
+
if (!val.startsWith("/")) continue;
|
|
291
|
+
|
|
292
|
+
// already scoped
|
|
293
|
+
if (this.isScopedToMount(val)) continue;
|
|
294
|
+
|
|
295
|
+
// asset-only
|
|
296
|
+
if (!hasAssetPrefix(val, this.assetPrefixes)) continue;
|
|
297
|
+
|
|
298
|
+
el.setAttribute(attrName, this.prependMount(val));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* HTMLRewriter handler that injects CSS for smooth view transitions.
|
|
305
|
+
*
|
|
306
|
+
* Injects CSS into the <head> element to enable browser-native view transitions
|
|
307
|
+
* when navigating between microfrontends. The CSS is only injected once per response.
|
|
308
|
+
*/
|
|
309
|
+
class SmoothTransitionsInjector {
|
|
310
|
+
private injected = false;
|
|
311
|
+
|
|
312
|
+
element(el: Element) {
|
|
313
|
+
if (this.injected) return;
|
|
314
|
+
this.injected = true;
|
|
315
|
+
|
|
316
|
+
const css = `@supports (view-transition-name: none) {
|
|
317
|
+
::view-transition-old(root),
|
|
318
|
+
::view-transition-new(root) {
|
|
319
|
+
animation-duration: 0.3s;
|
|
320
|
+
animation-timing-function: ease-in-out;
|
|
321
|
+
}
|
|
322
|
+
main { view-transition-name: main-content; }
|
|
323
|
+
nav { view-transition-name: navigation; }
|
|
324
|
+
}`;
|
|
325
|
+
|
|
326
|
+
el.append(`<style>${css}</style>`, { html: true });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* HTMLRewriter handler that announces the vmf mount to the hydrated client.
|
|
332
|
+
*
|
|
333
|
+
* vmf strips the mount server-side (apps serve at their own root, prefix-free)
|
|
334
|
+
* and rewrites HTTP-layer artifacts, but the one thing it cannot reach is the
|
|
335
|
+
* SPA router's client-side history: after hydration the browser URL carries
|
|
336
|
+
* the mount while the app's router thinks in root paths. This meta tag is the
|
|
337
|
+
* runtime contract that closes that gap — each mounted app's router reads
|
|
338
|
+
* `<meta name="si-mount">` at hydration and adopts it as its client basepath.
|
|
339
|
+
* No build-time configuration, correct for any mount, absent in dev-direct
|
|
340
|
+
* (no bouncer → no meta → basepath "/").
|
|
341
|
+
*/
|
|
342
|
+
class MountMetaInjector {
|
|
343
|
+
private injected = false;
|
|
344
|
+
|
|
345
|
+
constructor(private mount: string) {}
|
|
346
|
+
|
|
347
|
+
element(el: Element) {
|
|
348
|
+
if (this.injected || this.mount === "/" || this.mount === "") return;
|
|
349
|
+
this.injected = true;
|
|
350
|
+
el.append(`<meta name="si-mount" content="${this.mount}">`, { html: true });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* HTMLRewriter handler that injects speculation rules for prefetching routes.
|
|
356
|
+
*
|
|
357
|
+
* Injects a <script type="speculationrules"> element into the <head> to enable
|
|
358
|
+
* browser-native prefetching via the Speculation Rules API. This is more efficient
|
|
359
|
+
* than JavaScript-based fetching and respects user preferences.
|
|
360
|
+
*
|
|
361
|
+
* The script is only injected once per response.
|
|
362
|
+
*/
|
|
363
|
+
class SpeculationRulesInjector {
|
|
364
|
+
private injected = false;
|
|
365
|
+
private rulesJson: string;
|
|
366
|
+
|
|
367
|
+
constructor(preloadMounts: string[]) {
|
|
368
|
+
this.rulesJson = generateSpeculationRules(preloadMounts);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
element(el: Element) {
|
|
372
|
+
if (this.injected) return;
|
|
373
|
+
this.injected = true;
|
|
374
|
+
|
|
375
|
+
// Inject speculation rules script into head
|
|
376
|
+
// Note: CSP may need 'inline-speculation-rules' source or hash/nonce
|
|
377
|
+
el.append(`<script type="speculationrules">${this.rulesJson}</script>`, {
|
|
378
|
+
html: true,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* HTMLRewriter handler that injects a fallback preload script for non-Chromium browsers.
|
|
385
|
+
*
|
|
386
|
+
* Injects a <script> tag that loads the `__mf-preload.js` script, which uses fetch()
|
|
387
|
+
* to preload routes. This is used as a fallback for browsers that don't support
|
|
388
|
+
* the Speculation Rules API (Firefox, Safari).
|
|
389
|
+
*
|
|
390
|
+
* The script is only injected once per response, before </body>.
|
|
391
|
+
*/
|
|
392
|
+
class PreloadScriptInjector {
|
|
393
|
+
private injected = false;
|
|
394
|
+
private scriptPath: string;
|
|
395
|
+
|
|
396
|
+
constructor(mountActual: string) {
|
|
397
|
+
// Special handling for root mount to avoid "//__mf-preload.js"
|
|
398
|
+
this.scriptPath = mountActual === "/" ? "/__mf-preload.js" : `${mountActual}/__mf-preload.js`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
element(el: Element) {
|
|
402
|
+
if (this.injected) return;
|
|
403
|
+
this.injected = true;
|
|
404
|
+
|
|
405
|
+
// Inject script tag before closing body tag
|
|
406
|
+
const tag = `<script src="${this.scriptPath}" defer></script>`;
|
|
407
|
+
el.append(tag, { html: true });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* ----------------------- headers / redirects / cookies ----------------------- */
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Creates a copy of headers with transformation-incompatible headers removed.
|
|
415
|
+
*
|
|
416
|
+
* Removes headers that become invalid when response body is transformed:
|
|
417
|
+
* - content-length: Body size changes after rewriting
|
|
418
|
+
* - etag: Content changes, so ETag is invalid
|
|
419
|
+
* - content-encoding: Compression is removed when reading body as text
|
|
420
|
+
*/
|
|
421
|
+
function cloneHeadersForTransform(original: Headers): Headers {
|
|
422
|
+
const headers = new Headers(original);
|
|
423
|
+
headers.delete("content-length");
|
|
424
|
+
headers.delete("etag");
|
|
425
|
+
headers.delete("content-encoding");
|
|
426
|
+
return headers;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Rewrites redirect Location headers to include the mount prefix.
|
|
431
|
+
*
|
|
432
|
+
* When an upstream service redirects to an absolute path on the same origin,
|
|
433
|
+
* the path is rewritten to include the mount prefix so the redirect points to
|
|
434
|
+
* the correct path within the mounted microfrontend.
|
|
435
|
+
*
|
|
436
|
+
* @param location - Original Location header value
|
|
437
|
+
* @param mount - Mount prefix to prepend (e.g., "/docs")
|
|
438
|
+
* @param requestUrl - Original request URL for resolving relative URLs
|
|
439
|
+
* @returns Rewritten Location header value
|
|
440
|
+
*/
|
|
441
|
+
function rewriteLocation(location: string, mount: string, requestUrl: URL): string {
|
|
442
|
+
mount = normalizePath(mount);
|
|
443
|
+
try {
|
|
444
|
+
const url = new URL(location, requestUrl.origin);
|
|
445
|
+
|
|
446
|
+
// Same-origin redirects: prepend the mount prefix (unless root) and emit
|
|
447
|
+
// a *relative* Location (`pathname + search + hash`) so the browser
|
|
448
|
+
// resolves it against the public origin it navigated from, rather than
|
|
449
|
+
// `requestUrl.origin` (workerd's local bind address under `wrangler dev`).
|
|
450
|
+
if (url.origin === requestUrl.origin && url.pathname.startsWith("/")) {
|
|
451
|
+
const newPath = mount === "/" ? url.pathname : mount + url.pathname;
|
|
452
|
+
return newPath + url.search + url.hash;
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
// ignore invalid URLs
|
|
456
|
+
}
|
|
457
|
+
return location;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Rewrites Set-Cookie headers to scope cookie paths to the mount prefix.
|
|
462
|
+
*
|
|
463
|
+
* Cookies with Path=/ are rewritten to Path=/mount/ to prevent cookie collisions
|
|
464
|
+
* between different microfrontends mounted at different paths.
|
|
465
|
+
*
|
|
466
|
+
* Uses Headers.getSetCookie() which is available in Cloudflare Workers runtime.
|
|
467
|
+
*
|
|
468
|
+
* @param headers - Headers object containing Set-Cookie headers
|
|
469
|
+
* @param mount - Mount prefix to use for cookie path (e.g., "/docs")
|
|
470
|
+
*/
|
|
471
|
+
function rewriteSetCookie(headers: Headers, mount: string) {
|
|
472
|
+
mount = normalizePath(mount);
|
|
473
|
+
|
|
474
|
+
const cookies = headers.getSetCookie();
|
|
475
|
+
if (cookies.length === 0) return;
|
|
476
|
+
|
|
477
|
+
headers.delete("Set-Cookie");
|
|
478
|
+
for (const cookie of cookies) {
|
|
479
|
+
if (/;\s*Path=\//i.test(cookie)) {
|
|
480
|
+
// If mount is "/", keep Path=/ (root)
|
|
481
|
+
const newPath = mount === "/" ? "/" : `${mount}/`;
|
|
482
|
+
headers.append("Set-Cookie", cookie.replace(/;\s*Path=\//i, `; Path=${newPath}`));
|
|
483
|
+
} else {
|
|
484
|
+
headers.append("Set-Cookie", cookie);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* --------------------------- preload script endpoint --------------------------- */
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Generates a preload script that fetches specified routes after DOM loads.
|
|
493
|
+
*
|
|
494
|
+
* This script is served at `${mount}/__mf-preload.js` and is injected into HTML
|
|
495
|
+
* responses as an external script tag. Using an external script is more CSP-friendly
|
|
496
|
+
* than inline JavaScript.
|
|
497
|
+
*
|
|
498
|
+
* The script preloads routes by making GET requests with same-origin credentials,
|
|
499
|
+
* helping to warm up routes for faster navigation.
|
|
500
|
+
*
|
|
501
|
+
* **Important:** Preload targets must be static mount roots (no dynamic parameters),
|
|
502
|
+
* otherwise we cannot determine the concrete mount paths to fetch.
|
|
503
|
+
*
|
|
504
|
+
* @param preloadMounts - Array of mount paths to preload (e.g., ["/app1", "/app2"])
|
|
505
|
+
* @returns Response containing the preload script
|
|
506
|
+
*/
|
|
507
|
+
function getPreloadScriptResponse(preloadMounts: string[]): Response {
|
|
508
|
+
const json = JSON.stringify(preloadMounts);
|
|
509
|
+
const js =
|
|
510
|
+
`(()=>{const routes=${json};` +
|
|
511
|
+
`const run=()=>{for(const p of routes){fetch(p,{method:"GET",credentials:"same-origin",cache:"default"}).catch(()=>{});}};` +
|
|
512
|
+
`if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",run,{once:true});}else{run();}` +
|
|
513
|
+
`})();`;
|
|
514
|
+
|
|
515
|
+
return new Response(js, {
|
|
516
|
+
status: 200,
|
|
517
|
+
headers: {
|
|
518
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
519
|
+
"cache-control": "public, max-age=300",
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/* --------------------------- speculation rules --------------------------- */
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Detects if the browser is Chromium-based (Chrome, Edge, etc.) from User-Agent.
|
|
528
|
+
*
|
|
529
|
+
* Chromium-based browsers support the Speculation Rules API, while others
|
|
530
|
+
* (Firefox, Safari) do not yet support it and need the fallback fetch script.
|
|
531
|
+
*
|
|
532
|
+
* @param userAgent - User-Agent header string from the request
|
|
533
|
+
* @returns True if the browser is Chromium-based
|
|
534
|
+
*/
|
|
535
|
+
function isChromiumBrowser(userAgent: string): boolean {
|
|
536
|
+
// Chromium-based browsers include: Chrome, Edge, Opera, Brave, etc.
|
|
537
|
+
// They typically have "Chrome" in the User-Agent (even Edge does)
|
|
538
|
+
// but not "Firefox" or "Safari" (without Chrome)
|
|
539
|
+
if (!userAgent) return false;
|
|
540
|
+
|
|
541
|
+
const ua = userAgent.toLowerCase();
|
|
542
|
+
// Check for Chromium indicators
|
|
543
|
+
const hasChrome = ua.includes("chrome");
|
|
544
|
+
const hasEdge = ua.includes("edg/"); // Edge uses "Edg/" not "Edge"
|
|
545
|
+
const hasOpera = ua.includes("opr/");
|
|
546
|
+
const hasBrave = ua.includes("brave");
|
|
547
|
+
|
|
548
|
+
// Exclude Firefox and Safari (which don't support Speculation Rules yet)
|
|
549
|
+
const isFirefox = ua.includes("firefox");
|
|
550
|
+
const isSafari = ua.includes("safari") && !ua.includes("chrome");
|
|
551
|
+
|
|
552
|
+
return (hasChrome || hasEdge || hasOpera || hasBrave) && !isFirefox && !isSafari;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Generates speculation rules JSON for prefetching routes.
|
|
557
|
+
*
|
|
558
|
+
* Uses the Speculation Rules API to enable browser-native prefetching of routes.
|
|
559
|
+
* This is more efficient than JavaScript-based fetching as it:
|
|
560
|
+
* - Respects user preferences (battery saver, data saver)
|
|
561
|
+
* - Works for cross-site navigations (with proper configuration)
|
|
562
|
+
* - Doesn't get blocked by Cache-Control headers
|
|
563
|
+
* - Automatically manages priority
|
|
564
|
+
* - Stores prefetched resources in a per-document in-memory cache
|
|
565
|
+
*
|
|
566
|
+
* For same-origin routes (which is the case for all microfrontend routes),
|
|
567
|
+
* we use simple prefetch rules without cross-origin requirements.
|
|
568
|
+
*
|
|
569
|
+
* @param preloadMounts - Array of mount paths to prefetch (e.g., ["/app1", "/app2"])
|
|
570
|
+
* @returns JSON string containing speculation rules
|
|
571
|
+
*/
|
|
572
|
+
function generateSpeculationRules(preloadMounts: string[]): string {
|
|
573
|
+
const rules = {
|
|
574
|
+
prefetch: [
|
|
575
|
+
{
|
|
576
|
+
urls: preloadMounts,
|
|
577
|
+
// For same-origin routes, we don't need requires or referrer_policy
|
|
578
|
+
// The browser will use same-origin credentials automatically
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
};
|
|
582
|
+
return JSON.stringify(rules);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* ------------------------------ main proxy handler ------------------------------ */
|
|
586
|
+
|
|
587
|
+
interface MountedAppOptions {
|
|
588
|
+
smoothTransitions?: boolean;
|
|
589
|
+
preloadStaticMounts?: string[];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Strips the matched mount prefix from the path before forwarding to upstream.
|
|
594
|
+
* The upstream service expects paths relative to its mount point.
|
|
595
|
+
* Example: /docs/about -> /about (when mount is /docs)
|
|
596
|
+
* If mount is "/" (root), the path passes through as-is without stripping.
|
|
597
|
+
*/
|
|
598
|
+
function stripMountPrefix(forwardUrl: URL, mountActual: string): void {
|
|
599
|
+
if (mountActual === "/") return;
|
|
600
|
+
if (forwardUrl.pathname === mountActual) {
|
|
601
|
+
forwardUrl.pathname = "/";
|
|
602
|
+
} else if (forwardUrl.pathname.startsWith(mountActual + "/")) {
|
|
603
|
+
forwardUrl.pathname = forwardUrl.pathname.slice(mountActual.length) || "/";
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Records upstream status/content-type on the request log; on 5xx also
|
|
609
|
+
* captures a body preview (from a clone — the original stays readable).
|
|
610
|
+
*/
|
|
611
|
+
async function logUpstreamResponse(upstreamResp: Response, contentType: string): Promise<void> {
|
|
612
|
+
const log = getRequestLog();
|
|
613
|
+
log?.add({
|
|
614
|
+
upstream_status: upstreamResp.status,
|
|
615
|
+
upstream_content_type: contentType || undefined,
|
|
616
|
+
});
|
|
617
|
+
if (upstreamResp.status >= 500) {
|
|
618
|
+
const clone = upstreamResp.clone();
|
|
619
|
+
const body = await clone.text().catch(() => "<unreadable body>");
|
|
620
|
+
log?.add({ upstream_body_preview: body.slice(0, 512) });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Builds a redirect response with the Location header rewritten under the
|
|
626
|
+
* mount and Set-Cookie paths scoped to it. Mutates `headers`.
|
|
627
|
+
*/
|
|
628
|
+
function buildRedirectResponse(
|
|
629
|
+
upstreamResp: Response,
|
|
630
|
+
headers: Headers,
|
|
631
|
+
mountActual: string,
|
|
632
|
+
requestUrl: URL,
|
|
633
|
+
): Response {
|
|
634
|
+
const loc = headers.get("location");
|
|
635
|
+
if (loc) headers.set("location", rewriteLocation(loc, mountActual, requestUrl));
|
|
636
|
+
rewriteSetCookie(headers, mountActual);
|
|
637
|
+
|
|
638
|
+
getRequestLog()?.add({ redirect_location: headers.get("location") ?? undefined });
|
|
639
|
+
|
|
640
|
+
return new Response(null, { status: upstreamResp.status, headers });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Assembles the HTMLRewriter pipeline: asset-URL rewriting on all elements,
|
|
645
|
+
* optional view-transition CSS, and the browser-appropriate preload
|
|
646
|
+
* mechanism (Speculation Rules for Chromium, fetch-script fallback for
|
|
647
|
+
* Firefox/Safari).
|
|
648
|
+
*/
|
|
649
|
+
function buildHtmlRewriter(
|
|
650
|
+
request: Request,
|
|
651
|
+
mountActual: string,
|
|
652
|
+
assetPrefixes: string[],
|
|
653
|
+
options?: MountedAppOptions,
|
|
654
|
+
): HTMLRewriter {
|
|
655
|
+
const rewriter = new HTMLRewriter().on(
|
|
656
|
+
"*",
|
|
657
|
+
new AllAttributesRewriter(mountActual, assetPrefixes),
|
|
658
|
+
);
|
|
659
|
+
rewriter.on("head", new MountMetaInjector(mountActual));
|
|
660
|
+
if (options?.smoothTransitions) rewriter.on("head", new SmoothTransitionsInjector());
|
|
661
|
+
|
|
662
|
+
if (options?.preloadStaticMounts?.length) {
|
|
663
|
+
const userAgent = request.headers.get("user-agent") || "";
|
|
664
|
+
if (isChromiumBrowser(userAgent)) {
|
|
665
|
+
rewriter.on("head", new SpeculationRulesInjector(options.preloadStaticMounts));
|
|
666
|
+
} else {
|
|
667
|
+
rewriter.on("body", new PreloadScriptInjector(mountActual));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return rewriter;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function transformHtmlResponse(
|
|
674
|
+
request: Request,
|
|
675
|
+
upstreamResp: Response,
|
|
676
|
+
headers: Headers,
|
|
677
|
+
mountActual: string,
|
|
678
|
+
assetPrefixes: string[],
|
|
679
|
+
options?: MountedAppOptions,
|
|
680
|
+
): Promise<Response> {
|
|
681
|
+
const htmlText = await upstreamResp.text();
|
|
682
|
+
|
|
683
|
+
const headersOut = cloneHeadersForTransform(headers);
|
|
684
|
+
rewriteSetCookie(headersOut, mountActual);
|
|
685
|
+
|
|
686
|
+
return buildHtmlRewriter(request, mountActual, assetPrefixes, options).transform(
|
|
687
|
+
new Response(htmlText, {
|
|
688
|
+
status: upstreamResp.status,
|
|
689
|
+
statusText: upstreamResp.statusText,
|
|
690
|
+
headers: headersOut,
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Transforms a CSS response: rewrites url() references with absolute asset
|
|
697
|
+
* paths to include the mount prefix. The prefix regex is built dynamically
|
|
698
|
+
* from `assetPrefixes`.
|
|
699
|
+
*/
|
|
700
|
+
async function transformCssResponse(
|
|
701
|
+
upstreamResp: Response,
|
|
702
|
+
headers: Headers,
|
|
703
|
+
mountActual: string,
|
|
704
|
+
assetPrefixes: string[],
|
|
705
|
+
): Promise<Response> {
|
|
706
|
+
const cssText = await upstreamResp.text();
|
|
707
|
+
const headersOut = cloneHeadersForTransform(headers);
|
|
708
|
+
rewriteSetCookie(headersOut, mountActual);
|
|
709
|
+
|
|
710
|
+
// Special handling for root mount: don't add prefix.
|
|
711
|
+
const cssMountPrefix = mountActual === "/" ? "" : mountActual;
|
|
712
|
+
|
|
713
|
+
// Build regex pattern from asset prefixes (escape special regex chars, join with |).
|
|
714
|
+
// Example: /assets/|/static/|/build/ becomes (?:/assets/|/static/|/build/)
|
|
715
|
+
const prefixPattern = assetPrefixes
|
|
716
|
+
.map((p) => p.slice(1, -1)) // Remove leading "/" and trailing "/" for regex
|
|
717
|
+
.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) // Escape regex special chars
|
|
718
|
+
.join("|");
|
|
719
|
+
const regex = new RegExp(`url\\(\\s*(['"]?)(/(?:${prefixPattern})/)`, "g");
|
|
720
|
+
|
|
721
|
+
// Match: url('...'), url("..."), or url(...) with absolute asset paths.
|
|
722
|
+
const rewrittenCss = cssText.replace(regex, `url($1${cssMountPrefix}$2`);
|
|
723
|
+
|
|
724
|
+
return new Response(rewrittenCss, {
|
|
725
|
+
status: upstreamResp.status,
|
|
726
|
+
statusText: upstreamResp.statusText,
|
|
727
|
+
headers: headersOut,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Handles a request for a mounted microfrontend.
|
|
733
|
+
*
|
|
734
|
+
* This is the core request processing function that:
|
|
735
|
+
* 1. Strips the mount prefix from the request path before forwarding upstream
|
|
736
|
+
* 2. Transforms the response (HTML/CSS rewriting, redirect/cookie handling)
|
|
737
|
+
* 3. Optionally injects speculation rules and view transition CSS
|
|
738
|
+
*
|
|
739
|
+
* @param request - Original incoming request
|
|
740
|
+
* @param upstream - Fetcher for the service binding (upstream microfrontend)
|
|
741
|
+
* @param mountActual - The concrete matched mount path (e.g., "/docs" or "/acme" for "/:tenant")
|
|
742
|
+
* @param assetPrefixes - Array of asset prefixes to use for URL rewriting
|
|
743
|
+
* @param options - Optional configuration for response transformation
|
|
744
|
+
* @returns Transformed response
|
|
745
|
+
*/
|
|
746
|
+
export async function handleMountedApp(
|
|
747
|
+
request: Request,
|
|
748
|
+
upstream: Fetcher,
|
|
749
|
+
mountActual: string,
|
|
750
|
+
assetPrefixes: string[],
|
|
751
|
+
options?: MountedAppOptions,
|
|
752
|
+
): Promise<Response> {
|
|
753
|
+
mountActual = normalizePath(mountActual);
|
|
754
|
+
|
|
755
|
+
const forwardUrl = new URL(request.url);
|
|
756
|
+
stripMountPrefix(forwardUrl, mountActual);
|
|
757
|
+
|
|
758
|
+
// Serve preload script from the router itself (not from upstream service).
|
|
759
|
+
// This script is used as a fallback for browsers that don't support Speculation Rules API.
|
|
760
|
+
// Must be checked BEFORE forwarding to upstream to intercept the request.
|
|
761
|
+
if (options?.preloadStaticMounts?.length && forwardUrl.pathname === "/__mf-preload.js") {
|
|
762
|
+
return getPreloadScriptResponse(options.preloadStaticMounts);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Correlation headers (cf-request-id, x-caller-app) are stamped at the
|
|
766
|
+
// bouncer entry by `stampUpstreamHeaders` so both passthrough and vmf
|
|
767
|
+
// dispatch paths get them; `request` here already carries them.
|
|
768
|
+
const upstreamResp = await upstream.fetch(new Request(forwardUrl.toString(), request));
|
|
769
|
+
const headers = new Headers(upstreamResp.headers);
|
|
770
|
+
const contentType = headers.get("content-type") || "";
|
|
771
|
+
|
|
772
|
+
await logUpstreamResponse(upstreamResp, contentType);
|
|
773
|
+
|
|
774
|
+
if (upstreamResp.status >= 300 && upstreamResp.status < 400) {
|
|
775
|
+
return buildRedirectResponse(upstreamResp, headers, mountActual, new URL(request.url));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (contentType.includes("text/html")) {
|
|
779
|
+
return transformHtmlResponse(
|
|
780
|
+
request,
|
|
781
|
+
upstreamResp,
|
|
782
|
+
headers,
|
|
783
|
+
mountActual,
|
|
784
|
+
assetPrefixes,
|
|
785
|
+
options,
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (contentType.includes("text/css")) {
|
|
790
|
+
return transformCssResponse(upstreamResp, headers, mountActual, assetPrefixes);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Passthrough for all other content types (JSON, images, fonts, etc.).
|
|
794
|
+
// Only rewrite cookies - don't modify the body.
|
|
795
|
+
rewriteSetCookie(headers, mountActual);
|
|
796
|
+
return new Response(upstreamResp.body, {
|
|
797
|
+
status: upstreamResp.status,
|
|
798
|
+
statusText: upstreamResp.statusText,
|
|
799
|
+
headers,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// `buildRoutes` and the `default { fetch }` export are owned by
|
|
804
|
+
// `./routes.ts` (compileRoutes) and `./index.ts` (the entry handler).
|