cloudflare / cloudflare/vinext
Cloudflare prerender/prewarm: bare-Node prerender crashes on `cloudflare:workers` imports — drive functional prewarming from the deployed Worker instead
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## 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](https://github.com/cloudflare/vinext/pull/2901)). **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 `GET`s 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 #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 #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 #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 #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
- PR #2901 — https://github.com/cloudflare/vinext/pull/2901 (trusted prerender route-params hardening; discovery thread)
- Issue #2781 — https://github.com/cloudflare/vinext/issues/2781 (the security issue #2901 fixes)
- `packages/vinext/src/build/prerender-server-entry.ts` — bare-Node prerender child
- `packages/vinext/src/server/prod-server.ts:220-226`, `1282-1284`, `1561` — loads `dist/server/index.js` in Node
- `packages/vinext/src/server/app-router-entry.ts:158-170` — #2901 hostRuntime gate
- `packages/cloudflare/src/cdn-warm.ts:83-121`, `145-161`, `226-288` — prewarming + manifest paths
- `packages/cloudflare/src/deploy.ts:160`, `198`, `855-954` — prerender + warmup ordering in deploy
- `README.md:303-330` — recommended `cloudflare:workers` binding pattern
Contributor guide
Research direction
Start with packages/cloudflare/src/deploy.ts:855-954 and packages/cloudflare/src/cdn-warm.ts:83-121,226-288 to trace prerender and warmup ordering and manifest paths. Then read packages/vinext/src/build/prerender-server-entry.ts and packages/vinext/src/server/prod-server.ts:220-226,1282-1284,1561 to understand the bare-Node load failure. Done means Cloudflare prewarming renders against the deployed Worker, preserves the #2901 route-parameter boundary, and reports an actionable error for unsupported local prerendering.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript, vite
- Domain
- cloud, devops
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100