@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/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).