Skip to content
This repository was archived by the owner on Aug 8, 2026. It is now read-only.

feat(vite-plugin): RSC build manifest for deploy - #50

Closed
agcty wants to merge 21 commits into
alchemy-run:mainfrom
agcty:feat/rsc-build
Closed

feat(vite-plugin): RSC build manifest for deploy#50
agcty wants to merge 21 commits into
alchemy-run:mainfrom
agcty:feat/rsc-build

Conversation

@agcty

@agcty agcty commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Status / stack

Draft until #47 lands or maintainers confirm this should be reviewed before that merge.

What

Adds the production-build half of RSC support to the distilled Cloudflare vite plugin. A vite build now emits a __distilled-build.json manifest describing the deployable Worker - its entry, full module set, compatibility data, module types, and static-assets directory - so a deployer such as Alchemy's Cloudflare.Vite can ship an RSC app without inferring the topology from directory convention or a single environment's in-memory bundle.

Dev support is #47. This PR is the build/deploy contract. The deploy-side consumer is alchemy-run/alchemy#615.

Why an explicit manifest, and why on disk

The official @cloudflare/vite-plugin does not infer the Worker from directory convention either: it writes an explicit deploy contract to disk (.wrangler/deploy/config.json plus per-worker Wrangler config) that Wrangler reads. This PR follows the same pattern, but emits a Wrangler-free, deployer-native manifest - keeping the information a deployer needs while avoiding a Wrangler-specific format.

