Skip to content

Cloudflare prerender/prewarm: bare-Node prerender crashes on cloudflare:workers imports — drive functional prewarming from the deployed Worker instead #2911

Description

@ask-bonk

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 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

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:

  1. Deploy the Worker first.
  2. 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.
  3. 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:

  • Flag: --experimental-warm-cdn-cache (packages/cloudflare/src/deploy.ts:160, 198).
  • 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.

Relationship to #2901 (and impact on this design)

#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 #2901drop 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.
  • If the deployed-Worker prewarm path needs trusted route params, it uses an explicit context flag (not an inbound header), preserving the fix(app-router): reject unverified Worker prerender params #2901 security boundary.
  • Dev/prod parity preserved; concrete-path warming (no route params) continues to work.

Options to explore

  1. 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).
  2. Run prerender in workerd/miniflare instead of bare Node when the target is Cloudflare.
  3. Node-side cloudflare:* stub/env proxy for the prerender context (e.g. backed by .dev.vars / getPlatformProxy()-style values) — likely a partial solution only.
  4. Minimum bar: detect cloudflare:* in the server graph and emit a clear, actionable error instead of ERR_MODULE_NOT_FOUND.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions