diff --git a/src/servers/api/dev-proxy/html-rewrite.test.ts b/src/servers/api/dev-proxy/html-rewrite.test.ts new file mode 100644 index 00000000..c053d027 --- /dev/null +++ b/src/servers/api/dev-proxy/html-rewrite.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'bun:test'; +import { rewriteAssetPaths, rewriteHtml } from './html-rewrite'; + +const BASE = '/api/dev-proxy/3001'; +const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!); + +// What a browser would resolve the rewritten path to. The bug this file exists for is invisible without +// this step: the rewrite looked right and the browser threw the prefix away. +const resolved = (path: string) => new URL(path, 'https://platform.officer.dev/').pathname; + +describe('rewriteHtml', () => { + test('a plain root-absolute asset gets the prefix', () => { + expect(attrs(rewriteHtml('', BASE))).toEqual(['/api/dev-proxy/3001/style.css']); + }); + + // The blank preview. Bun's dev server emits `href="/../../../chunk-qmw09gd9.css"` — root-absolute AND + // climbing. Prefixed naively the browser normalises it straight back to the platform root and 404s. + test('`..` segments are collapsed BEFORE the prefix, not handed to the browser', () => { + const out = attrs(rewriteHtml('', BASE)); + expect(out).toEqual(['/api/dev-proxy/3001/chunk-qmw09gd9.css']); + }); + + test('and the result survives the browser resolving it — the part that actually failed', () => { + const [href] = attrs(rewriteHtml('`; +/** + * Collapse `..` before prefixing, or the prefix is thrown away. + * + * Bun's dev server emits `href="/../../../chunk-qmw09gd9.css"` — root-absolute AND climbing. Prefixed + * naively that becomes `/api/dev-proxy/3001/../../../chunk-…`, which the BROWSER normalises straight back + * to `/chunk-…` at the platform root, where it 404s. The page then renders blank with two failed asset + * requests and no clue that a proxy was ever involved. + * + * `..` in a URL is resolved by whoever consumes it, so it has to be resolved HERE, before the prefix goes + * on. `new URL` does exactly the normalisation the browser would. + */ +const normalisePath = (path: string): string => { + try { + const url = new URL(path, 'http://placeholder/'); + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return path; + } +}; + +/** `/x` → `/x`, with `..` already collapsed. Never touches `//host` or a full URL. */ +const prefix = (base: string, path: string): string => `${base}${normalisePath(path)}`; + // HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override export const rewriteHtml = (html: string, base: string) => html .replace(/]*)>/i, `${proxyOverrideScript(base)}`) - .replace(/(href|src|action)="\/(?!\/)/g, `$1="${base}/`) - .replace(/(href|src|action)='\/(?!\/)/g, `$1='${base}/`) - .replace(/url\(\//g, `url(${base}/`); + .replace( + /(href|src|action)=("|')(\/(?!\/)[^"']*)\2/gi, + (_match, attr: string, quote: string, path: string) => `${attr}=${quote}${prefix(base, path)}${quote}`, + ) + .replace(/url\((\/(?!\/)[^)"']*)\)/g, (_match, path: string) => `url(${prefix(base, path)})`); // JS/CSS: only `/_bun/` paths. //