diff --git a/src/servers/api/dev-proxy/html-rewrite.test.ts b/src/servers/api/dev-proxy/html-rewrite.test.ts index 40865792..f5293c78 100644 --- a/src/servers/api/dev-proxy/html-rewrite.test.ts +++ b/src/servers/api/dev-proxy/html-rewrite.test.ts @@ -1,5 +1,5 @@ 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 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); }); }); + +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('
', BASE)).toContain( + `url("${BASE}/a.png")`, + ); + }); +}); diff --git a/src/servers/api/dev-proxy/html-rewrite.ts b/src/servers/api/dev-proxy/html-rewrite.ts index 0510c338..4341ea64 100644 --- a/src/servers/api/dev-proxy/html-rewrite.ts +++ b/src/servers/api/dev-proxy/html-rewrite.ts @@ -139,12 +139,41 @@ export const rewriteHtml = (html: string, base: string) => /(imagesrcset|srcset)=("|')([^"']*)\2/gi, (_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 // 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 // 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/`); + +/* + 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)); diff --git a/src/servers/api/dev-proxy/router.ts b/src/servers/api/dev-proxy/router.ts index 683fec32..c27f5d57 100644 --- a/src/servers/api/dev-proxy/router.ts +++ b/src/servers/api/dev-proxy/router.ts @@ -1,7 +1,7 @@ import { createRouter } from '../../create-router'; import { resolveAuthToken } from '../../auth-token'; 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. @@ -145,7 +145,8 @@ devProxyRouter.all('/:port/*', async (ctx) => { const type = res.headers.get('content-type') ?? ''; 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); // 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 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. headers.delete('content-length'); headers.delete('content-encoding');