cloudflare / cloudflare/vinext

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

Open
#2,911 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.