You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The build-time prerender server loads the workerd-targeted worker bundle (dist/server/index.js) into a bare Node.js process. Any user module in the server graph that does import { env } from "cloudflare:workers" — the documented, recommended way to access Cloudflare bindings in vinext — throws ERR_MODULE_NOT_FOUND at module-load time, before a single route renders.
For Cloudflare deployments we should stop trying to make local/bare-Node prerender work for apps that depend on the workerd runtime, and instead lean on the experimental prewarming path (fetch the deployed Worker) for those apps. At minimum, the bare-Node prerender server must fail with a clear, actionable error instead of a raw ERR_MODULE_NOT_FOUND.
Discovered during review of #2901 (see PR thread). Not caused by that PR — it is a pre-existing architectural gap — but the two features interact (see "Relationship to #2901" below).
Evidence
The prerender server imports the deployed worker bundle into bare Node.
The prerender worker child process calls startProdServer(...) (packages/vinext/src/build/prerender-server-entry.ts:29).
startProdServer resolves the App Router entry to dist/server/index.js (packages/vinext/src/server/prod-server.ts:1282-1284) and loads it via a plain import() in importServerEntryModule (prod-server.ts:220-226, invoked at prod-server.ts:1561).
dist/server/index.js is the workerd bundle produced by @cloudflare/vite-plugin (RSC env runs in workerd), not a Node-targeted build.
cloudflare:workers survives in that bundle as a bare external import.
README: "In production builds, the import is externalized so workerd resolves it at runtime" (README.md:314). Node has no cloudflare:workers module, so import() of the bundle rejects at load time. The failure is at the ESM specifier level, so it fires even when env is only dereferenced lazily inside a function.
This is the documented, recommended binding pattern — not an edge case.
README.md:303-330 and the migrate-to-vinext skill both instruct users to import { env } from "cloudflare:workers" and explicitly say "You do not need getPlatformProxy()… cloudflare:workers is the recommended way to access bindings."
Real in-repo examples do exactly this: apps/web/app/lib/db/client.ts:2, apps/web/app/lib/benchmarks/server.ts:1.
No Node-side shim/alias exists for cloudflare:* in the prerender context.cloudflare:* handling lives entirely in @cloudflare/vite-plugin and only targets workerd.
Mitigating factors (why it's not on fire today)
Build-time prerender is opt-in (--prerender-all, output: 'export', or prerender: { routes: "*" }), so a default vinext build / Cloudflare deploy never starts this server.
When it does trigger, it currently fails at build time (loud), just with an unhelpful ERR_MODULE_NOT_FOUND rather than an actionable message.
For Cloudflare, don't focus on local/bare-Node prerender. Instead, restructure experimental prewarming to be the primary "make prerendered paths functional" mechanism for Cloudflare:
Deploy the Worker first.
Use the deployed Worker to pull out the prerender manifest info (the concrete paths / fallback shells), rather than depending on a local render that can't load cloudflare:workers.
Prerender those paths against the deployed Worker (which runs in workerd and can resolve cloudflare:workers bindings natively), so they end up fully functional/warmed.
Rationale: the deployed Worker is the only environment that can correctly execute a graph that imports cloudflare:workers. A bare-Node prerender cannot, by construction, produce a "fully functional" render for those apps.
For the bare-Node prerender/prewarm server that remains, improve error logging so a cloudflare:* import failure produces a clear, actionable message instead of a raw ERR_MODULE_NOT_FOUND.
Current prewarming shape (for design context)
Today's experimental CDN prewarming already fetches the deployed Worker, which is the right runtime:
warmCdnCache / warmCdnCacheFromPrerender (packages/cloudflare/src/cdn-warm.ts:226, 281) issue plain external GETs to the deployed Worker; fetchWithTimeout only sets User-Agent: vinext-cloudflare-cdn-warm (cdn-warm.ts:145-161).
But the paths come from a locally-emitted prerender manifest (readPrerenderWarmPaths reads dist/server/vinext-prerender.json, cdn-warm.ts:83-121), and today those paths are produced by the local prerender step (deploy.ts:877-902), which is exactly the bare-Node path that breaks for cloudflare:workers apps.
So the proposed design is essentially: decouple manifest/path discovery + rendering from the local Node prerender, and source both from the deployed Worker instead. The deploy already runs prerender (step 6a) before deploy + warmup (step 7) at deploy.ts:885-954; the new design would flip that for Cloudflare so warmup drives functional rendering against workerd.
#2901 hardens the trusted x-vinext-prerender-route-params transport so that only the Node prerender path (which sets hostRuntime: "node" via createNodeExecutionContext, prod-server.ts) can re-attach verified route params, while a deployed Worker (platform ExecutionContext, no hostRuntime: "node") always drops the header (app-router-entry.ts:158-170).
Impact on the proposed design:
The trusted route-params payload is currently a Node-prerender-only transport. It is the mechanism that threads encoded params / fallbackParamNames into dynamic/fallback prerenders. If we move functional prerendering to run against the deployed Worker (workerd, hostRuntime: "worker"), that Worker path will — correctly, per fix(app-router): reject unverified Worker prerender params #2901 — drop this payload. So the new design cannot rely on the header to deliver route params to the deployed Worker.
If the deployed-Worker prewarm needs trusted route params (e.g. to render dynamic fallback shells for specific param sets), it will need its own explicit execution-context flag (not an inbound header), consistent with the pattern fix(app-router): reject unverified Worker prerender params #2901 establishes. A forged header must never be trusted at the external Worker boundary.
The plain-GET prewarm paths (concrete, already-known URLs) don't need trusted route params at all, so simple concrete-path warming is unaffected by fix(app-router): reject unverified Worker prerender params #2901 and works against the deployed Worker today.
Acceptance criteria
Bare-Node prerender no longer crashes with a raw ERR_MODULE_NOT_FOUND for apps importing cloudflare:*. Either it's skipped for Cloudflare targets with a clear message, or the import failure is surfaced as an actionable error ("prerender cannot run in Node because your app imports cloudflare:workers; do X").
Cloudflare prewarming can source prerender manifest/path info and render functional pages against the deployed Worker, not a local Node render, for apps that depend on workerd-only modules.
Dev/prod parity preserved; concrete-path warming (no route params) continues to work.
Options to explore
Deployed-Worker-driven prewarm (preferred, per proposal): deploy → read manifest/paths from the deployed Worker → render/warm those paths against the deployed Worker (workerd resolves cloudflare:workers).
Run prerender in workerd/miniflare instead of bare Node when the target is Cloudflare.
Node-side cloudflare:* stub/env proxy for the prerender context (e.g. backed by .dev.vars / getPlatformProxy()-style values) — likely a partial solution only.
Minimum bar: detect cloudflare:* in the server graph and emit a clear, actionable error instead of ERR_MODULE_NOT_FOUND.
Summary
The build-time prerender server loads the workerd-targeted worker bundle (
dist/server/index.js) into a bare Node.js process. Any user module in the server graph that doesimport { env } from "cloudflare:workers"— the documented, recommended way to access Cloudflare bindings in vinext — throwsERR_MODULE_NOT_FOUNDat module-load time, before a single route renders.For Cloudflare deployments we should stop trying to make local/bare-Node prerender work for apps that depend on the workerd runtime, and instead lean on the experimental prewarming path (fetch the deployed Worker) for those apps. At minimum, the bare-Node prerender server must fail with a clear, actionable error instead of a raw
ERR_MODULE_NOT_FOUND.Discovered during review of #2901 (see PR thread). Not caused by that PR — it is a pre-existing architectural gap — but the two features interact (see "Relationship to #2901" below).
Evidence
The prerender server imports the deployed worker bundle into bare Node.
startProdServer(...)(packages/vinext/src/build/prerender-server-entry.ts:29).startProdServerresolves the App Router entry todist/server/index.js(packages/vinext/src/server/prod-server.ts:1282-1284) and loads it via a plainimport()inimportServerEntryModule(prod-server.ts:220-226, invoked atprod-server.ts:1561).dist/server/index.jsis the workerd bundle produced by@cloudflare/vite-plugin(RSC env runs in workerd), not a Node-targeted build.cloudflare:workerssurvives in that bundle as a bare external import.README.md:314). Node has nocloudflare:workersmodule, soimport()of the bundle rejects at load time. The failure is at the ESM specifier level, so it fires even whenenvis only dereferenced lazily inside a function.This is the documented, recommended binding pattern — not an edge case.
README.md:303-330and the migrate-to-vinext skill both instruct users toimport { env } from "cloudflare:workers"and explicitly say "You do not needgetPlatformProxy()…cloudflare:workersis the recommended way to access bindings."apps/web/app/lib/db/client.ts:2,apps/web/app/lib/benchmarks/server.ts:1.No Node-side shim/alias exists for
cloudflare:*in the prerender context.cloudflare:*handling lives entirely in@cloudflare/vite-pluginand only targets workerd.Mitigating factors (why it's not on fire today)
--prerender-all,output: 'export', orprerender: { routes: "*" }), so a defaultvinext build/ Cloudflare deploy never starts this server.ERR_MODULE_NOT_FOUNDrather than an actionable message.Proposed design (from @james-elicx)
For Cloudflare, don't focus on local/bare-Node prerender. Instead, restructure experimental prewarming to be the primary "make prerendered paths functional" mechanism for Cloudflare:
cloudflare:workers.cloudflare:workersbindings natively), so they end up fully functional/warmed.Rationale: the deployed Worker is the only environment that can correctly execute a graph that imports
cloudflare:workers. A bare-Node prerender cannot, by construction, produce a "fully functional" render for those apps.For the bare-Node prerender/prewarm server that remains, improve error logging so a
cloudflare:*import failure produces a clear, actionable message instead of a rawERR_MODULE_NOT_FOUND.Current prewarming shape (for design context)
Today's experimental CDN prewarming already fetches the deployed Worker, which is the right runtime:
--experimental-warm-cdn-cache(packages/cloudflare/src/deploy.ts:160,198).warmCdnCache/warmCdnCacheFromPrerender(packages/cloudflare/src/cdn-warm.ts:226,281) issue plain externalGETs to the deployed Worker;fetchWithTimeoutonly setsUser-Agent: vinext-cloudflare-cdn-warm(cdn-warm.ts:145-161).readPrerenderWarmPathsreadsdist/server/vinext-prerender.json,cdn-warm.ts:83-121), and today those paths are produced by the local prerender step (deploy.ts:877-902), which is exactly the bare-Node path that breaks forcloudflare:workersapps.So the proposed design is essentially: decouple manifest/path discovery + rendering from the local Node prerender, and source both from the deployed Worker instead. The deploy already runs prerender (step 6a) before deploy + warmup (step 7) at
deploy.ts:885-954; the new design would flip that for Cloudflare so warmup drives functional rendering against workerd.Relationship to #2901 (and impact on this design)
#2901 hardens the trusted
x-vinext-prerender-route-paramstransport so that only the Node prerender path (which setshostRuntime: "node"viacreateNodeExecutionContext,prod-server.ts) can re-attach verified route params, while a deployed Worker (platformExecutionContext, nohostRuntime: "node") always drops the header (app-router-entry.ts:158-170).Impact on the proposed design:
fallbackParamNamesinto dynamic/fallback prerenders. If we move functional prerendering to run against the deployed Worker (workerd,hostRuntime: "worker"), that Worker path will — correctly, per fix(app-router): reject unverified Worker prerender params #2901 — drop this payload. So the new design cannot rely on the header to deliver route params to the deployed Worker.Acceptance criteria
ERR_MODULE_NOT_FOUNDfor apps importingcloudflare:*. Either it's skipped for Cloudflare targets with a clear message, or the import failure is surfaced as an actionable error ("prerender cannot run in Node because your app importscloudflare:workers; do X").Options to explore
cloudflare:workers).cloudflare:*stub/env proxy for the prerender context (e.g. backed by.dev.vars/getPlatformProxy()-style values) — likely a partial solution only.cloudflare:*in the server graph and emit a clear, actionable error instead ofERR_MODULE_NOT_FOUND.References
packages/vinext/src/build/prerender-server-entry.ts— bare-Node prerender childpackages/vinext/src/server/prod-server.ts:220-226,1282-1284,1561— loadsdist/server/index.jsin Nodepackages/vinext/src/server/app-router-entry.ts:158-170— fix(app-router): reject unverified Worker prerender params #2901 hostRuntime gatepackages/cloudflare/src/cdn-warm.ts:83-121,145-161,226-288— prewarming + manifest pathspackages/cloudflare/src/deploy.ts:160,198,855-954— prerender + warmup ordering in deployREADME.md:303-330— recommendedcloudflare:workersbinding pattern