From 74b353e2236e1138bcc85360495014ff85fa65b2 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 8 Jun 2026 13:37:24 +0100 Subject: [PATCH 1/2] fix(build): inline new URL(asset, import.meta.url) blob imports for edge (#1824) Edge/worker routes that reference static assets via `new URL("./asset", import.meta.url)` and `fetch(url)` failed at runtime. Vite's built-in `vite:asset-import-meta-url` plugin only runs in the `client` environment, so the URL was left untransformed in the worker bundle; and on Cloudflare Workers `import.meta.url` is the literal string `"worker"` (not a URL), so `new URL(...)` throws `TypeError: Invalid URL`. The whole upstream `edge-compiler-can-import-blob-assets` suite (5 tests) was red. Adds `vinext:edge-asset-import-meta-url`, which rewrites the expression to an inline `data:` URL computed from the referenced file at build time. A data URL is a valid absolute URL (no dependency on the runtime value of `import.meta.url`), can be bound to a variable and `fetch()`ed later, and is fetchable in both workerd and Node. Bare specifiers (node_modules assets like `my-pkg/hello/world.json`) resolve through the bundler. Mirrors the existing `vinext:og-inline-fetch-assets` plugin, which already base64-inlines `fetch(new URL(...))` assets for the same reason, and runs immediately after it so the OG inliner's verbatim-pattern match is preserved. Complements the still-open SSR work in #1346/#1640, which takes a Node-only `file://` emit approach that does not work on Workers. Closes #1824 --- packages/vinext/src/index.ts | 12 ++ .../src/plugins/edge-asset-import-meta-url.ts | 197 ++++++++++++++++++ tests/edge-asset-import-meta-url.test.ts | 152 ++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 packages/vinext/src/plugins/edge-asset-import-meta-url.ts create mode 100644 tests/edge-asset-import-meta-url.test.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 5b21e8560..bf36054aa 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -116,6 +116,7 @@ import { } from "./client/instrumentation-client-inject.js"; import { createMiddlewareServerOnlyPlugin } from "./plugins/middleware-server-only.js"; import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; +import { createEdgeAssetImportMetaUrlPlugin } from "./plugins/edge-asset-import-meta-url.js"; import { createOgInlineFetchAssetsPlugin, createOgAssetsPlugin } from "./plugins/og-assets.js"; import { generateRouteTypes } from "./typegen.js"; import { @@ -4301,6 +4302,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Inline binary assets fetched via `fetch(new URL("./asset", import.meta.url))` — // see src/plugins/og-assets.ts createOgInlineFetchAssetsPlugin(), + // Inline assets referenced via `new URL("./asset", import.meta.url)` in + // server/worker environments as `data:` URLs so edge routes can fetch + // them — see src/plugins/edge-asset-import-meta-url.ts and #1824. + // + // MUST run AFTER `vinext:og-inline-fetch-assets`: that plugin matches the + // verbatim `fetch(new URL(...)).then(r => r.arrayBuffer())` pattern. If we + // rewrote the inner `new URL(...)` to a data URL first, the OG inliner + // would no longer match and @vercel/og font inlining would silently + // change shape. Both plugins are `enforce: "pre"`, so array order here is + // what sequences them. + createEdgeAssetImportMetaUrlPlugin(), // Dedupe/copy @vercel/og binary WASM assets in the RSC output — see src/plugins/og-assets.ts createOgAssetsPlugin(), // Collect SSR/RSC bundle externals and write dist/server/vinext-externals.json. diff --git a/packages/vinext/src/plugins/edge-asset-import-meta-url.ts b/packages/vinext/src/plugins/edge-asset-import-meta-url.ts new file mode 100644 index 000000000..7e48e4a7a --- /dev/null +++ b/packages/vinext/src/plugins/edge-asset-import-meta-url.ts @@ -0,0 +1,197 @@ +/** + * vinext:edge-asset-import-meta-url + * + * Inlines static/blob assets referenced via `new URL("./asset", import.meta.url)` + * (or a bare-specifier form like `new URL("my-pkg/data.json", import.meta.url)`) + * in server/worker environments so they can be fetched at runtime. + * + * Why this is needed + * ------------------ + * Vite's built-in `vite:asset-import-meta-url` plugin only runs in the + * `client` environment. Server-side code that builds an asset URL from + * `import.meta.url` and fetches it — e.g. an edge API route: + * + * const url = new URL('../../src/text-file.txt', import.meta.url) + * return fetch(url) + * + * is therefore left untransformed in the worker bundle. Worse, on Cloudflare + * Workers `import.meta.url` is the literal string `"worker"` (not a URL), so + * `new URL('./x', import.meta.url)` throws `TypeError: Invalid URL` and the + * whole `edge-compiler-can-import-blob-assets` suite fails (cloudflare/vinext#1824). + * + * Approach + * -------- + * Rewrite the entire `new URL("", import.meta.url)` expression to a + * `data:` URL literal (`new URL("data:;base64,")`) computed at + * build time from the referenced file. A `data:` URL: + * + * - is a valid absolute URL, so `new URL(...)` never throws (no dependency + * on the runtime value of `import.meta.url`); + * - can be bound to a variable and `fetch()`ed later, matching the + * fixture's `const url = new URL(...); return fetch(url)` pattern that the + * `fetch(new URL(...))`-only OG inliner (vinext:og-inline-fetch-assets) + * does not cover; + * - is fetchable in both workerd and Node, so no asset file needs to be + * emitted to (and served from) the worker output. + * + * This mirrors the existing `vinext:og-inline-fetch-assets` plugin, which + * already base64-inlines `fetch(new URL(...))` font/wasm assets for the same + * "import.meta.url is not a URL in workerd" reason. + * + * Relation to #1346 / PR #1640 (vinext:server-asset-import-meta-url): that + * (still-open) work targets the Node SSR path, where it emits the asset to + * disk and rewrites the URL to a chunk-relative `file://` path. That strategy + * does not work on Cloudflare Workers (no filesystem, `import.meta.url` is + * `"worker"`), which is why the edge path inlines instead. + * + * Honours `/* @vite-ignore *\/` to match Vite's upstream contract. + */ + +import type { Plugin } from "vite"; +import path from "node:path"; +import fs from "node:fs"; +import { CONTENT_TYPES } from "../server/static-file-cache.js"; + +// Matches `new URL("", import.meta.url)` with a quoted string literal +// (no template literals) for the spec. Both relative (`./`, `../`) and bare +// specifiers (`my-pkg/data.json`) are accepted; an optional `.href` / +// `.pathname` accessor immediately after is preserved by leaving the trailing +// member access untouched (we only replace the `new URL(...)` expression). +// Excludes specifiers that already look like an absolute URL (contain `://`) +// or a protocol-relative/data form — those are runtime URLs, not assets. +// Intentionally NOT global: this object is reused as a `transform.filter` and +// for `String.prototype.matchAll`-style scanning below. A global (`/g`) regex +// is stateful (`lastIndex` persists across `.test()` calls), so the handler +// builds its own fresh `/g` copy for iteration via `new RegExp(re, "g")`. +const ASSET_IMPORT_META_URL_RE = + /\bnew\s+URL\s*\(\s*(['"])([^'"`\n]+)\1\s*,\s*import\.meta\.url\s*(?:,\s*)?\)/; +const VITE_IGNORE_RE = /\/\*\s*@vite-ignore\s*\*\//; + +// A few common asset extensions that `CONTENT_TYPES` (tuned for the static +// file server) does not carry but `new URL(...)` assets routinely use. The +// content type is best-effort metadata for the `data:` URL; the bytes are +// always exact, so an unknown type degrades to `application/octet-stream`. +const EXTRA_CONTENT_TYPES: Record = { + ".txt": "text/plain", + ".md": "text/markdown", + ".xml": "application/xml", + ".wasm": "application/wasm", + ".csv": "text/csv", +}; + +function mimeTypeFor(file: string): string { + const ext = path.extname(file).toLowerCase(); + return CONTENT_TYPES[ext] ?? EXTRA_CONTENT_TYPES[ext] ?? "application/octet-stream"; +} + +/** + * Create the `vinext:edge-asset-import-meta-url` Vite plugin. + * + * Inlines assets referenced via `new URL("", import.meta.url)` in + * server/worker environments as `data:` URLs so they remain fetchable on + * Cloudflare Workers where `import.meta.url` is not a real URL. + */ +export function createEdgeAssetImportMetaUrlPlugin(): Plugin { + // Build-only cache: absolute resolved path -> data URL string. Dev skips the + // cache so asset edits are picked up without a restart. + const cache = new Map(); + let isBuild = false; + + return { + name: "vinext:edge-asset-import-meta-url", + enforce: "pre", + // Run for all non-client environments (App Router RSC, App Router SSR, + // Pages Router SSR, Cloudflare worker). Vite's upstream plugin already + // covers `client`. + applyToEnvironment(environment) { + return environment.config.consumer !== "client"; + }, + configResolved(config) { + isBuild = config.command === "build"; + }, + buildStart() { + if (isBuild) cache.clear(); + }, + transform: { + filter: { code: ASSET_IMPORT_META_URL_RE }, + async handler(code, id) { + // Virtual modules have no real filesystem path to resolve relative + // specifiers against, so skip them. + if (id.startsWith("\0") || id.startsWith("virtual:")) return null; + + const moduleDir = path.dirname(id.split("?")[0]!); + const re = new RegExp(ASSET_IMPORT_META_URL_RE, "g"); + let result = ""; + let lastIndex = 0; + let didReplace = false; + let match: RegExpExecArray | null; + + // Read the asset (resolving bare specifiers via the bundler's + // resolver) and return its `data:` URL, or null if it can't be + // resolved/read — in which case the expression is left untouched. + const toDataUrl = async (spec: string): Promise => { + let file: string | undefined; + if (spec.startsWith("./") || spec.startsWith("../")) { + file = path.resolve(moduleDir, spec); + } else { + // Bare specifier (e.g. `my-pkg/hello/world.json`). Resolve it + // through the bundler so node_modules assets work. + const resolved = await this.resolve(spec, id, { skipSelf: true }); + file = resolved?.id?.split("?")[0]; + } + if (!file) return null; + + const cached = isBuild ? cache.get(file) : undefined; + if (cached !== undefined) return cached; + + let buffer: Buffer; + try { + buffer = await fs.promises.readFile(file); + } catch { + return null; + } + const dataUrl = `data:${mimeTypeFor(file)};base64,${buffer.toString("base64")}`; + if (isBuild) cache.set(file, dataUrl); + return dataUrl; + }; + + while ((match = re.exec(code))) { + const fullMatch = match[0]; + const quote = match[1]!; + const spec = match[2]!; + const matchStart = match.index; + const matchEnd = matchStart + fullMatch.length; + + // Skip specifiers that are already absolute/runtime URLs — these are + // not build-time assets (e.g. `new URL("https://example.com")` is + // matched by a separate code path, but a two-arg form pointing at an + // absolute URL should be left alone). + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(spec) || spec.startsWith("//")) { + continue; + } + + // Honour `/* @vite-ignore */` between `new URL(` and the literal. + const literalStart = code.indexOf(quote, matchStart); + if (literalStart !== -1 && VITE_IGNORE_RE.test(code.slice(matchStart, literalStart))) { + continue; + } + + const dataUrl = await toDataUrl(spec); + if (dataUrl === null) continue; + + if (matchStart > lastIndex) result += code.slice(lastIndex, matchStart); + // A single-argument `new URL()` is enough: the data URL is + // absolute, so no base is needed and the runtime never touches + // `import.meta.url`. + result += `new URL(${JSON.stringify(dataUrl)})`; + lastIndex = matchEnd; + didReplace = true; + } + + if (!didReplace) return null; + if (lastIndex < code.length) result += code.slice(lastIndex); + return { code: result, map: null }; + }, + }, + } satisfies Plugin; +} diff --git a/tests/edge-asset-import-meta-url.test.ts b/tests/edge-asset-import-meta-url.test.ts new file mode 100644 index 000000000..6c8ba086e --- /dev/null +++ b/tests/edge-asset-import-meta-url.test.ts @@ -0,0 +1,152 @@ +/** + * Regression test for cloudflare/vinext#1824. + * + * Edge/worker routes that reference static assets via + * `new URL("./asset", import.meta.url)` and `fetch(url)` failed at runtime: + * Vite's built-in `vite:asset-import-meta-url` plugin only runs in the + * `client` environment, so the URL was left untransformed, and on Cloudflare + * Workers `import.meta.url` is the literal string `"worker"` — `new URL(...)` + * then throws `TypeError: Invalid URL`. The whole upstream + * `edge-compiler-can-import-blob-assets` suite (5 tests) was red. + * + * `vinext:edge-asset-import-meta-url` rewrites the expression to an inline + * `data:` URL so the asset is fetchable in both workerd and Node. This test + * drives the plugin's `transform` hook directly (mirroring the + * `edge.js` fixture from the upstream suite) and asserts the rewrite. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import vinext from "../packages/vinext/src/index.js"; +import type { Plugin } from "vite"; + +function getPlugin(): Plugin { + const plugins = vinext() as Plugin[]; + const plugin = plugins.find((p) => p.name === "vinext:edge-asset-import-meta-url"); + if (!plugin) throw new Error("vinext:edge-asset-import-meta-url plugin not found"); + return plugin; +} + +function transformHandler(plugin: Plugin): (...args: any[]) => any { + const t = plugin.transform as any; + return typeof t === "function" ? t : t.handler; +} + +// Minimal `this` context for the transform hook. `environment.config.consumer` +// is "server" so applyToEnvironment would admit it; isBuild defaults to false +// (we don't call configResolved) which disables the cache — fine for a test. +function makeCtx(resolveMap: Record = {}) { + return { + environment: { name: "rsc", config: { consumer: "server" } }, + async resolve(spec: string) { + const id = resolveMap[spec]; + return id ? { id } : null; + }, + }; +} + +let tmpDir: string; +let routePath: string; + +beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-edge-asset-")); + const srcDir = path.join(tmpDir, "src"); + const apiDir = path.join(tmpDir, "pages", "api"); + await fs.mkdir(srcDir, { recursive: true }); + await fs.mkdir(apiDir, { recursive: true }); + + await fs.writeFile(path.join(srcDir, "text-file.txt"), "Hello, from text-file.txt!"); + await fs.writeFile( + path.join(srcDir, "vercel.png"), + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01, 0x02]), + ); + await fs.writeFile(path.join(tmpDir, "world.json"), '{ "i am": "a node dependency" }'); + + routePath = path.join(apiDir, "edge.js"); +}); + +afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); +}); + +describe("vinext:edge-asset-import-meta-url", () => { + it("rewrites a relative text asset URL to a fetchable data: URL", async () => { + const plugin = getPlugin(); + const code = [ + "const url = new URL('../../src/text-file.txt', import.meta.url)", + "return fetch(url)", + ].join("\n"); + + const result = await transformHandler(plugin).call(makeCtx(), code, routePath); + expect(result, "expected the relative URL to be rewritten").not.toBeNull(); + + const expected = + "data:text/plain;base64," + Buffer.from("Hello, from text-file.txt!").toString("base64"); + expect(result.code).toContain(`new URL(${JSON.stringify(expected)})`); + // The runtime no longer touches the (string "worker") import.meta.url for + // this expression. + expect(result.code).not.toContain("import.meta.url"); + + // The inlined data URL round-trips to the original bytes. + const decoded = Buffer.from(expected.split(",")[1]!, "base64").toString("utf8"); + expect(decoded).toBe("Hello, from text-file.txt!"); + }); + + it("inlines a binary image asset with the correct mime type", async () => { + const plugin = getPlugin(); + const code = "const url = new URL('../../src/vercel.png', import.meta.url); fetch(url)"; + const result = await transformHandler(plugin).call(makeCtx(), code, routePath); + expect(result).not.toBeNull(); + + const expected = + "data:image/png;base64," + + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01, 0x02]).toString("base64"); + expect(result.code).toContain(`new URL(${JSON.stringify(expected)})`); + }); + + it("resolves bare specifiers (node_modules assets) via the bundler resolver", async () => { + const plugin = getPlugin(); + const worldJson = path.join(tmpDir, "world.json"); + const code = "const url = new URL('my-pkg/hello/world.json', import.meta.url); fetch(url)"; + const ctx = makeCtx({ "my-pkg/hello/world.json": worldJson }); + + const result = await transformHandler(plugin).call(ctx, code, routePath); + expect(result, "expected the bare-specifier URL to be rewritten").not.toBeNull(); + + const expected = + "data:application/json;base64," + + Buffer.from('{ "i am": "a node dependency" }').toString("base64"); + expect(result.code).toContain(`new URL(${JSON.stringify(expected)})`); + + const decoded = JSON.parse(Buffer.from(expected.split(",")[1]!, "base64").toString("utf8")); + expect(decoded).toEqual({ "i am": "a node dependency" }); + }); + + it("leaves absolute/remote URLs untouched", async () => { + const plugin = getPlugin(); + // Single-arg remote URL and a two-arg base form — neither references a + // build-time asset, so the plugin must not rewrite them. + const code = [ + "const a = new URL('https://example.vercel.sh')", + "const b = new URL('/', 'https://example.vercel.sh')", + ].join("\n"); + const result = await transformHandler(plugin).call(makeCtx(), code, routePath); + expect(result).toBeNull(); + }); + + it("leaves the expression untouched when the file does not exist", async () => { + const plugin = getPlugin(); + const code = "const url = new URL('../../src/missing.bin', import.meta.url)"; + const result = await transformHandler(plugin).call(makeCtx(), code, routePath); + expect(result).toBeNull(); + }); + + it("does not run in the client environment", () => { + const plugin = getPlugin(); + const applyToEnvironment = plugin.applyToEnvironment as (env: any) => boolean; + expect(applyToEnvironment({ config: { consumer: "client" } })).toBe(false); + expect(applyToEnvironment({ config: { consumer: "server" } })).toBe(true); + }); +}); From 41277604929ef1267ec27187a1d5e8c5bd64ce6f Mon Sep 17 00:00:00 2001 From: James Date: Mon, 8 Jun 2026 13:49:07 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(build):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20scope=20edge=20asset=20inlining=20to=20worker=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bonk review follow-ups on #1833: - Scope to the bundled worker build (Cloudflare/Nitro) only. The plugin now takes an `isWorkerTarget` getter and `applyToEnvironment` returns false in a plain Node SSR build, where `import.meta.url` is already a valid file:// URL and rewriting to a data: URL would break `fileURLToPath(new URL(...))` and `.pathname` for the split `const u = new URL(...); readFileSync(...)` form that the OG inliner does not catch. - Add an end-to-end build test that runs a real Pages Router edge-API route through the full Vite pipeline (filter + applyToEnvironment + plugin ordering), plus a negative test asserting no rewrite in a plain Node SSR build. - Match the optional-chained `import.meta?.url` form (parity with the existing vinext:import-meta-url plugin). - Strip `?query`/`#hash` from relative specifiers before resolving, matching the bare-specifier branch. - Fix the doc nit about trailing accessors. --- packages/vinext/src/index.ts | 11 +- .../src/plugins/edge-asset-import-meta-url.ts | 72 ++++++---- tests/edge-asset-import-meta-url.test.ts | 132 ++++++++++++++++-- 3 files changed, 176 insertions(+), 39 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index bf36054aa..ff5fe6bc2 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4303,8 +4303,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // see src/plugins/og-assets.ts createOgInlineFetchAssetsPlugin(), // Inline assets referenced via `new URL("./asset", import.meta.url)` in - // server/worker environments as `data:` URLs so edge routes can fetch - // them — see src/plugins/edge-asset-import-meta-url.ts and #1824. + // the bundled worker build as `data:` URLs so edge routes can fetch them — + // see src/plugins/edge-asset-import-meta-url.ts and #1824. Scoped to the + // Cloudflare/Nitro worker target (where import.meta.url is "worker"); the + // getter reads the flags set in the `config` hook above, which runs before + // the plugin's `applyToEnvironment` gate. // // MUST run AFTER `vinext:og-inline-fetch-assets`: that plugin matches the // verbatim `fetch(new URL(...)).then(r => r.arrayBuffer())` pattern. If we @@ -4312,7 +4315,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // would no longer match and @vercel/og font inlining would silently // change shape. Both plugins are `enforce: "pre"`, so array order here is // what sequences them. - createEdgeAssetImportMetaUrlPlugin(), + createEdgeAssetImportMetaUrlPlugin({ + isWorkerTarget: () => hasCloudflarePlugin || hasNitroPlugin, + }), // Dedupe/copy @vercel/og binary WASM assets in the RSC output — see src/plugins/og-assets.ts createOgAssetsPlugin(), // Collect SSR/RSC bundle externals and write dist/server/vinext-externals.json. diff --git a/packages/vinext/src/plugins/edge-asset-import-meta-url.ts b/packages/vinext/src/plugins/edge-asset-import-meta-url.ts index 7e48e4a7a..d6d56c87b 100644 --- a/packages/vinext/src/plugins/edge-asset-import-meta-url.ts +++ b/packages/vinext/src/plugins/edge-asset-import-meta-url.ts @@ -3,7 +3,8 @@ * * Inlines static/blob assets referenced via `new URL("./asset", import.meta.url)` * (or a bare-specifier form like `new URL("my-pkg/data.json", import.meta.url)`) - * in server/worker environments so they can be fetched at runtime. + * in the Cloudflare Workers / Nitro worker build so they can be fetched at + * runtime. * * Why this is needed * ------------------ @@ -31,8 +32,16 @@ * fixture's `const url = new URL(...); return fetch(url)` pattern that the * `fetch(new URL(...))`-only OG inliner (vinext:og-inline-fetch-assets) * does not cover; - * - is fetchable in both workerd and Node, so no asset file needs to be - * emitted to (and served from) the worker output. + * - is fetchable in workerd, so no asset file needs to be emitted to (and + * served from) the worker output. + * + * Scope: only the bundled-worker build (Cloudflare `vite-plugin-cloudflare` + * or Nitro), where `import.meta.url` is `"worker"`. In a plain Node SSR build + * `import.meta.url` is already a valid `file://` URL, so rewriting to a data + * URL there would needlessly change URL semantics — e.g. + * `fileURLToPath(new URL(...))` throws on a `data:` URL, and `.pathname` + * would no longer be a filesystem path. The Node SSR asset-emit path is + * handled separately (cloudflare/vinext#1346). * * This mirrors the existing `vinext:og-inline-fetch-assets` plugin, which * already base64-inlines `fetch(new URL(...))` font/wasm assets for the same @@ -52,19 +61,22 @@ import path from "node:path"; import fs from "node:fs"; import { CONTENT_TYPES } from "../server/static-file-cache.js"; -// Matches `new URL("", import.meta.url)` with a quoted string literal -// (no template literals) for the spec. Both relative (`./`, `../`) and bare -// specifiers (`my-pkg/data.json`) are accepted; an optional `.href` / -// `.pathname` accessor immediately after is preserved by leaving the trailing -// member access untouched (we only replace the `new URL(...)` expression). -// Excludes specifiers that already look like an absolute URL (contain `://`) -// or a protocol-relative/data form — those are runtime URLs, not assets. -// Intentionally NOT global: this object is reused as a `transform.filter` and -// for `String.prototype.matchAll`-style scanning below. A global (`/g`) regex -// is stateful (`lastIndex` persists across `.test()` calls), so the handler -// builds its own fresh `/g` copy for iteration via `new RegExp(re, "g")`. +// Matches `new URL("", import.meta.url)` (and the optional-chained +// `import.meta?.url` form) with a quoted string literal — no template +// literals — for the spec. Both relative (`./`, `../`) and bare specifiers +// (`my-pkg/data.json`) are accepted. Only the `new URL(...)` expression is +// replaced, so a trailing accessor such as `.href` is left untouched and +// still works (it reads off the rewritten data URL). +// +// Specifiers that already look like an absolute/runtime URL are filtered out +// in the handler, not the regex. +// +// Intentionally NOT global: this object is reused as a `transform.filter`. A +// global (`/g`) regex is stateful (`lastIndex` persists across `.test()` +// calls), so the handler builds its own fresh `/g` copy via +// `new RegExp(re, "g")`. const ASSET_IMPORT_META_URL_RE = - /\bnew\s+URL\s*\(\s*(['"])([^'"`\n]+)\1\s*,\s*import\.meta\.url\s*(?:,\s*)?\)/; + /\bnew\s+URL\s*\(\s*(['"])([^'"`\n]+)\1\s*,\s*import\.meta\??\.url\s*(?:,\s*)?\)/; const VITE_IGNORE_RE = /\/\*\s*@vite-ignore\s*\*\//; // A few common asset extensions that `CONTENT_TYPES` (tuned for the static @@ -87,11 +99,19 @@ function mimeTypeFor(file: string): string { /** * Create the `vinext:edge-asset-import-meta-url` Vite plugin. * - * Inlines assets referenced via `new URL("", import.meta.url)` in - * server/worker environments as `data:` URLs so they remain fetchable on - * Cloudflare Workers where `import.meta.url` is not a real URL. + * Inlines assets referenced via `new URL("", import.meta.url)` as + * `data:` URLs so they remain fetchable on Cloudflare Workers, where + * `import.meta.url` is the literal string `"worker"` (not a real URL). + * + * @param options.isWorkerTarget - Returns true when the build targets a + * bundled worker runtime (Cloudflare `vite-plugin-cloudflare` or Nitro). + * The flag is resolved during Vite's `config` hook, before + * `applyToEnvironment` runs, so the getter is populated by the time the + * gate is evaluated. */ -export function createEdgeAssetImportMetaUrlPlugin(): Plugin { +export function createEdgeAssetImportMetaUrlPlugin(options: { + isWorkerTarget: () => boolean; +}): Plugin { // Build-only cache: absolute resolved path -> data URL string. Dev skips the // cache so asset edits are picked up without a restart. const cache = new Map(); @@ -100,11 +120,12 @@ export function createEdgeAssetImportMetaUrlPlugin(): Plugin { return { name: "vinext:edge-asset-import-meta-url", enforce: "pre", - // Run for all non-client environments (App Router RSC, App Router SSR, - // Pages Router SSR, Cloudflare worker). Vite's upstream plugin already - // covers `client`. + // Only the non-client server environments (App Router RSC/SSR, Pages + // Router SSR) of a bundled worker build. Vite's upstream plugin already + // covers `client`; a plain Node SSR build keeps a real `file://` + // `import.meta.url` and must not be rewritten (see the file header). applyToEnvironment(environment) { - return environment.config.consumer !== "client"; + return environment.config.consumer !== "client" && options.isWorkerTarget(); }, configResolved(config) { isBuild = config.command === "build"; @@ -132,7 +153,10 @@ export function createEdgeAssetImportMetaUrlPlugin(): Plugin { const toDataUrl = async (spec: string): Promise => { let file: string | undefined; if (spec.startsWith("./") || spec.startsWith("../")) { - file = path.resolve(moduleDir, spec); + // Strip any `?query`/`#hash` suffix so the path resolves to a real + // file on disk (mirrors the bare-specifier branch, which splits on + // `?` after resolution). + file = path.resolve(moduleDir, spec.split(/[?#]/, 1)[0]!); } else { // Bare specifier (e.g. `my-pkg/hello/world.json`). Resolve it // through the bundler so node_modules assets work. diff --git a/tests/edge-asset-import-meta-url.test.ts b/tests/edge-asset-import-meta-url.test.ts index 6c8ba086e..3a9cd5dcf 100644 --- a/tests/edge-asset-import-meta-url.test.ts +++ b/tests/edge-asset-import-meta-url.test.ts @@ -10,23 +10,29 @@ * `edge-compiler-can-import-blob-assets` suite (5 tests) was red. * * `vinext:edge-asset-import-meta-url` rewrites the expression to an inline - * `data:` URL so the asset is fetchable in both workerd and Node. This test - * drives the plugin's `transform` hook directly (mirroring the - * `edge.js` fixture from the upstream suite) and asserts the rewrite. + * `data:` URL so the asset is fetchable on workerd. The unit tests below drive + * the plugin's `transform` hook directly (mirroring the `edge.js` fixture from + * the upstream suite); the end-to-end block runs a real Pages Router edge-API + * build through the full Vite pipeline (filter + applyToEnvironment + the + * cross-plugin ordering that is the explicit motivation for the change). */ import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { build as viteBuild } from "vite"; import vinext from "../packages/vinext/src/index.js"; +import { createEdgeAssetImportMetaUrlPlugin } from "../packages/vinext/src/plugins/edge-asset-import-meta-url.js"; import type { Plugin } from "vite"; -function getPlugin(): Plugin { - const plugins = vinext() as Plugin[]; - const plugin = plugins.find((p) => p.name === "vinext:edge-asset-import-meta-url"); - if (!plugin) throw new Error("vinext:edge-asset-import-meta-url plugin not found"); - return plugin; +const ROOT_NODE_MODULES = path.resolve(import.meta.dirname, "../node_modules"); + +// Build the plugin directly so the test controls the worker-target gate. +// Defaults to a worker target since that is the environment the plugin is +// scoped to. +function getPlugin(isWorkerTarget = true): Plugin { + return createEdgeAssetImportMetaUrlPlugin({ isWorkerTarget: () => isWorkerTarget }); } function transformHandler(plugin: Plugin): (...args: any[]) => any { @@ -143,10 +149,112 @@ describe("vinext:edge-asset-import-meta-url", () => { expect(result).toBeNull(); }); - it("does not run in the client environment", () => { + it("matches the optional-chained `import.meta?.url` form", async () => { + const plugin = getPlugin(); + const code = "const url = new URL('../../src/text-file.txt', import.meta?.url)"; + const result = await transformHandler(plugin).call(makeCtx(), code, routePath); + expect(result, "expected import.meta?.url to be rewritten").not.toBeNull(); + const expected = + "data:text/plain;base64," + Buffer.from("Hello, from text-file.txt!").toString("base64"); + expect(result.code).toContain(`new URL(${JSON.stringify(expected)})`); + }); + + it("strips ?query/#hash from relative specifiers before resolving", async () => { const plugin = getPlugin(); - const applyToEnvironment = plugin.applyToEnvironment as (env: any) => boolean; - expect(applyToEnvironment({ config: { consumer: "client" } })).toBe(false); - expect(applyToEnvironment({ config: { consumer: "server" } })).toBe(true); + const code = "const url = new URL('../../src/text-file.txt?raw', import.meta.url)"; + const result = await transformHandler(plugin).call(makeCtx(), code, routePath); + expect(result, "expected query-suffixed specifier to resolve").not.toBeNull(); + const expected = + "data:text/plain;base64," + Buffer.from("Hello, from text-file.txt!").toString("base64"); + expect(result.code).toContain(`new URL(${JSON.stringify(expected)})`); }); + + it("only runs in non-client environments of a worker-target build", () => { + const workerPlugin = getPlugin(true); + const applyWorker = workerPlugin.applyToEnvironment as (env: any) => boolean; + expect(applyWorker({ config: { consumer: "client" } })).toBe(false); + expect(applyWorker({ config: { consumer: "server" } })).toBe(true); + + // Plain Node SSR build (no Cloudflare/Nitro plugin): never runs, because + // `import.meta.url` there is already a valid file:// URL. + const nodePlugin = getPlugin(false); + const applyNode = nodePlugin.applyToEnvironment as (env: any) => boolean; + expect(applyNode({ config: { consumer: "server" } })).toBe(false); + expect(applyNode({ config: { consumer: "client" } })).toBe(false); + }); +}); + +// End-to-end: build a real Pages Router edge-API route through the full Vite +// pipeline and assert the worker server bundle inlines the asset. Exercises +// the filter, applyToEnvironment gate, and plugin ordering — not just the +// transform handler in isolation. A stub plugin named `vite-plugin-cloudflare` +// flips vinext's worker-target detection (the gate only checks the plugin +// name), so the plugin runs without pulling in @cloudflare/vite-plugin. +describe("vinext:edge-asset-import-meta-url (end-to-end build)", () => { + async function buildEdgeRoute(opts: { workerTarget: boolean }): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-edge-e2e-")); + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-edge-e2e-out-")); + try { + await fs.symlink(ROOT_NODE_MODULES, path.join(tmpDir, "node_modules"), "junction"); + const apiDir = path.join(tmpDir, "pages", "api"); + const assetDir = path.join(tmpDir, "src"); + await fs.mkdir(apiDir, { recursive: true }); + await fs.mkdir(assetDir, { recursive: true }); + await fs.writeFile(path.join(assetDir, "text-file.txt"), "Hello, from text-file.txt!"); + await fs.writeFile( + path.join(apiDir, "edge.js"), + `export const config = { runtime: 'edge' }\n` + + `export default async function handler() {\n` + + ` const url = new URL('../../src/text-file.txt', import.meta.url)\n` + + ` return fetch(url)\n` + + `}\n`, + ); + await fs.writeFile( + path.join(tmpDir, "pages", "index.tsx"), + "export default function Home() {\n return
Hi
;\n}\n", + ); + + const stubCloudflarePlugin: Plugin = { name: "vite-plugin-cloudflare" }; + await viteBuild({ + root: tmpDir, + configFile: false, + plugins: [ + ...(opts.workerTarget ? [stubCloudflarePlugin] : []), + vinext({ disableAppRouter: true }), + ], + logLevel: "silent", + build: { + outDir, + emptyOutDir: false, + ssr: "virtual:vinext-server-entry", + rollupOptions: { output: { entryFileNames: "entry.js" } }, + }, + }); + + return await fs.readFile(path.join(outDir, "entry.js"), "utf8"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + await fs.rm(outDir, { recursive: true, force: true }).catch(() => {}); + } + } + + it("inlines the asset as a data: URL in the worker server bundle", async () => { + const entry = await buildEdgeRoute({ workerTarget: true }); + const expected = + "data:text/plain;base64," + Buffer.from("Hello, from text-file.txt!").toString("base64"); + expect( + entry.includes(expected), + `expected the edge route's new URL(...) to be inlined as a data: URL`, + ).toBe(true); + // The rewritten expression no longer references import.meta.url, which is + // the literal string "worker" at runtime and would throw on `new URL(...)`. + expect(entry).not.toContain("text-file.txt"); + }, 180_000); + + it("leaves the expression untouched in a plain Node SSR build", async () => { + const entry = await buildEdgeRoute({ workerTarget: false }); + // No Cloudflare/Nitro plugin → the edge-asset plugin must not run, so no + // data: URL is emitted for this asset. + expect(entry).not.toContain("data:text/plain;base64"); + }, 180_000); });