dev-proxy: a stylesheet's url() is a URL
Two bugs, one line apart, both silent.
The `url(/…)` rule only matched an UNQUOTED target, so `url("/fonts/x.woff2")` — the form every
generator emits — went through untouched. And it only ran on HTML, so a linked stylesheet got the
narrow `/_bun/` rule and nothing else.
Together that meant a site's own @font-face rules kept pointing at `/fonts/…`, which inside the frame
is the platform's root. Every face 404'd and the preview rendered in a system fallback: nothing looked
broken, it just looked like a different site than the one being previewed.
JavaScript keeps the narrow rule — an absolute path in JS may not be a URL. In CSS it always is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { proxyOverrideScript, rewriteAssetPaths, rewriteHtml } from './html-rewrite';
|
import { proxyOverrideScript, rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
|
||||||
|
|
||||||
const BASE = '/api/dev-proxy/3001';
|
const BASE = '/api/dev-proxy/3001';
|
||||||
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
|
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
|
||||||
@@ -212,3 +212,42 @@ describe('the injected location reporter', () => {
|
|||||||
expect(r.posted).toHaveLength(2);
|
expect(r.posted).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stylesheets', () => {
|
||||||
|
test('a quoted url is prefixed — the form every generator emits', () => {
|
||||||
|
expect(rewriteCss('@font-face{src:url("/fonts/Averta.woff2") format("woff2")}', BASE)).toContain(
|
||||||
|
`url("${BASE}/fonts/Averta.woff2")`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single quotes and bare urls too', () => {
|
||||||
|
expect(rewriteCss("a{background:url('/bg.png')}", BASE)).toContain(`url('${BASE}/bg.png')`);
|
||||||
|
expect(rewriteCss('a{background:url(/bg.png)}', BASE)).toContain(`url(${BASE}/bg.png)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the quote style is preserved, not normalised', () => {
|
||||||
|
expect(rewriteCss('a{background:url("/bg.png")}', BASE)).not.toContain("url('");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a url that is already absolute is left alone', () => {
|
||||||
|
const external = 'a{background:url("https://cdn.example.com/bg.png")}';
|
||||||
|
expect(rewriteCss(external, BASE)).toBe(external);
|
||||||
|
const protocolRelative = 'a{background:url("//cdn.example.com/bg.png")}';
|
||||||
|
expect(rewriteCss(protocolRelative, BASE)).toBe(protocolRelative);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a relative url is left alone — it already resolves against the stylesheet', () => {
|
||||||
|
const relative = 'a{background:url("./bg.png")}';
|
||||||
|
expect(rewriteCss(relative, BASE)).toBe(relative);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stylesheet still gets the /_bun/ rule', () => {
|
||||||
|
expect(rewriteCss('@import "/_bun/x.css";', BASE)).toContain(`${BASE}/_bun/x.css`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inline CSS in HTML gets the same treatment, quotes included', () => {
|
||||||
|
expect(rewriteHtml('<div style=\'background:url("/a.png")\'></div>', BASE)).toContain(
|
||||||
|
`url("${BASE}/a.png")`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -139,12 +139,41 @@ export const rewriteHtml = (html: string, base: string) =>
|
|||||||
/(imagesrcset|srcset)=("|')([^"']*)\2/gi,
|
/(imagesrcset|srcset)=("|')([^"']*)\2/gi,
|
||||||
(_match, attr: string, quote: string, list: string) => `${attr}=${quote}${prefixSrcset(base, list)}${quote}`,
|
(_match, attr: string, quote: string, list: string) => `${attr}=${quote}${prefixSrcset(base, list)}${quote}`,
|
||||||
)
|
)
|
||||||
.replace(/url\((\/(?!\/)[^)"']*)\)/g, (_match, path: string) => `url(${prefix(base, path)})`);
|
.replace(CSS_URL, cssUrlPrefixer(base));
|
||||||
|
|
||||||
// JS/CSS: only `/_bun/` paths.
|
/*
|
||||||
|
`url(/…)` — a font, a background image, an `@import`. CSS allows the target bare, single-quoted or
|
||||||
|
double-quoted, and all three mean the same thing, so all three have to match. The quote used is
|
||||||
|
captured and put back rather than normalised: rewriting is not the place to have an opinion about
|
||||||
|
someone else's stylesheet.
|
||||||
|
|
||||||
|
This rule missed every QUOTED url until 2026-08-31, which is a bad way to fail. `url(/x.png)` was
|
||||||
|
rewritten and `url("/x.png")` was not, so whether a stylesheet worked through the proxy came down to
|
||||||
|
the formatting preference of whatever generated it.
|
||||||
|
*/
|
||||||
|
const CSS_URL = /url\(\s*(["']?)(\/(?!\/)[^)"']*)\1\s*\)/g;
|
||||||
|
|
||||||
|
const cssUrlPrefixer =
|
||||||
|
(base: string) =>
|
||||||
|
(_match: string, quote: string, path: string): string =>
|
||||||
|
`url(${quote}${prefix(base, path)}${quote})`;
|
||||||
|
|
||||||
|
// JS: only `/_bun/` paths.
|
||||||
//
|
//
|
||||||
// Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are
|
// Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are
|
||||||
// not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it
|
// not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it
|
||||||
// is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to
|
// is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to
|
||||||
// match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that.
|
// match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that.
|
||||||
export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`);
|
export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`);
|
||||||
|
|
||||||
|
/*
|
||||||
|
A STYLESHEET gets more than that, because a stylesheet's absolute paths are unambiguous: `url(/…)` is
|
||||||
|
a URL and nothing else, so the argument for being narrow in JavaScript does not apply here.
|
||||||
|
|
||||||
|
This was the whole of it for a long time, and the symptom was specific: a site's own `@font-face` rules
|
||||||
|
kept pointing at `/fonts/…`, which inside the frame is the PLATFORM's root, so every face 404'd and the
|
||||||
|
preview quietly rendered in a system fallback. Nothing looked broken — it just looked like a different
|
||||||
|
site than the one being previewed, which is the one thing a preview must never do.
|
||||||
|
*/
|
||||||
|
export const rewriteCss = (text: string, base: string) =>
|
||||||
|
rewriteAssetPaths(text, base).replace(CSS_URL, cssUrlPrefixer(base));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { resolveAuthToken } from '../../auth-token';
|
import { resolveAuthToken } from '../../auth-token';
|
||||||
import { isSuperAdmin } from '../../super-admin';
|
import { isSuperAdmin } from '../../super-admin';
|
||||||
import { rewriteAssetPaths, rewriteHtml } from './html-rewrite';
|
import { rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See a dev server running on THIS machine's loopback, from a browser that is somewhere else.
|
* See a dev server running on THIS machine's loopback, from a browser that is somewhere else.
|
||||||
@@ -145,7 +145,8 @@ devProxyRouter.all('/:port/*', async (ctx) => {
|
|||||||
|
|
||||||
const type = res.headers.get('content-type') ?? '';
|
const type = res.headers.get('content-type') ?? '';
|
||||||
const isHtml = type.includes('text/html');
|
const isHtml = type.includes('text/html');
|
||||||
const rewritable = isHtml || type.includes('javascript') || type.includes('text/css');
|
const isCss = type.includes('text/css');
|
||||||
|
const rewritable = isHtml || isCss || type.includes('javascript');
|
||||||
|
|
||||||
const headers = new Headers(res.headers);
|
const headers = new Headers(res.headers);
|
||||||
// Anything framed here is the owner's own dev server; the platform frames it deliberately.
|
// Anything framed here is the owner's own dev server; the platform frames it deliberately.
|
||||||
@@ -169,7 +170,11 @@ devProxyRouter.all('/:port/*', async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const body = isHtml ? rewriteHtml(text, prefix) : rewriteAssetPaths(text, prefix);
|
const body = isHtml
|
||||||
|
? rewriteHtml(text, prefix)
|
||||||
|
: isCss
|
||||||
|
? rewriteCss(text, prefix)
|
||||||
|
: rewriteAssetPaths(text, prefix);
|
||||||
// Both are wrong after a rewrite: the length changed, and the body was decoded on the way through.
|
// Both are wrong after a rewrite: the length changed, and the body was decoded on the way through.
|
||||||
headers.delete('content-length');
|
headers.delete('content-length');
|
||||||
headers.delete('content-encoding');
|
headers.delete('content-encoding');
|
||||||
|
|||||||
Reference in New Issue
Block a user