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