Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
197 changes: 197 additions & 0 deletions packages/vinext/src/plugins/edge-asset-import-meta-url.ts
Original file line number Diff line number Diff line change
@@ -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("<spec>", import.meta.url)` expression to a
* `data:` URL literal (`new URL("data:<mime>;base64,<bytes>")`) 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("<spec>", 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<string, string> = {
".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("<spec>", 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<string, string>();
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<string | null> => {
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(<absolute>)` 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;
}
152 changes: 152 additions & 0 deletions tests/edge-asset-import-meta-url.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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);
});
});
Loading