diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 5b21e8560..ff5fe6bc2 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,22 @@ 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 + // 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 + // 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({ + 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 new file mode 100644 index 000000000..d6d56c87b --- /dev/null +++ b/packages/vinext/src/plugins/edge-asset-import-meta-url.ts @@ -0,0 +1,221 @@ +/** + * 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 the Cloudflare Workers / Nitro worker build 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 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 + * "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)` (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*)?\)/; +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)` 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(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(); + let isBuild = false; + + return { + name: "vinext:edge-asset-import-meta-url", + enforce: "pre", + // 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" && options.isWorkerTarget(); + }, + 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("../")) { + // 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. + 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..3a9cd5dcf --- /dev/null +++ b/tests/edge-asset-import-meta-url.test.ts @@ -0,0 +1,260 @@ +/** + * 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 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"; + +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 { + 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("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 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); +});