a path that climbs out of the proxy is collapsed before it is prefixed
The blank preview, and it was never the restarts. Bun's dev server emits
href="/../../../chunk-qmw09gd9.css"
Root-absolute AND climbing. Prefixed naively that is
`/api/dev-proxy/3001/../../../chunk-…`, which the BROWSER normalises straight back
to `/chunk-…` — the platform root, where it 404s. Two failed asset requests, a
white pane, and nothing in the network tab suggesting a proxy was involved. The
rewrite looked correct in isolation and was undone by whoever read it.
`..` is resolved by the consumer, so it has to be resolved before the prefix goes
on. Both halves do it now: `rewriteHtml` for what is written in the document, and
the injected script for URLs built at runtime, which Bun also emits in this shape.
Verified through the whole stack against the real app on 3001: document 200, both
assets 200 with `text/css` and `text/javascript` — where before they were 404s at
the platform root.
13 tests, and one of them asserts the failure rather than the fix: that
`/api/dev-proxy/3001/../../../chunk-x.css` really does resolve to `/chunk-x.css`.
Without that line the other tests look like arbitrary string manipulation.
── Separately, and not a bug in this code ──
Two processes were listening on 3001, holding DIFFERENT task output files
(`b0oe99a47` and `b6zat0x7e`) — two independent jobs, not one restarted. Bun allows
both to bind through SO_REUSEPORT, so requests were split between them and stopping
one legitimately left the other serving. That is worth knowing before reading a
Stop as broken, and it is the second time today SO_REUSEPORT has made a duplicate
invisible.
tsgo clean, frontend builds, 914 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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('<link href="/style.css">', 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('<link href="/../../../chunk-qmw09gd9.css">', 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('<script src="/../../../chunk-bmp7wy3m.js">', BASE));
|
||||
expect(resolved(href!)).toBe('/api/dev-proxy/3001/chunk-bmp7wy3m.js');
|
||||
});
|
||||
|
||||
test('the naive version would have escaped, which is why this is asserted at all', () => {
|
||||
// Not testing our code — testing the claim that made the fix necessary.
|
||||
expect(resolved(`${BASE}/../../../chunk-x.css`)).toBe('/chunk-x.css');
|
||||
});
|
||||
|
||||
test('single quotes are handled too', () => {
|
||||
expect(rewriteHtml("<img src='/a/../b.png'>", BASE)).toContain("src='/api/dev-proxy/3001/b.png'");
|
||||
});
|
||||
|
||||
test('a protocol-relative URL is left alone — it names another host', () => {
|
||||
expect(attrs(rewriteHtml('<script src="//cdn.example.com/x.js">', BASE))).toEqual(['//cdn.example.com/x.js']);
|
||||
});
|
||||
|
||||
test('an absolute URL is left alone', () => {
|
||||
expect(attrs(rewriteHtml('<img src="https://example.com/x.png">', BASE))).toEqual(['https://example.com/x.png']);
|
||||
});
|
||||
|
||||
test('a relative path is left alone — it already resolves under the prefix', () => {
|
||||
expect(attrs(rewriteHtml('<img src="./logo.png">', BASE))).toEqual(['./logo.png']);
|
||||
});
|
||||
|
||||
test('css url() gets the same treatment', () => {
|
||||
expect(rewriteHtml('<style>a{background:url(/../img/x.png)}</style>', BASE)).toContain(
|
||||
'url(/api/dev-proxy/3001/img/x.png)',
|
||||
);
|
||||
});
|
||||
|
||||
test('the interception script is injected into head', () => {
|
||||
const out = rewriteHtml('<html><head><title>x</title></head></html>', BASE);
|
||||
expect(out).toContain('window.fetch=function');
|
||||
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('<title>'));
|
||||
});
|
||||
|
||||
test('a query string and hash survive normalisation', () => {
|
||||
expect(attrs(rewriteHtml('<script src="/../x.js?v=2#frag">', BASE))).toEqual(['/api/dev-proxy/3001/x.js?v=2#frag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteAssetPaths', () => {
|
||||
test("Bun's chunk imports are prefixed", () => {
|
||||
expect(rewriteAssetPaths('import "/_bun/client/index.js"', BASE)).toBe(
|
||||
'import "/api/dev-proxy/3001/_bun/client/index.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test('nothing else in the file is touched', () => {
|
||||
const js = 'const re = /^\\/api\\/users$/; fetch("/api/users");';
|
||||
expect(rewriteAssetPaths(js, BASE)).toBe(js);
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,13 @@
|
||||
export const proxyOverrideScript = (base: string) =>
|
||||
`<script>(function(){` +
|
||||
`var b=${JSON.stringify(base)},o=location.origin;` +
|
||||
// `n()` collapses `..` the way the browser would, for the same reason the HTML rewrite does: Bun emits
|
||||
// `/../../../chunk-x.js`, and prefixing without normalising hands the browser a path that climbs back
|
||||
// out of the proxy and 404s at the platform root.
|
||||
`function n(u){try{var p=new URL(u,"http://x/");return p.pathname+p.search+p.hash}catch(e){return u}}` +
|
||||
`function rw(u){` +
|
||||
`if(typeof u==="string"){` +
|
||||
`if(u.startsWith("/")&&!u.startsWith("//")&&!u.startsWith(b))return b+u;` +
|
||||
`if(u.startsWith("/")&&!u.startsWith("//")&&!u.startsWith(b))return b+n(u);` +
|
||||
`if(u.startsWith(o+"/")&&!u.startsWith(o+b))return o+b+u.slice(o.length);` +
|
||||
`return u}` +
|
||||
`if(u instanceof URL&&u.origin===o&&!u.pathname.startsWith(b))` +
|
||||
@@ -42,13 +46,38 @@ export const proxyOverrideScript = (base: string) =>
|
||||
`window.WebSocket.OPEN=NWS.OPEN;window.WebSocket.CLOSING=NWS.CLOSING;window.WebSocket.CLOSED=NWS.CLOSED;` +
|
||||
`})()</script>`;
|
||||
|
||||
/**
|
||||
* 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` → `<base>/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(/<head([^>]*)>/i, `<head$1>${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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user