On disk, specifically:

  • The worker bundles are already on disk - vite build writes dist/server/**, dist/ssr/**, dist/client/**, etc.
  • The manifest is a small index pointing at those files. A plain vite build produces it, it is inspectable, and it does not couple the consumer to plugin internals. Because Alchemy runs the build in-process it could capture the manifest object in memory, but it still has to read the module bundles from disk, so an on-disk manifest is the natural CLI-compatible contract.

The single-worker module set

The official plugin can model rsc -> ssr as multiple Workers with service bindings. The distilled plugin's RSC topology currently folds this into one uploaded Worker module set: the worker entry loads child-environment output through module imports, so the deployed Worker is a single Worker whose module set spans the entry (rsc) plus child (ssr) outputs. The manifest preserves the on-disk relative layout so those cross-environment imports resolve after upload.

That matches the current distilled runtime model and the Alchemy consumer in #615. Multi-worker/service-binding topology is a future manifest extension, not part of this PR.

The manifest

Current v2 shape:

{
  "version": 2,
  "workers": {
    "app": {
      "main": "server/entry.worker.js",
      "modules": [
        { "path": "server/entry.worker.js", "type": "esm" },
        { "path": "ssr/index.js", "type": "esm" },
        { "path": "ssr/assets-manifest.json", "type": "json" }
      ],
      "compatibilityDate": "2026-03-17",
      "compatibilityFlags": ["nodejs_compat"]
    }
  },
  "assets": {
    "directory": "client",
    "runWorkerFirst": ["/api/*"]
  }
}

All paths are POSIX-style and relative to the manifest directory (the build root). main is the distilled worker-entry chunk, identified by its module marker, so it is not confused with a framework's own entry. Module kind is explicit (esm, wasm, data, text, json) using the same module rules the build emits.

A deployer reads the manifest, uploads workers.app.modules as one module set with workers.app.main as the entry, and serves assets.directory with the optional asset-routing settings.

buildApp: defer to the framework

The plugin previously imposed its own buildApp loop, which double-built every environment on top of plugin-rsc's multi-pass orchestration. It now defers to the framework's buildApp and writes the manifest in a buildApp hook with order: "post", after every environment is on disk - the same broad timing hook the official plugin uses.

Edge cases handled by design

  • Asset/route ignoring - the manifest lives beside the build root, not in client, so it is not served as a client asset.
  • Stale output - worker output dirs are emptied up front so the on-disk module set reflects only the current build.
  • Stale manifest - a build that emits no manifest (SPA/assets-only, or a refused split output layout) removes any prior manifest.
  • Module kinds - the module set covers all Cloudflare module kinds via explicit manifest tags, not just JS/Wasm.
  • Scan passes - entry capture skips non-writing scan passes and records the real worker entry.

Verified

Across both RSC fixtures and a non-RSC worker fixture:

  • An AST-accurate scan (Bun.Transpiler) confirms every relative import in the worker module set resolves within the set and no client assets leak in.
  • main is always the real worker entry.
  • A pure SPA (static-website) emits no manifest and removes any stale prior manifest.
  • Build-mode tests lock the contract, including stale-output/stale-manifest handling.
  • format / lint / typecheck are clean.

Downstream validation in alchemy-run/alchemy#615:

  • Cloudflare.Vite reads this v2 manifest and uploads the manifest-selected module set with explicit content types.
  • A live Cloudflare deploy test passed for one Vite Worker with a local Durable Object binding: static assets served, /api/reset returned ok, and /api/count incremented through the DO.
  • alchemy dev also served the same fixture locally through the distilled runtime and Vite dev server.

Limitations & follow-ups

  • Custom build.outDir with the RSC topology is unsupported. plugin-rsc keeps the ssr child at dist/ssr even when the entry moves, so the worker output spans two roots. The plugin detects this, emits nothing, and removes any stale manifest rather than producing a broken one.
  • The manifest currently models the distilled single-worker topology. Multi-worker/service-binding topology can be added later by extending workers beyond workers.app after a deployer needs that shape.
  • Sourcemap upload signaling and export-shape hints are possible future additions. Bindings, secrets, migrations, and resource ownership remain deploy-time inputs, not build outputs.

Scope


Written on behalf of agcty.

@agcty
agcty marked this pull request as ready for review June 15, 2026 21:21
@agcty
agcty marked this pull request as draft June 15, 2026 21:22
agcty added 20 commits June 15, 2026 23:55
The dev plugin hardcoded a single worker environment named "ssr". RSC
apps (@vitejs/plugin-rsc: React Router RSC, Waku) run the worker in the
"rsc" environment (react-server condition) and load "ssr" from it at
runtime, so a single-env assumption can't host them — dev crashed with
"registerMissingImport is not supported in dev rsc" and, once past that,
failed SSR with a duplicate-React null dispatcher.

Add a viteEnvironment { name, childEnvironments } option (mirroring the
official @cloudflare/vite-plugin) and generalize the single "ssr" env to
an entry env plus its children:

- each worker env gets the workerd resolve conditions + dependency
  pre-bundling (noDiscovery: false), built per-env so each carries its
  own optimizeDeps.entries (a shared entries seeded from the rsc main
  left the ssr child with no scan root, causing a mid-session
  re-optimization that re-hashed and duplicated React)
- dev connects a module runner for the entry env and each child, and
  awaits each env's depsOptimizer.init() before its runner imports
- the worker entry environment name is the configured entry (was "ssr")

Default (no viteEnvironment) is unchanged: entry "ssr", no children.
Minimal @vitejs/plugin-rsc starter (react-rsc) and a React Router on RSC
app (react-router-rsc), both wired through the child-environment model
(viteEnvironment: { name: rsc, childEnvironments: [ssr] }) to exercise
RSC dev.
- restore the exact non-RSC optimizeDeps.entries behavior: the per-env
  input fallback now applies only to multi-environment (RSC) topologies;
  single-worker apps keep entries solely from an explicit `main` (was a
  behavioral change vs the original single-"ssr" code)
- give the actionable maintainer-style hint when a runner is missing for
  an environment (point at viteEnvironment.childEnvironments)

Verified: RR-RSC fixture still renders; non-RSC (static-website) dev
unchanged.
… pattern)

Adds a /worker-render endpoint that loads a custom "worker-ssr" module
from the ssr environment via loadModule("ssr", "worker-ssr") instead of
importing react-dom/server in the rsc worker entry — the blessed pattern
from agcty/vite-rsc-worker-env-repro#1. Proves the distilled plugin
handles: multiple ssr inputs (framework index + worker-ssr), the worker
loading a non-index ssr module cross-environment, and react-dom/server
resolving in the ssr child (it would fail under the rsc react-server
condition). Verified: GET /worker-render → 200 with rendered HTML; / still renders.
Reject viteEnvironment configs that would silently corrupt the generated
per-env config via computed keys: name "client" (the reserved browser
env), children including "client", children colliding with the entry,
and duplicate children. Mirrors @cloudflare/vite-plugin. (review finding)
Boot `vite dev` and assert the RSC routes render: react-rsc (server +
client component + server action) and react-router-rsc (home, /about,
and /worker-render exercising loadModule("ssr","worker-ssr")). Makes RSC
dev support an automated, runnable check (`bun test`). Build-mode is a
separate track and intentionally not covered.
A reviewer flagged that the rsc env's resolve.conditions ordering depends
on plugin order: with cloudflare() before rsc(), react-server lands at
index 5 instead of 0. Verified via resolveConfig probe that the ordering
does change — but export-condition resolution is set-membership (React's
own exports key order decides), so react-server being present is what
matters, not its array index. Adds vite.config.cf-first.ts (cloudflare
before rsc) + a test booting it: the app renders 200 with a working RSC
flight stream, proving order-independence. Not a correctness bug; locked
so it can't silently regress.
Address review: empty worker output dirs up front so the on-disk module walk reflects only the current build (Vite skips emptyOutDir for envs the framework scan-passes mark rendered); include all Cloudflare module kinds (.bin/.txt/.html/.sql) via MODULE_RULES, not just .js/.mjs/.wasm; and remove any stale manifest when a build emits none.
A pure SPA has no worker entry environment in the builder, so the handler returned before reaching the manifest removal, leaving a prior manifest in place. Resolve the manifest root from the entry or client output and remove any stale manifest before the no-worker check.
@john-royal

Copy link
Copy Markdown
Contributor

Replaced by #55. I adapted a lot of your work so you're listed as a co-author.

@john-royal john-royal closed this Jun 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants