Skip to content

fix(build): inline new URL(asset, import.meta.url) blob imports for edge (#1824)#1833

Open
james-elicx wants to merge 2 commits into
mainfrom
fix/issue-1824-edge-blob-asset-imports
Open

fix(build): inline new URL(asset, import.meta.url) blob imports for edge (#1824)#1833
james-elicx wants to merge 2 commits into
mainfrom
fix/issue-1824-edge-blob-asset-imports

Conversation

@james-elicx
Copy link
Copy Markdown
Member

Summary

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. 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. The whole upstream edge-compiler-can-import-blob-assets suite (5 tests) was red.

Adds a vinext:edge-asset-import-meta-url Vite plugin that, in non-client (server/worker) environments, rewrites the entire new URL("<spec>", import.meta.url) expression to an inline data: URL literal computed from the referenced file at build time.

A data: URL:

  • is a valid absolute URL, so new URL(...) never throws and never depends 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) shape, which the fetch(new URL(...))-only OG inliner does not cover;
  • is fetchable in both workerd and Node, so no asset file needs to be emitted to / served from the worker output.

Bare specifiers (node_modules assets like my-pkg/hello/world.json) resolve through the bundler's this.resolve. Absolute/remote URLs (new URL("https://..."), new URL("/", "https://...")) and /* @vite-ignore */-annotated specifiers are left untouched.

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. The new plugin is registered immediately after the OG inliner (both are enforce: "pre", so array order sequences them) so the OG inliner's verbatim-pattern match is preserved.

Relation to #1346 / #1640

PR #1640 (still open, for #1346) targets the Node SSR path: 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. The two are complementary; #1640 is not merged, so there is no overlap on main.

Test plan

  • New focused test tests/edge-asset-import-meta-url.test.ts drives the plugin's transform hook with the exact patterns from the upstream edge.js fixture (relative text asset, binary image with correct mime, bare-specifier node_modules asset) and asserts each is rewritten to the expected data: URL; also asserts absolute/remote URLs and missing files are left untouched, and that the plugin does not run in the client environment.
  • Verified end-to-end against a real Pages Router edge-API build (throwaway harness, removed before commit): the worker entry emits new URL(\data:text/plain;base64,...`)with no residualimport.meta.url`.
  • tests/og-assets.test.ts and tests/import-meta-url.test.ts still green (no regression from plugin ordering).
  • vp check clean on all changed files.

Closes #1824

…dge (#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
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1833
npm i https://pkg.pr.new/vinext@1833

commit: 4127760

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 8, 2026

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(build): inline new URL(asset, import.meta.url) blob imports for edge

Solid, well-documented fix. It matches the upstream edge-compiler-can-import-blob-assets fixture patterns exactly (relative text/binary assets + bare-specifier node_modules asset, leaving absolute/remote URLs alone), vp check is clean, and the new test plus tests/og-assets.test.ts / tests/import-meta-url.test.ts are green. The enforce: "pre" ordering relative to vinext:og-inline-fetch-assets is correct, and since the existing vinext:import-meta-url plugin is enforce: "post", this pre plugin runs ahead of it and rewrites the new URL(...) before import.meta.url would be normalized — no conflict there.

A few things worth considering before merge:

1. Plugin runs in the Node SSR environment too, not just the edge/worker — possible regression (medium)

applyToEnvironment admits every non-client environment (consumer !== "client"), so this also runs in the plain Node SSR build path (index.ts:2216, shouldInjectPlainPagesEnvironments, consumer: "server"). On Node, import.meta.url is a valid file:// URL, so new URL("./asset", import.meta.url) already worked natively there.

For the fetch(url) use case the data-URL rewrite is harmless (the body's "fetchable in both workerd and Node" claim holds). But the rewrite changes the semantics of the resulting URL object for any code that does not just fetch it:

  • fileURLToPath(new URL("./x", import.meta.url)) throws on Node after the rewrite: fileURLToPath("data:...")The URL must be of scheme file.
  • new URL("./x", import.meta.url).pathname returns the data-URL body (text/plain;base64,...) instead of a filesystem-like path.

The vinext:og-inline-fetch-assets plugin handles the verbatim readFileSync(fileURLToPath(new URL(...))) shape and runs first, so the common single-expression form is covered. But the split form — const u = new URL("./x", import.meta.url); readFileSync(fileURLToPath(u)) — is not matched by the OG inliner, so this plugin would rewrite the standalone new URL and break a previously-working Node SSR module.

The PR body frames the Node SSR path (PR #1640) as "complementary, no overlap on main," but by running in all non-client environments this plugin does affect Node SSR behavior today. Consider scoping it to the edge/worker environment (or gating on the edge runtime) so the Node SSR path keeps the native file:// URL. If the broad scope is intentional, it's worth a one-line note in the plugin doc explaining why Node SSR is deliberately included.

2. No end-to-end / build test — only the transform hook is exercised (medium)

The new test calls the transform handler directly with a hand-rolled this context (makeCtx), which bypasses applyToEnvironment, the transform.filter, and the real enforce: "pre" ordering against vinext:og-inline-fetch-assets and vinext:import-meta-url. The PR body notes a throwaway harness verified a real edge build but it was removed.

Given the ordering between three pre/post plugins is the most fragile part of this change (and the explicit motivation for the array placement), an integration test that builds a small Pages-Router edge API fixture using the upstream edge.js shape and asserts the worker output contains the inlined data: URL with no residual import.meta.url would protect against silent ordering regressions. A fixture under tests/fixtures/pages-basic/pages/api/ would be a natural home.

3. Relative specifiers with a query/hash are not stripped before resolving (low)

new URL("./a.png?inline", import.meta.url) captures spec = "./a.png?inline", and the relative branch does path.resolve(moduleDir, spec) without stripping the query, so readFile misses and the expression is (gracefully) left untouched. The bare-specifier branch already strips the query via resolved?.id?.split("?")[0] (edge-asset-import-meta-url.ts:140). For consistency and to actually inline query-suffixed relative assets, strip ?/# from spec in the relative branch too (edge-asset-import-meta-url.ts:134-135). Vite's own asset plugin strips queries here. Low severity since it degrades safely.

4. Minor parity gap: import.meta?.url not matched (low)

ASSET_IMPORT_META_URL_RE (edge-asset-import-meta-url.ts:67) matches import.meta.url but not the optional-chained import.meta?.url. The existing vinext:import-meta-url plugin does handle the chained form (import-meta-url.ts:544-549). Unlikely in real code; noting only for completeness.

Nits

  • The transform.filter and the iteration regex are the same source pattern, with a fresh /g copy built per call — correct, and the comment explaining the lastIndex statefulness is appreciated.
  • The doc claims a trailing .href/.pathname accessor is "preserved." Syntactically true (the member access stays valid), but .pathname on the resulting data URL is meaningless (see #1). Worth a half-sentence clarifying that only .href/fetch consumers behave as before.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 8, 2026

Review posted to PR #1833.

Summary

I reviewed the edge new URL(asset, import.meta.url) inlining plugin. The implementation is well-documented, matches the upstream Next.js edge-compiler-can-import-blob-assets fixture patterns exactly, passes vp check, and the new + related tests are green. The plugin ordering (enforce: "pre", placed after the OG inliner; ahead of the enforce: "post" import-meta-url plugin) is correct.

I raised four issues:

  1. Node SSR scope (medium)applyToEnvironment admits every non-client environment, so the plugin also runs in the plain Node SSR build (index.ts:2216) where import.meta.url is already a valid file:// URL. For fetch(url) this is harmless, but it changes URL semantics: fileURLToPath(new URL(...)) throws on the rewritten data URL and .pathname returns garbage. The split form const u = new URL(...); readFileSync(fileURLToPath(u)) isn't caught by the OG inliner, so a previously-working Node SSR module could break. Suggested scoping to the edge/worker environment.

  2. No end-to-end test (medium) — the test only drives the transform hook with a mock this, bypassing applyToEnvironment, the filter, and the real cross-plugin ordering that's the explicit motivation for the change. Recommended a Pages-Router edge-API fixture build test.

  3. Query/hash specifiers (low) — relative specifiers like ./a.png?inline aren't query-stripped before path.resolve, unlike the bare-specifier branch; degrades safely but inconsistent.

  4. import.meta?.url not matched (low) — minor parity gap vs. the existing import-meta-url plugin.

Plus nits on the .pathname "preserved" doc claim.

github run

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.
@james-elicx
Copy link
Copy Markdown
Member Author

Thanks for the review — addressed all four findings:

  1. Node SSR scope — the plugin now takes an isWorkerTarget getter (Cloudflare/Nitro) and applyToEnvironment returns false in a plain Node SSR build, so the fileURLToPath/.pathname semantics there are untouched.
  2. End-to-end test — added a real Pages Router edge-API build test through the full Vite pipeline (filter + applyToEnvironment + plugin ordering), with a negative case asserting no rewrite in a plain Node SSR build.
  3. Query/hash specifiers — relative specifiers are now ?query/#hash-stripped before resolving, matching the bare-specifier branch.
  4. import.meta?.url — now matched (parity with vinext:import-meta-url).

Also fixed the trailing-accessor doc nit.

/bigbonk review for issues

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 8, 2026

Re-review: all four findings addressed

Re-reviewed at 4127760. Each finding is resolved correctly, and I re-ran the suite locally — tests/edge-asset-import-meta-url.test.ts (10 tests, incl. both e2e build cases), tests/og-assets.test.ts, tests/import-meta-url.test.ts all green, and vp check clean on all three changed files.

1. Node SSR scope ✅

applyToEnvironment now gates on consumer !== "client" && isWorkerTarget(), with isWorkerTarget = () => hasCloudflarePlugin || hasNitroPlugin (index.ts:4319). The flags are set inside the config hook (index.ts:1556/1564) and shared via plugin-closure state, so they're populated before applyToEnvironment runs — confirmed by the ordering note in the doc and verified against Vite's hook order.

This is the right discriminator: in a Cloudflare/Nitro build the rsc/ssr environments are the worker bundle (everything bundled, import.meta.url === "worker"), so all non-client envs need the rewrite; in a plain Node SSR build the plugin never runs and the native file:// URL — and fileURLToPath(...)/.pathname semantics — is left intact. The const u = new URL(...); readFileSync(fileURLToPath(u)) split-form regression I flagged can no longer happen on Node.

2. End-to-end test ✅

The new (end-to-end build) block builds a real Pages Router edge-API route through the full Vite pipeline (filter + applyToEnvironment + plugin ordering), driven by a vite-plugin-cloudflare stub that flips worker-target detection. The positive case asserts the inlined data: URL with no residual asset path; the negative case asserts no data: rewrite in a plain Node SSR build. This covers exactly the cross-plugin ordering/gating path the transform-only unit tests bypassed.

3. Query/hash specifiers ✅

The relative branch now strips ?query/#hash via spec.split(/[?#]/, 1)[0] before path.resolve (edge-asset-import-meta-url.ts:159), matching the bare-specifier branch. The mime type is derived from the stripped path, which is correct. Covered by the new "strips ?query/#hash" test.

4. import.meta?.url

ASSET_IMPORT_META_URL_RE now uses import\.meta\??\.url (edge-asset-import-meta-url.ts:79), matching both the plain and optional-chained forms — parity with vinext:import-meta-url. Covered by the new optional-chaining test.

Doc nit ✅

The regex comment no longer claims .pathname is "preserved" — it now says only the new URL(...) expression is replaced and a trailing .href reads off the rewritten data URL, and the file header explicitly notes .pathname is not a filesystem path on a data: URL.

LGTM. No new issues found.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 8, 2026

Posted the re-review to PR #1833. Summary:

All four findings (plus the doc nit) are correctly addressed in commit 4127760, and I verified locally:

  1. Node SSR scopeapplyToEnvironment now gates on consumer !== "client" && isWorkerTarget(). The isWorkerTarget getter reads hasCloudflarePlugin || hasNitroPlugin, which are set in the config hook (shared closure state, populated before applyToEnvironment). This is the correct discriminator: in a worker build, rsc/ssr environments are the worker bundle; in plain Node SSR the plugin never runs, so the split-form fileURLToPath regression can no longer occur.

  2. E2E test — a real Pages Router edge-API build through the full Vite pipeline, with a positive (data URL inlined) and negative (no rewrite in Node SSR) case.

  3. Query/hash — relative specifiers stripped via split(/[?#]/, 1)[0] before resolving, matching the bare-specifier branch.

  4. import.meta?.url — regex now uses import\.meta\??\.url.

Verification: 10 tests pass (incl. both e2e build cases), og-assets/import-meta-url tests green, vp check clean. No new issues found — LGTM.

github run

@james-elicx james-elicx marked this pull request as ready for review June 8, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Edge runtime: new URL(asset, import.meta.url) blob/asset imports fail to fetch

1 participant