From a5374791e407b0482128fec9f56f8697c411266f Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Sat, 7 Nov 2026 16:06:05 +0100 Subject: [PATCH] Resolve asset references from the chunk that holds them The runtime path was `./` plus the asset's path from the package root, which is right for every chunk the build emits today, all of which sit at that root, and wrong for one emitted under a directory. Vite names the referencing chunk, so the path is now computed relative to it. The sound loader kept a branch for sounds inlined as data URLs, with a comment saying the component build produces them. No build does any more, and no host would inline a sound of this size, so the branch and its tests go. Co-Authored-By: Claude Fable 5.1 --- src/soundUtils.test.ts | 33 +++++++-------------------------- src/soundUtils.ts | 28 +--------------------------- vite-component.config.ts | 20 ++++++++++++-------- 3 files changed, 20 insertions(+), 61 deletions(-) diff --git a/src/soundUtils.test.ts b/src/soundUtils.test.ts index 3d9810fe0..c6b652f9d 100644 --- a/src/soundUtils.test.ts +++ b/src/soundUtils.test.ts @@ -34,23 +34,6 @@ describe("prefetchSounds", () => { expect(bytesOf(sounds.beep)).toEqual([1, 2, 3]); }); - it("decodes base64 data: URLs without fetching them", async () => { - // "OggS" — the bytes 0x4f 0x67 0x67 0x53 - const sounds = await prefetchSounds({ - beep: { ogg: "data:audio/ogg;base64,T2dnUw==" }, - }); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(bytesOf(sounds.beep)).toEqual([0x4f, 0x67, 0x67, 0x53]); - }); - - it("leaves out a sound with a percent-encoded data: URL", async () => { - const sounds = await prefetchSounds({ - beep: { ogg: "data:audio/ogg,Ogg%53" }, - }); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(sounds).toEqual({}); - }); - it("leaves out a sound whose response is not okay", async () => { fetchSpy.mockResolvedValue(new Response(null, { status: 404 })); const sounds = await prefetchSounds({ @@ -60,18 +43,16 @@ describe("prefetchSounds", () => { }); it("leaves out a sound that fails to load, keeping the others", async () => { - fetchSpy.mockRejectedValue(new TypeError("Failed to fetch")); + // Fetched in the order given + fetchSpy + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce( + new Response(new Uint8Array([1, 2, 3]), { status: 200 }), + ); const sounds = await prefetchSounds({ beep: { ogg: "https://example.org/beep.ogg" }, - boop: { ogg: "data:audio/ogg;base64,T2dnUw==" }, + boop: { ogg: "https://example.org/boop.ogg" }, }); expect(Object.keys(sounds)).toEqual(["boop"]); }); - - it("leaves out a sound with a malformed data: URL", async () => { - const sounds = await prefetchSounds({ - beep: { ogg: "data:audio/ogg;base64" }, - }); - expect(sounds).toEqual({}); - }); }); diff --git a/src/soundUtils.ts b/src/soundUtils.ts index 74d2ea33c..7ace7876e 100644 --- a/src/soundUtils.ts +++ b/src/soundUtils.ts @@ -32,38 +32,12 @@ function getPreferredAudioFormat(): "ogg" | "mp3" { const preferredFormat = getPreferredAudioFormat(); -/** - * Decode a `data:` URL into the bytes it carries. - * - * The component build inlines the sound files as `data:` URLs. Those could be - * `fetch`ed like any other URL, but that fetch is subject to the host page's - * Content Security Policy, and `connect-src` lists rarely allow `data:` (a `*` - * source does not cover it). Reading the bytes out of the URL directly needs no - * network access, so no policy can stop it. - * @param url The `data:` URL to decode. - * @returns The decoded bytes. - */ -function decodeDataUrl(url: string): ArrayBuffer { - const separator = url.indexOf(","); - if (!url.startsWith("data:") || separator === -1) - throw new Error("Not a valid data: URL"); - const header = url.slice("data:".length, separator); - // Vite only percent-encodes SVGs; every other inlined asset is base64. - if (!header.split(";").includes("base64")) - throw new Error("Only base64 data: URLs are supported"); - // `decoded` is a binary string: one char per byte - const decoded = atob(url.slice(separator + 1)); - const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0)); - return bytes.buffer; -} - /** * Load the bytes of a single sound file. - * @param url Where the sound is: an ordinary URL to fetch, or a `data:` URL. + * @param url Where the sound is. * @returns The bytes, or null if the sound could not be loaded. */ async function loadSound(url: string): Promise { - if (url.startsWith("data:")) return decodeDataUrl(url); const response = await fetch(url); if (isFailure(response)) return null; return await response.arrayBuffer(); diff --git a/vite-component.config.ts b/vite-component.config.ts index fc0c9ac4a..859d2f5ff 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { defineConfig } from "vite"; +import { posix } from "node:path"; import { vitePluginsConfig } from "./vite.config.ts"; import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot.ts"; @@ -41,14 +42,17 @@ export default defineConfig(({ mode }) => { // imports do.) base: "./", experimental: { - // Spelled `./assets/…`, not `assets/…` as the relative base would have it: - // a host's bundler reads a bare name as a package to look up. - renderBuiltUrl: (filename, { hostType }) => - hostType === "js" - ? { - runtime: `new URL(${JSON.stringify(`./${filename}`)}, import.meta.url).href`, - } - : { relative: true }, + // Relative to the chunk that references it, wherever that chunk lands, + // and spelled `./assets/…` rather than `assets/…`: a host's bundler reads + // a bare name as a package to look up. + renderBuiltUrl: (filename, { hostId, hostType }) => { + if (hostType !== "js") return { relative: true }; + const relative = posix.relative(posix.dirname(hostId), filename); + const specifier = relative.startsWith(".") ? relative : `./${relative}`; + return { + runtime: `new URL(${JSON.stringify(specifier)}, import.meta.url).href`, + }; + }, }, // A host's document is not ours to style: everything in the stylesheet is // confined to the element Element Call is mounted in