diff --git a/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md b/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md
index c9b7f38675..959b4fd34c 100644
--- a/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md
+++ b/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md
@@ -195,45 +195,45 @@ Routes.tsx ← 4 routes added inside `. Typed params, globs, redirects, `` layouts, `` auth guards. Named route helpers. Link/navigate/useLocation/useParams. |
-| auth | Provider-agnostic. `createAuth(provider)` → {AuthProvider, useAuth}. State: loading/authenticated/user. \*SSR/RSC: ServerAuthProvider injects state for SSR. |
-| web | App shell. RedwoodProvider. createCell (GraphQL state→UI). Apollo (useQuery/useMutation). Head/MetaTags. FatalErrorBoundary. Toast. FetchConfig. |
-| api | Server runtime. Auth extraction. Validations (validate/validateWith). CORS. Logging (Pino). Cache (Redis/Memcached/InMemory). Webhooks. RedwoodError. |
-| graphql-server | Yoga factory. Merge SDLs (schema) + services (resolvers) + directives + subscriptions. Armor. GraphiQL. useRequireAuth. Directive system (validator+transformer). |
-| vite | cedar() → Vite plugins. Cell transform, entry injection, auto-imports. \*SSR/RSC: adds Express + 2 Vite servers, RSC transforms, Hot Module Replacement. |
-| cli | Yargs. 25+ commands. Generators for all types. Plugin system. Telemetry. .env loading. |
-| forms | react-hook-form wrapper. Typed fields. GraphQL coercion (valueAsBoolean/JSON). Error display. |
-| prerender | Static Site Generation. renderToString at build, extract react-helmet meta tags, populate Apollo cache, write static HTML. |
-| realtime | Live queries + subscriptions. @live directive. createPubSub. InMemory/Redis stores. |
-| jobs | Background processing. JobManager/jobs/queues/workers. Delay/waitUntil/cron. Prisma adapter. |
-| mailer | Email. Core + handlers (nodemailer/resend/in-memory) + renderers (react-email/mjml). |
-| storage | File uploads. setupStorage→Prisma extension. FileSystem/Memory adapters. UrlSigner. |
-| record | ActiveRecord on Prisma. Validations, reflections, relations. |
-| context | Request-scoped context via AsyncLocalStorage. Proxy-based. Declaration merging. |
-| server-store | Per-request store: auth state, headers, cookies, URL. \*SSR/RSC: used by middleware. |
-| gqlorm | Prisma API → Proxy → GraphQL. useLiveQuery. Parser+generator. |
-| structure | Project model (pages/routes/cells/services/SDLs). Diagnostics. ts-morph. |
-| codemods | jscodeshift transforms. Version-organized (v2-v7). Cedar+migration from Redwood. |
-| testing | Jest/Vitest config. MockProviders, MockRouter, mockGql, scenario helpers. |
-| storybook | Vite Storybook. |
-| project-config | Read cedar.toml. getPaths/getConfig/findUp. |
-| internal | Re-exports project-config+babel-config. buildApi/dev/generate. Route extraction. |
-| api-server | Fastify. Auto-discover Lambda functions. Mount GraphQL. Custom server.ts. |
-| web-server | Fastify for web side. Uses fastify-web adapter. |
-| fastify-web | Fastify plugin. Static files, SPA fallback, API proxy, prerender. |
-| babel-config | Presets/plugins for api+web. registerApiSideBabelHook. |
-| eslint-config | Flat config. TS+React+a11y+react-compiler+prettier. |
-| eslint-plugin | Rules: process-env-computed, service-type-annotations, unsupported-route-components. |
-| create-cedar-app | Standalone scaffolding CLI. Interactive. TS/JS. Copies templates. |
-| create-cedar-rsc-app | Standalone RSC scaffolding. Downloads template zip. |
-| telemetry | Anonymous CLI telemetry. Duration/errors. |
-| tui | Terminal UI. spinners, boxes, reactive updates. |
-| ogimage-gen | Vite plugin+middleware. OG images from React components. |
-| cookie-jar | Typed cookie map. get/set/has/unset/serialize. |
-| utils | Pluralization wrapper. |
+| Package | Behavior |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| core | Umbrella. Re-exports CLI, servers, testing, config. Bin shims. |
+| router | JSX routing. ``. Typed params, globs, redirects, `` layouts, `` auth guards. Named route helpers. Link/navigate/useLocation/useParams. |
+| auth | Provider-agnostic. `createAuth(provider)` → {AuthProvider, useAuth}. State: loading/authenticated/user. \*SSR/RSC: ServerAuthProvider injects state for SSR. |
+| web | App shell. RedwoodProvider. createCell (GraphQL state→UI). Apollo (useQuery/useMutation). Head/MetaTags. FatalErrorBoundary. Toast. FetchConfig. |
+| api | Server runtime. Auth extraction. Validations (validate/validateWith). CORS. Logging (Pino). Cache (Redis/Memcached/InMemory). Webhooks. RedwoodError. |
+| graphql-server | Yoga factory. Merge SDLs (schema) + services (resolvers) + directives + subscriptions. Armor. GraphiQL. useRequireAuth. Directive system (validator+transformer). |
+| vite | cedar() → Vite plugins. Cell transform, entry injection, auto-imports. \*SSR/RSC: adds Express + 2 Vite servers, RSC transforms, Hot Module Replacement. |
+| cli | Yargs. 25+ commands. Generators for all types. Plugin system. Telemetry. .env loading. |
+| forms | react-hook-form wrapper. Typed fields. GraphQL coercion (valueAsBoolean/JSON). Error display. |
+| prerender | Static Site Generation. renderToString at build, extract react-helmet meta tags, populate Apollo cache, write static HTML. |
+| realtime | Live queries + subscriptions. @live directive. createPubSub. InMemory/Redis stores. |
+| jobs | Background processing. JobManager/jobs/queues/workers. Delay/waitUntil/cron. Prisma adapter. |
+| mailer | Email. Core + handlers (nodemailer/resend/in-memory) + renderers (react-email/mjml). |
+| storage | File uploads. setupStorage→Prisma extension. FileSystem/Memory adapters. UrlSigner. |
+| record | ActiveRecord on Prisma. Validations, reflections, relations. |
+| context | Request-scoped context via AsyncLocalStorage. Proxy-based. Declaration merging. |
+| server-store | Per-request store: auth state, headers, cookies, URL. \*SSR/RSC: used by middleware. |
+| gqlorm | Prisma API → Proxy → GraphQL. useLiveQuery. Parser+generator. |
+| structure | Project model (pages/routes/cells/services/SDLs). Diagnostics. ts-morph. |
+| codemods | jscodeshift transforms. Version-organized (v2-v7). Cedar+migration from Redwood. |
+| testing | Jest/Vitest config. MockProviders, MockRouter, mockGql, scenario helpers. |
+| storybook | Vite Storybook. |
+| project-config | Read cedar.toml. getPaths/getConfig/findUp. |
+| internal | Re-exports project-config+babel-config. buildApi/dev/generate. Route extraction. |
+| api-server | Fastify (default) + srvx/WinterTC (opt-in via `cedar serve api --ud` or `cedar-ud-server` binary). Auto-discover Lambda functions. Mount GraphQL. Custom server.ts. srvx path uses `buildCedarDispatcher` + `createUDServer` for Fastify-free serving. |
+| web-server | Fastify for web side. Uses fastify-web adapter. |
+| fastify-web | Fastify plugin. Static files, SPA fallback, API proxy, prerender. |
+| babel-config | Presets/plugins for api+web. registerApiSideBabelHook. |
+| eslint-config | Flat config. TS+React+a11y+react-compiler+prettier. |
+| eslint-plugin | Rules: process-env-computed, service-type-annotations, unsupported-route-components. |
+| create-cedar-app | Standalone scaffolding CLI. Interactive. TS/JS. Copies templates. |
+| create-cedar-rsc-app | Standalone RSC scaffolding. Downloads template zip. |
+| telemetry | Anonymous CLI telemetry. Duration/errors. |
+| tui | Terminal UI. spinners, boxes, reactive updates. |
+| ogimage-gen | Vite plugin+middleware. OG images from React components. |
+| cookie-jar | Typed cookie map. get/set/has/unset/serialize. |
+| utils | Pluralization wrapper. |
## CONVENTIONS
@@ -250,6 +250,6 @@ Routes.tsx ← 4 routes added inside {
- const ctx = await buildCedarContext(request)
- return handleRequest(request, ctx)
- },
- }
- ```
-- Integrate `@universal-deploy/store`: call `addEntry()` for each
- Cedar server entry (GraphQL, auth, filesystem functions) during the
- build
-- Validate self-hosting using `@universal-deploy/adapter-node`, which
- wraps store entries with `srvx` + `sirv` — Cedar does not implement
- any Node HTTP handling itself
-- Validate Netlify deployment using `@universal-deploy/adapter-netlify`
- as an early end-to-end check
-- Confirm `yarn rw serve` delegates to UD's node adapter rather than
- Fastify
+- Implement `buildCedarDispatcher(options)` in `@cedarjs/api-server`:
+ discovers API functions from `api/dist/functions/` at runtime,
+ builds a rou3 router and per-function `Fetchable` map, and returns a
+ single dispatch `Fetchable` together with the `EntryMeta[]` needed to
+ register each function with the UD store
+- Implement `createUDServer(options)` in `@cedarjs/api-server`: wraps
+ `buildCedarDispatcher` in an srvx HTTP server and calls `addEntry()`
+ for each discovered function for UD store introspection
+- Expose `cedar-ud-server` binary and `cedar serve api --ud` CLI flag,
+ both delegating to `createUDServer` instead of Fastify
+
+#### Why `@universal-deploy/node` proper is a Phase 4 concern
+
+`@universal-deploy/node` is designed to be consumed through a Vite
+build pipeline. Its server entry (`@universal-deploy/node/serve`)
+starts srvx by statically importing the catch-all handler as a virtual
+module:
+
+```ts
+// @universal-deploy/node/serve (simplified)
+import userServerEntry from 'virtual:ud:catch-all'
+// srvx then calls userServerEntry.fetch for every request
+```
+
+`virtual:ud:catch-all` is not a real module path — it only resolves
+during a Vite build. Cedar's API side is currently compiled with
+Babel/esbuild, not Vite, so `@universal-deploy/node/serve` cannot be
+imported or run for `cedar serve api` today.
+
+Phase 3's `createUDServer` is the practical equivalent for the
+current build pipeline: it uses the same srvx server and produces
+identical runtime behaviour, discovering and loading functions from
+the already-compiled `api/dist/functions/` at startup rather than
+through a Vite virtual module graph.
+
+#### How to wire in `@universal-deploy/node` once Phase 4 is done
+
+When Phase 4 gives Cedar a Vite-based API server build, the hookup is
+straightforward:
+
+1. Introduce `cedarUniversalDeployPlugin()` in `@cedarjs/vite` and add
+ it to the **API server Vite build config** (not the web client
+ config — the plugin resolves API-server virtual modules that have
+ no relevance to the browser bundle)
+2. Wire `virtual:ud:catch-all` → `virtual:cedar-api` inside the plugin
+ so that `@universal-deploy/node/serve` can import Cedar's aggregate
+ Fetchable at build time
+3. Add `node()` from `@universal-deploy/node/vite` to the same
+ **API server Vite build config**
+4. `cedar serve` runs the Vite-built output directly
+
+**Naming caution for Phase 4**: Vite calls its Node.js server build
+environment **"SSR"** regardless of whether it renders HTML. This is
+confusing in Cedar's context, where "SSR" specifically means React
+streaming / RSC. The Vite "SSR environment" output that
+`@universal-deploy/node` produces is purely the API server entry — it
+has no connection to Cedar's HTML SSR feature. Do not add `node()` to
+any Vite config that also builds the HTML SSR entry.
#### Deliverables
-- `buildCedarContext` utility in a shared framework package
-- Build tooling that emits `Fetchable` entries per Cedar server entry
-- `@universal-deploy/store` integration (`addEntry` calls at build time)
-- Validated self-hosting via `@universal-deploy/adapter-node`
+- `buildCedarDispatcher(options)` — runtime function discovery and
+ Fetchable dispatch, in `@cedarjs/api-server`
+- `createUDServer(options)` — srvx-based API server wrapping the
+ dispatcher, in `@cedarjs/api-server`
+- `cedar-ud-server` binary and `cedar serve api --ud` flag — serve
+ the Cedar API without Fastify
#### Exit Criteria
-- Cedar can run in production on Node without Fastify, using
- `@universal-deploy/adapter-node`
-- Cedar's server entries are registered in the UD store at build time
-- `yarn rw serve` no longer depends on the Fastify-first API server
- architecture
+- Cedar can run in production on Node without Fastify via
+ `cedar serve api --ud` or the `cedar-ud-server` binary
+
+#### Temporary scaffolding introduced in Phase 3
+
+Several pieces of Phase 3 are deliberate scaffolding — they make Cedar
+work without Fastify today while the Vite-based build pipeline that
+`@universal-deploy/node` requires does not yet exist. They should be
+removed or replaced in the phases noted below.
+
+**Remove / replace in Phase 4:**
+
+- `createUDServer` (`packages/api-server/src/createUDServer.ts`) —
+ the srvx runtime stand-in for `@universal-deploy/node`. Phase 4
+ replaces it with a Vite-built server entry produced by
+ `@universal-deploy/node/vite`'s `node()` plugin. Once `cedar serve`
+ runs that built output, `createUDServer` has no remaining purpose
+ and should be deleted.
+- `udBin.ts` / `udCLIConfig.ts` / the `cedar-ud-server` binary —
+ these exist solely to invoke `createUDServer`. They go away together
+ with it in Phase 4, unless a non-Vite standalone serve mode is
+ deliberately kept.
+- `cedar serve api --ud` CLI flag (`packages/cli/src/commands/serve.ts`)
+ — the temporary bridge that routes to `createUDServer` instead of
+ Fastify. Phase 4 should make UD serving the default and remove the
+ flag entirely.
+- `buildCedarDispatcher` (`packages/api-server/src/udDispatcher.ts`) —
+ the runtime function-discovery function (uses `fast-glob` to scan
+ `api/dist/functions/` at startup). In Phase 4 the API is built and
+ bundled by Vite, so runtime discovery is no longer needed; the
+ function can be deleted. If a non-Vite standalone mode is kept,
+ `buildCedarDispatcher` can be retained for that path only.
**User-facing impact**: None for most developers. Self-hosting users
-get a simpler, Fastify-free production server backed by UD's node
-adapter.
+can opt in to the Fastify-free srvx server via `cedar serve api --ud`
+or the `cedar-ud-server` binary. Full `@universal-deploy/node`
+end-to-end arrives in Phase 4.
---
@@ -748,34 +818,80 @@ Depends on Phases 2 and 3.
#### Goal
Replace the current web+API split dev model with a single Vite-hosted
-development entrypoint.
+development entrypoint for Cedar's default runtime path, while preserving
+a compatibility path for existing apps that depend on custom Fastify
+server setup.
#### Work
-- Eliminate the `8910 → proxy → 8911` mental model
+- Eliminate the `8910 → proxy → 8911` mental model for the default
+ Cedar runtime path
- Route page, GraphQL, auth, and function requests through one
- externally visible dev host
+ externally visible dev host on that default path
- Integrate backend handler execution into the Vite dev runtime
(likely via Vite's `server.middlewareMode` or custom plugin)
- Ensure server-side file watching and invalidation work for backend
entries
- Preserve strong DX for browser requests, direct `curl` requests,
and GraphQL tooling (e.g., GraphiQL must still work)
+- Preserve a compatibility path for apps that use `api/src/server.{ts,js}`,
+ `configureFastify`, `configureApiServer`, or direct Fastify plugin
+ registration, rather than silently routing them through the new
+ default runtime and dropping supported behavior
+- Introduce `cedarUniversalDeployPlugin()` in `@cedarjs/vite` and wire
+ it into the **API server Vite build config**: register
+ `virtual:cedar-api` with the UD store via `addEntry()`, resolve
+ `virtual:ud:catch-all` → `virtual:cedar-api`, and export the Cedar
+ API Fetchable as the virtual module's default export. This plugin
+ belongs to the API server build — not the web client build — because
+ it resolves API-server virtual modules that have no relevance to the
+ browser bundle. When the plugin is introduced, add
+ `@cedarjs/api-server` as a `peerDependency` of `@cedarjs/vite` in
+ `packages/vite/package.json` — the virtual module emitted by the
+ plugin imports `buildCedarDispatcher` from `@cedarjs/api-server`, so
+ consumers need it installed alongside `@cedarjs/vite`
+- Add `node()` from `@universal-deploy/node/vite` to the same API
+ server Vite build config (not the web client config, and not the
+ HTML SSR config — see naming caution below). After this,
+ `cedar serve` runs the Vite-built server entry instead of `createUDServer`
+
+**Naming caution**: Vite calls its Node.js server build environment
+**"SSR"** regardless of whether it renders HTML. This is confusing in
+Cedar's context, where "SSR" specifically means React streaming / RSC.
+The Vite "SSR environment" output that `@universal-deploy/node`
+produces is purely the API server entry — it has no connection to
+Cedar's HTML SSR feature. Do not add `node()` to any Vite config that
+also builds the HTML SSR entry.
#### Deliverables
-- One visible development port
-- One dev request dispatcher
-- One shared module graph for frontend and backend development
+- One visible development port on the default runtime path
+- One dev request dispatcher on the default runtime path
+- One shared module graph for frontend and backend development on the
+ default runtime path
+- A documented compatibility path for apps with custom Fastify server
+ setup
+- `@universal-deploy/node` wired end-to-end: Vite builds a
+ self-contained server entry; `cedar serve` runs it on the default
+ runtime path
#### Exit Criteria
-- Cedar dev no longer requires a separately exposed backend port
+- Cedar dev no longer requires a separately exposed backend port on the
+ default runtime path
- Requests to functions and GraphQL can be made directly against the
- Vite dev host
-
-**User-facing impact**: High (positive). Developers see one port, one
-process, simpler mental model. Config files may need minor updates.
+ Vite dev host on the default runtime path
+- `cedar serve` runs an `@universal-deploy/node`-built server entry on
+ the default runtime path, completing the Phase 3 goal of removing
+ Fastify from that production path
+- Existing apps with custom Fastify server setup still have a supported
+ compatibility path and are not silently forced onto the new default
+ runtime
+
+**User-facing impact**: High (positive). Most developers see one port,
+one process, and a simpler mental model. Existing apps with custom
+Fastify setup remain on a compatibility path until a later migration
+story exists. Config files may need minor updates.
---
@@ -787,37 +903,58 @@ Depends on Phase 4.
#### Goal
-Promote the initial `addEntry()` wiring from Phase 3 into a
-first-class Cedar Vite plugin in `@cedarjs/vite`. Phase 3 gets Cedar
-running without Fastify using UD's adapters; Phase 5 makes the
-integration complete, correct, and provider-discoverable.
+Expand `cedarUniversalDeployPlugin()` (introduced in Phase 4 as part
+of the API server Vite build) from a single aggregate entry into a
+complete, per-route registration that UD adapters and provider plugins
+can rely on. Phase 4 ships a working plugin with one catch-all entry;
+Phase 5 makes it correct and provider-discoverable.
+
+#### Current state after Phase 4
+
+`cedarUniversalDeployPlugin()` exists (introduced in Phase 4) and
+provides:
+
+- A single aggregate `virtual:cedar-api` entry registered with
+ `addEntry()`, covering all Cedar API routes via one catch-all
+ Fetchable
+- `virtual:cedar-api` virtual module: exports Cedar's API Fetchable
+ so UD adapters can consume it
+- `virtual:ud:catch-all` → `virtual:cedar-api` resolution: routes
+ the UD catch-all ID (used by `@universal-deploy/node/serve`) to
+ Cedar's aggregate API entry
#### Work
-- Extract the `addEntry()` calls from Phase 3's ad-hoc build wiring
- into a formal `@cedarjs/vite` plugin
+- Replace the single `virtual:cedar-api` aggregate entry with
+ per-function entries derived from Cedar's route manifest (Phase 2),
+ so providers that benefit from per-route isolation (e.g., Cloudflare
+ Workers) can split on individual functions
- Ensure all Cedar server entries are registered with the correct
- `route`, `method`, and `environment` metadata that UD and provider
- plugins need:
- - web catch-all SSR entry (or SPA fallback)
+ `route`, `method`, and `environment` metadata:
- GraphQL entry
- auth entry
- filesystem-discovered function entries
-- Align Cedar's internal `CedarRouteRecord` manifest (from Phase 2)
- with the `EntryMeta` shape UD's store expects — Cedar should derive
- UD entries from its own route manifest, not maintain them separately
-- Validate the plugin against `@universal-deploy/adapter-node` and
+ - web catch-all / SPA fallback (web side)
+- Align Cedar's `CedarRouteRecord` manifest (Phase 2) with the
+ `EntryMeta` shape UD's store expects — entries should be derived
+ from the manifest, not maintained separately
+- Update `virtual:ud:catch-all` to generate a proper multi-route
+ dispatcher (using rou3 across all registered entries) rather than
+ the simple single-entry re-export from Phase 4
+- Validate the plugin against `@universal-deploy/node` and
`@universal-deploy/adapter-netlify`
- Document the plugin's role so future UD adapter authors know what
Cedar registers and in what shape
#### Deliverables
-- `@cedarjs/vite` Cedar UD plugin
+- `cedarUniversalDeployPlugin()` expanded with per-route entries
+ from Cedar's route manifest
- All Cedar server entries registered via `addEntry()` with complete
metadata at Vite/plugin time
- Cedar's route manifest and UD's store in sync from a single source
of truth
+- Validated against `@universal-deploy/node` end-to-end
#### Exit Criteria
@@ -885,9 +1022,9 @@ targets Cedar cares about.
#### Work
- Validate Netlify and Vercel first (largest user base)
-- Validate Node/self-hosted via `@universal-deploy/adapter-node`
+- Validate Node/self-hosted via `@universal-deploy/node`
- Optionally validate Cloudflare after the first pass
-- Use UD's adapters (`@universal-deploy/adapter-node`,
+- Use UD's adapters (`@universal-deploy/node`,
`@universal-deploy/adapter-netlify`, and equivalent) — Cedar builds
none of its own
- Test:
@@ -938,8 +1075,11 @@ nothing.**
developer's perspective. UD's node adapter is wired up but used only
for production self-hosting. Dev still uses two ports.
-**After Phase 4**: Single-port dev. This is the first major visible
-change. Developers update their config and enjoy a simpler mental model.
+**After Phase 4**: Single-port dev on the default runtime path. This is
+the first major visible change. Developers on the standard Cedar path
+update their config and enjoy a simpler mental model. Apps with custom
+Fastify server setup remain on a compatibility path rather than being
+silently forced onto the new runtime.
**After Phase 5**: No visible change for developers. UD integration is
framework-internal.
@@ -1009,18 +1149,20 @@ impact (Phases 4, 6, 7). The guide should cover:
- Step-by-step migration instructions
- Before/after code examples
- Common pitfalls
+- How to identify whether an app is on the default runtime path or the
+ custom Fastify compatibility path
### Which Phases Require App Developer Action
-| Phase | App Developer Action Required |
-| ----- | ------------------------------------------- |
-| 1 | None (shim handles it) |
-| 2 | None |
-| 3 | None |
-| 4 | Config updates, possible dev script changes |
-| 5 | None |
-| 6 | SSR config migration |
-| 7 | Deploy config updates |
+| Phase | App Developer Action Required |
+| ----- | ----------------------------------------------------------------------------------- |
+| 1 | None (shim handles it) |
+| 2 | None |
+| 3 | None |
+| 4 | Config updates for standard apps; compatibility-path review for custom Fastify apps |
+| 5 | None |
+| 6 | SSR config migration |
+| 7 | Deploy config updates |
## Risks
@@ -1038,6 +1180,9 @@ impact (Phases 4, 6, 7). The guide should cover:
to edge cases in existing auth middleware
- Phase 4 (Vite-centric dev) being significantly harder than estimated
due to HMR, module graph, and backend file watching interactions
+- Silently dropping supported Fastify-specific behavior for existing
+ apps that use `api/src/server.{ts,js}`, `configureFastify`,
+ `configureApiServer`, or direct Fastify plugin registration
## Open Questions
diff --git a/docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md b/docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md
new file mode 100644
index 0000000000..2344fdafee
--- /dev/null
+++ b/docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md
@@ -0,0 +1,1125 @@
+# Detailed Plan: Universal Deploy Phase 4 — Vite-Centric Full-Stack Dev Runtime
+
+## Summary
+
+Phase 4 is the point where Cedar's Universal Deploy work becomes visible in
+day-to-day development. The core shift is not just "use Vite more"; it is
+"make Vite the single externally visible development runtime for the whole app."
+
+Today, Cedar development is still mentally and operationally split:
+
+- the web side is served through Vite
+- the API side runs as a separate backend process
+- requests move through a proxy boundary
+- backend invalidation and frontend invalidation are related, but not truly part
+ of one runtime model
+
+Phase 4 replaces that with a single dev host and a single request entrypoint
+that can serve:
+
+- web assets and HTML
+- GraphQL requests
+- auth requests
+- server function requests
+- future fetch-native backend handlers
+
+This phase is also where the temporary Phase 3 scaffolding starts turning into a
+real runtime architecture. In particular:
+
+- Cedar dev should stop exposing a separate backend port as part of the normal
+ developer experience
+- the API runtime should execute inside a Vite-centric development environment
+- the API server Vite build should gain the first real
+ `cedarUniversalDeployPlugin()`
+- `@universal-deploy/node` should be wired into the API server build and serve
+ path so `cedar serve` runs the Vite-built server entry rather than the
+ temporary direct server construction path
+
+Phase 4 is still not the phase where Cedar fully formalises per-route UD entry
+registration. That belongs to Phase 5. Phase 4 should intentionally ship a
+working aggregate-entry model that is operationally correct for local
+development and for the Node serve path.
+
+## Why Phase 4 Exists
+
+Phases 1-3 establish the prerequisites:
+
+- Phase 1 makes Cedar handlers fetch-native
+- Phase 2 gives Cedar a formal backend route manifest
+- Phase 3 adopts UD deployment adapters and introduces temporary scaffolding
+
+But none of that yet changes the main development experience enough. Cedar still
+feels like a split system unless development itself is unified.
+
+Phase 4 exists to solve five concrete problems:
+
+1. **Port split**
+ - Developers should not need to think in terms of "frontend port" and
+ "backend port" for normal app development.
+
+2. **Proxy split**
+ - Requests should not conceptually travel from "the Vite server" to "the API
+ server" as two separate application runtimes.
+
+3. **Module graph split**
+ - Backend code changes should participate in a Vite-owned invalidation and
+ restart model rather than a separate watcher/process model.
+
+4. **Serve path split**
+ - `cedar serve` should move onto the same UD-oriented build output that the
+ broader integration is targeting.
+
+5. **Architecture split**
+ - Cedar should stop treating the API runtime as a special non-Vite island in
+ development.
+
+## Relationship to the Refined Integration Plan
+
+This document expands the refined plan's Phase 4 section into an implementation
+plan with concrete architecture, workstreams, sequencing, risks, and acceptance
+criteria.
+
+It preserves the refined plan's key constraints:
+
+- one visible development port
+- one dev request dispatcher
+- backend execution integrated into the Vite dev runtime
+- strong DX for browser traffic and direct HTTP tooling
+- `cedarUniversalDeployPlugin()` introduced in the API server Vite build
+- `node()` from `@universal-deploy/node/vite` added to the API server Vite build
+- no confusion between Vite's "SSR environment" and Cedar's HTML SSR feature
+
+It also preserves the phase boundary:
+
+- Phase 4 delivers a working aggregate-entry plugin and runtime
+- Phase 5 expands that into full per-route registration and provider-facing
+ correctness
+
+## Goals
+
+### Primary Goals
+
+- Make `cedar dev` expose one externally visible host/port for the default app
+ runtime
+- Route web and API traffic through one development dispatcher for the default
+ Cedar runtime path
+- Execute Cedar-owned backend handlers in a Vite-centric runtime
+- Ensure backend source changes are reflected through a coherent dev invalidation
+ model
+- Make `cedar serve` run the Vite-built UD Node server entry for the default
+ non-custom-server path
+- Introduce the first production-worthy version of
+ `cedarUniversalDeployPlugin()` for the API server build
+- Preserve a compatibility lane for apps that depend on custom Fastify server
+ setup
+
+### Secondary Goals
+
+- Preserve GraphiQL and direct `curl` workflows
+- Preserve existing auth and function behavior during the transition
+- Minimise app-level migration burden
+- Keep Phase 4 compatible with the later Phase 5 route-registration expansion
+- Make the compatibility story for `api/src/server.{ts,js}`,
+ `configureFastify`, and custom Fastify plugins explicit
+
+## Non-Goals
+
+Phase 4 should explicitly not try to do all of the following:
+
+- rebuild Cedar HTML SSR or RSC
+- formalise per-route UD registration for all providers
+- redesign Cedar's web-side production serving model
+- solve every provider-specific deployment concern
+- remove all transitional compatibility layers introduced earlier
+- merge web and API build outputs into one universal production artifact
+- introduce a new public app authoring API unless required for runtime
+ correctness
+- remove the custom Fastify server path for apps that already depend on it
+- force all existing Fastify-specific customisations onto the new runtime in
+ this phase
+
+## Current Baseline Before Phase 4
+
+Based on the current Cedar architecture, the refined integration plan, and the
+current codebase, the baseline is:
+
+- web development is Vite-centric
+- API development is still conceptually separate
+- `cedar dev` still starts separate web and API jobs
+- the current web/API relationship still assumes a proxy-oriented model in
+ important places
+- production API serving has a temporary UD-oriented path
+- Cedar already has or is expected to have:
+ - fetch-native handlers
+ - a backend route manifest
+ - temporary UD scaffolding
+- `cedar serve api --ud` or equivalent transitional paths exist, but they are
+ not yet the default unified runtime story
+- Cedar still has a real, supported Fastify customisation surface through
+ `api/src/server.{ts,js}`, `configureApiServer`, and older
+ `configureFastify`-style configuration
+- the current UD dispatcher is an aggregate Cedar API dispatcher, but it is not
+ yet a complete replacement for arbitrary Fastify custom routes, hooks,
+ decorators, or plugins
+
+This means Phase 4 is not starting from zero. It is integrating already-created
+pieces into a coherent dev runtime while preserving a compatibility path for
+apps that depend on Fastify-specific server customisation.
+
+## Codebase Alignment Notes
+
+The current codebase already supports the main direction of this phase:
+
+- temporary UD scaffolding exists specifically to be removed in Phase 4
+- a shared aggregate Cedar dispatcher already exists and is intended to be used
+ by both the temporary server path and the future Vite virtual module path
+- the CLI already marks the current UD serve path as transitional
+- the current dev model is still clearly split between web and API processes
+
+At the same time, the codebase also makes two important constraints visible:
+
+1. The current aggregate UD dispatcher is still narrower than the final Phase 4
+ target. It already handles Cedar-owned API surfaces such as GraphQL and
+ filesystem-discovered functions, but it should not be treated as proof that
+ all Fastify-based customisation has already been subsumed by the fetch-native
+ runtime.
+2. Cedar currently exposes a real Fastify customisation surface. That means
+ Phase 4 cannot be treated as a blanket removal of Fastify from every app
+ runtime path without breaking supported user setups.
+
+These constraints shape the recommended implementation approach for this phase:
+the unified Vite-centric runtime becomes the default path for standard Cedar
+apps, while custom-server apps remain on an explicit compatibility lane until a
+later migration path exists.
+
+## Architectural Target for Phase 4
+
+### High-Level Shape
+
+After Phase 4, the default development architecture should look like this:
+
+- one Vite-hosted dev server is externally visible
+- that dev server owns the request entrypoint
+- browser-facing web requests are handled by Vite as usual
+- API-like requests are dispatched into Cedar's fetch-native backend runtime
+- backend modules are loaded through a Vite-aware mechanism rather than a
+ completely separate long-lived backend process
+- the API server build has a Vite config that:
+ - installs `cedarUniversalDeployPlugin()`
+ - installs `node()` from `@universal-deploy/node/vite`
+ - emits a self-contained Node server entry for `cedar serve`
+
+For apps with custom Fastify setup, Phase 4 should preserve a compatibility
+lane rather than forcing them onto the default unified runtime immediately.
+Those apps may continue to use a custom-server path until Cedar provides a
+framework-agnostic replacement for the Fastify-specific extension points they
+depend on.
+
+### Conceptual Request Flow in Dev
+
+The intended request flow is:
+
+1. request arrives at the single Vite dev host
+2. Cedar dev middleware classifies the request
+3. request is dispatched to one of:
+ - Vite static/HMR/web handling
+ - GraphQL handler
+ - auth handler
+ - function handler
+ - aggregate Cedar API dispatcher
+4. response is returned directly from the same host
+
+The important change is that the browser, GraphQL clients, auth callbacks, and
+CLI HTTP tooling all target the same visible origin.
+
+### Conceptual Build/Serve Flow
+
+For `cedar serve`, the intended flow is:
+
+1. API server Vite config builds the server entry
+2. `cedarUniversalDeployPlugin()` registers Cedar's aggregate API entry
+3. `node()` from `@universal-deploy/node/vite` produces the Node-compatible
+ server output
+4. `cedar serve` launches that built server entry
+
+This completes the move away from the temporary direct server construction path
+for the Node serve case.
+
+## Design Principles for This Phase
+
+### 1. Vite Owns the Dev Host
+
+The visible development host should be Vite's host, not a wrapper process that
+merely proxies to Vite. Cedar may compose middleware around Vite, but the
+developer mental model should still be "the app runs on one Vite dev server."
+
+### 2. Cedar Owns Request Classification
+
+Vite should remain the host, but Cedar should own the logic that decides whether
+a request is:
+
+- a frontend asset/HMR request
+- a page/document request
+- a GraphQL request
+- an auth request
+- a server function request
+- a fallback request
+
+This keeps Cedar's routing and runtime contract authoritative.
+
+### 3. Fetch-Native Execution Is the Runtime Center
+
+Backend execution should happen through Cedar's fetch-native handler contract,
+not through reintroduced Node/Express/Fastify-specific request objects.
+
+### 4. Aggregate Entry First, Per-Route Later
+
+Phase 4 should use one aggregate Cedar API entry for correctness and speed of
+delivery. It should not prematurely implement the full Phase 5 route-splitting
+model.
+
+### 5. No Cedar/SSR Terminology Drift
+
+Any Vite config or code comments must clearly distinguish:
+
+- Vite "SSR" meaning server-side module execution/build target
+- Cedar "SSR" meaning HTML server rendering / streaming / RSC-related behavior
+
+This distinction matters because the API server build will use Vite's server
+build machinery without implying Cedar HTML SSR.
+
+### 6. Preserve Existing App Contracts Where Possible
+
+App authors should not need to rewrite routes, functions, GraphQL handlers, or
+auth setup just to adopt Phase 4.
+
+### 7. Preserve a Compatibility Lane for Custom Fastify Apps
+
+Apps that use `api/src/server.{ts,js}`, `configureApiServer`,
+`configureFastify`, or direct `server.register(...)` Fastify plugin setup are
+using a supported Cedar extension path today. Phase 4 should not silently
+bypass or ignore those customisations.
+
+Instead, the default unified runtime should apply to standard Cedar apps, while
+custom-server apps remain on an explicit compatibility lane until Cedar offers a
+clear migration path to framework-agnostic extension points.
+
+## Proposed Runtime Architecture
+
+## 1. Dev Runtime Composition
+
+The Phase 4 dev runtime should be composed from three layers:
+
+### Layer A: Vite Dev Server
+
+Responsibilities:
+
+- static asset serving
+- HMR
+- HTML transforms
+- frontend module graph ownership
+- browser-facing dev ergonomics
+
+### Layer B: Cedar Dev Request Dispatcher
+
+Responsibilities:
+
+- classify incoming requests
+- decide whether Cedar backend handling should run
+- invoke the aggregate Cedar API fetch dispatcher when appropriate
+- fall through to Vite web handling when appropriate
+
+This is the key new Phase 4 layer.
+
+### Layer C: Cedar Aggregate API Runtime
+
+Responsibilities:
+
+- execute GraphQL
+- execute auth endpoints
+- execute filesystem-discovered functions
+- execute any other Cedar-owned fetch-native backend entries included in the
+ aggregate dispatcher
+
+This layer should be built on the Phase 1 and Phase 2 contracts, not on legacy
+event-shaped APIs.
+
+### Important Scope Note
+
+In the current codebase, the aggregate UD dispatcher should be treated as the
+Cedar-owned backend runtime path, not as a complete replacement for arbitrary
+Fastify customisation. Phase 4 should unify Cedar's default runtime path first,
+while preserving a separate compatibility lane for apps that depend on
+Fastify-specific hooks, decorators, routes, or plugins.
+
+## 2. Request Classification Model
+
+The dispatcher should classify requests in a deterministic order. A practical
+order is:
+
+1. Vite internal requests
+ - HMR endpoints
+ - Vite client assets
+ - transformed module requests
+2. explicit API endpoints
+ - GraphQL
+ - auth
+ - function routes
+3. web asset requests
+ - static files
+ - known web assets
+4. page/document requests
+ - app routes that should return the web app shell in SPA mode
+5. fallback/error handling
+
+The exact path patterns should come from Cedar configuration and route manifest
+data where possible, not from scattered hardcoded checks.
+
+### Why Ordering Matters
+
+Ordering mistakes can create subtle bugs:
+
+- Vite HMR requests accidentally routed into Cedar API handling
+- GraphQL requests falling through to SPA HTML
+- auth callback routes being treated as frontend routes
+- static assets being intercepted by API logic
+
+Phase 4 should therefore define request classification as a first-class runtime
+concern, not an incidental middleware detail.
+
+## 3. Backend Execution Model in Dev
+
+There are two broad implementation styles Cedar could take:
+
+### Option A: In-Process Vite Middleware Execution
+
+Cedar installs middleware into the Vite dev server and directly invokes the
+aggregate fetch dispatcher from there.
+
+**Pros**
+
+- simplest mental model
+- one visible server
+- minimal extra process orchestration
+- easiest path to "one dispatcher"
+
+**Cons**
+
+- backend invalidation semantics must be handled carefully
+- Node-only backend dependencies must coexist with Vite's server runtime model
+- error isolation may be weaker than a separate worker model
+
+### Option B: Vite-Owned Host with Internal Backend Worker
+
+Cedar still exposes one visible Vite host, but backend execution happens in an
+internal worker/sub-runtime managed by the dev system.
+
+**Pros**
+
+- stronger isolation
+- potentially cleaner backend reload semantics
+
+**Cons**
+
+- more moving parts
+- easier to accidentally recreate the old split model internally
+- more complexity for Phase 4 than likely necessary
+
+### Recommendation
+
+Phase 4 should prefer **Option A** unless implementation evidence proves it
+unworkable. The refined plan already points toward Vite middleware integration,
+and that is the shortest path to the intended developer experience.
+
+If isolation issues appear, they should be documented as follow-up work rather
+than causing Phase 4 to balloon into a multi-runtime orchestration project.
+
+## 4. Backend Invalidation and Reload Strategy
+
+This is one of the most important implementation details.
+
+The backend runtime must respond correctly to changes in:
+
+- `api/src/functions/**`
+- `api/src/graphql/**`
+- `api/src/services/**`
+- auth-related backend modules
+- route manifest inputs
+- generated artifacts that affect backend execution
+
+### Required Outcomes
+
+- code changes should be reflected without requiring manual process restarts
+- stale backend modules should not remain cached indefinitely
+- errors should surface clearly in the terminal and browser/client responses
+- invalidation should be targeted enough to avoid unnecessary full reloads when
+ possible
+
+### Practical Strategy
+
+Phase 4 should start with a conservative invalidation model:
+
+- treat the aggregate Cedar API runtime as a reloadable server module boundary
+- when backend-relevant files change, invalidate the aggregate backend entry and
+ its dependent modules
+- rebuild or re-import the backend dispatcher through Vite's server module
+ system
+- prefer correctness over maximal granularity
+
+This is another place where Phase 5 can later improve precision once per-route
+entries exist.
+
+### Important Constraint
+
+Do not try to make backend invalidation mirror frontend HMR exactly. Backend
+execution correctness matters more than preserving stateful hot replacement
+semantics.
+
+## 5. Aggregate API Entry Shape
+
+Phase 4 should introduce a single aggregate virtual entry, likely represented by
+`virtual:cedar-api`.
+
+That virtual module should:
+
+- import the Cedar API dispatcher construction logic
+- build the aggregate fetchable from Cedar's route/function/GraphQL/auth sources
+- export the aggregate fetchable as the default export
+
+This entry is the bridge between Cedar's runtime model and UD's Vite/plugin
+model.
+
+### Why Aggregate Entry Is Correct for Phase 4
+
+An aggregate entry:
+
+- keeps plugin complexity manageable
+- avoids premature provider-specific route splitting
+- is sufficient for local dev and Node serve
+- aligns with the refined plan's explicit Phase 4/Phase 5 boundary
+
+## 6. `cedarUniversalDeployPlugin()` Responsibilities in Phase 4
+
+The plugin introduced in this phase should do exactly the minimum needed for a
+working system on the default Cedar runtime path.
+
+### Required Responsibilities
+
+- register `virtual:cedar-api` with the UD store via `addEntry()`
+- resolve `virtual:ud:catch-all` to `virtual:cedar-api`
+- emit the virtual module that exports Cedar's aggregate API fetchable
+- operate in the API server Vite build, not the web client build
+
+### Explicit Non-Responsibilities in Phase 4
+
+- registering every Cedar route as a separate UD entry
+- becoming the final provider-facing route metadata source
+- handling web-side route registration comprehensively
+- solving all adapter-specific optimisations
+
+### Package Boundary Implication
+
+Because the virtual module imports API-server runtime code,
+`@cedarjs/vite` should declare `@cedarjs/api-server` as a `peerDependency`,
+matching the refined plan.
+
+## 7. `@universal-deploy/node` Integration in Phase 4
+
+The API server Vite build should add `node()` from
+`@universal-deploy/node/vite`.
+
+### Purpose
+
+- produce a self-contained Node server entry
+- let `cedar serve` run the built output
+- replace the temporary direct server construction path for the Node serve case
+
+### Important Clarification
+
+This is a Vite server build concern, not a Cedar HTML SSR concern.
+
+Any implementation notes, config names, comments, and docs should repeatedly
+make this clear to avoid future confusion.
+
+## Runtime Lanes in Phase 4
+
+Phase 4 should explicitly support two runtime lanes.
+
+### Lane A: Default Unified Runtime
+
+This is the primary Phase 4 target for standard Cedar apps:
+
+- one visible Vite-hosted dev port
+- one Cedar dev request dispatcher
+- Cedar-owned backend execution through the aggregate fetch-native runtime
+- API server Vite build integrated with `cedarUniversalDeployPlugin()`
+- `cedar serve` running the Vite-built UD Node server entry
+
+### Lane B: Custom Fastify Compatibility Runtime
+
+This lane exists for apps that depend on Cedar's current Fastify-specific server
+extension points, including:
+
+- `api/src/server.{ts,js}`
+- `configureApiServer`
+- `configureFastify`
+- direct `server.register(...)` plugin setup
+- custom Fastify routes, hooks, decorators, parsers, or reply/request behavior
+
+For these apps, Phase 4 should preserve a supported compatibility path rather
+than forcing immediate migration.
+
+### Runtime Selection Rule
+
+The implementation should treat the presence of a custom server path as a
+meaningful runtime distinction. If an app is using a custom server entry or
+Fastify-specific setup, Cedar should either:
+
+- keep that app on the compatibility lane automatically, or
+- fail clearly with guidance rather than silently dropping custom behaviour
+
+Silent partial compatibility is the worst outcome here.
+
+## Workstreams
+
+## Workstream 1: Inventory and Stabilise Existing Dev Entry Logic
+
+### Objective
+
+Understand and isolate the current `cedar dev` orchestration points so Phase 4
+can replace the split runtime without regressing unrelated behavior.
+
+### Tasks
+
+- identify the current web dev startup path
+- identify the current API dev startup path
+- identify where proxying between web and API currently happens
+- identify how GraphQL, auth, and functions are currently mounted in dev
+- identify current file watching and restart behavior for backend code
+- identify any assumptions in CLI output, port reporting, or generated URLs that
+ depend on separate web/API ports
+- identify all current custom-server and Fastify-specific extension points that
+ must remain supported on the compatibility lane
+- identify where `serverFileExists()` and related custom-server branching
+ already exist so Phase 4 can build on those distinctions rather than fighting
+ them
+
+### Deliverable
+
+A concrete map of the current dev orchestration points and the minimum set of
+places that must change.
+
+### Notes
+
+This work should be done before major implementation begins. Phase 4 will be
+much riskier if the current split behavior is only partially understood.
+
+## Workstream 2: Define the Dev Request Dispatcher Contract
+
+### Objective
+
+Create a clear internal contract for the single dev dispatcher.
+
+### Proposed Internal Contract
+
+The dispatcher should accept:
+
+- the incoming request
+- enough runtime context to classify the request
+- access to the aggregate Cedar API fetch handler
+- access to Vite's fallback handling path
+
+And it should return either:
+
+- a completed response
+- a signal to continue into Vite web handling
+
+### Tasks
+
+- define request classification inputs
+- define the fallback contract to Vite
+- define error handling behavior
+- define logging behavior for classified requests
+- define how direct HTTP requests should appear in logs and diagnostics
+
+### Deliverable
+
+An internal dispatcher API that can be tested independently of the full CLI
+startup path.
+
+## Workstream 3: Build the Aggregate Cedar API Runtime for Dev
+
+### Objective
+
+Create the aggregate fetch-native backend runtime that the dispatcher will call
+for the default Cedar runtime path.
+
+### Tasks
+
+- compose GraphQL handling into the aggregate runtime
+- compose auth handling into the aggregate runtime
+- compose filesystem-discovered function handling into the aggregate runtime
+- ensure route matching uses the Phase 2 route manifest or equivalent canonical
+ route data
+- ensure request context enrichment still works correctly
+- ensure cookies, params, query, and auth state are available through the new
+ fetch-native path
+- explicitly document that this aggregate runtime covers Cedar-owned backend
+ surfaces and is not yet a general replacement for arbitrary Fastify plugins or
+ custom Fastify routes
+
+### Deliverable
+
+A single backend fetch dispatcher that can answer all Cedar API requests in dev.
+
+### Validation Questions
+
+- Does GraphiQL still load correctly?
+- Do auth callback flows still work?
+- Do function routes preserve method handling and path params?
+- Do direct `curl` requests behave the same as browser-originated requests?
+
+## Workstream 4: Integrate Backend Execution into the Vite Dev Runtime
+
+### Objective
+
+Mount Cedar backend handling into the Vite dev server so one visible host serves
+the whole app on the default runtime lane.
+
+### Tasks
+
+- install Cedar middleware into the Vite dev server
+- intercept and classify requests before SPA fallback handling
+- invoke the aggregate Cedar API runtime for backend requests
+- fall through to Vite for frontend requests
+- ensure Vite internal endpoints are never intercepted incorrectly
+- ensure response streaming and headers are preserved correctly where relevant
+- ensure this integration is only the default path for standard apps, not a
+ silent override of custom Fastify server setups
+
+### Deliverable
+
+A working one-port dev runtime.
+
+### Key Acceptance Checks
+
+- opening the app in the browser works
+- GraphQL requests to the visible dev host work
+- auth endpoints on the visible dev host work
+- function endpoints on the visible dev host work
+- HMR still works
+- GraphiQL still works
+
+## Workstream 5: Implement Backend Invalidation and Watch Behavior
+
+### Objective
+
+Ensure backend changes are reflected reliably during development.
+
+### Tasks
+
+- identify backend-relevant file globs
+- hook those changes into Vite-aware invalidation
+- invalidate the aggregate backend entry on relevant changes
+- ensure generated artifacts that affect backend execution also trigger reload
+ behavior
+- surface backend reload events in logs for debugging
+
+### Deliverable
+
+Reliable backend code refresh without manual restarts in normal workflows.
+
+### Minimum Acceptable Behavior
+
+If a backend file changes, the next matching request should execute updated code
+without requiring the developer to restart `cedar dev`.
+
+## Workstream 6: Introduce `cedarUniversalDeployPlugin()` in the API Server Vite Build
+
+### Objective
+
+Create the first real Cedar UD Vite plugin implementation.
+
+### Tasks
+
+- add the plugin to the API server Vite build config
+- register `virtual:cedar-api` with UD via `addEntry()`
+- resolve `virtual:ud:catch-all` to `virtual:cedar-api`
+- emit the virtual module that exports the aggregate Cedar API fetchable
+- ensure the plugin only applies in the API server build context
+- add the `@cedarjs/api-server` peer dependency to `@cedarjs/vite`
+
+### Deliverable
+
+A working plugin that bridges Cedar's aggregate API runtime into UD's Vite entry
+model.
+
+### Important Guardrail
+
+Do not let this plugin accidentally become coupled to browser build concerns.
+Its job in Phase 4 is server-entry registration for the API server build.
+
+## Workstream 7: Wire `@universal-deploy/node` into the API Server Build and Serve Path
+
+### Objective
+
+Make `cedar serve` run the Vite-built Node server entry.
+
+### Tasks
+
+- add `node()` from `@universal-deploy/node/vite` to the API server Vite build
+- ensure the build output is self-contained enough for `cedar serve`
+- update `cedar serve` to launch the built server entry
+- remove or bypass the temporary direct `createUDServer`-style path for the Node
+ serve case
+- verify startup, shutdown, logging, and error reporting behavior
+
+### Deliverable
+
+`cedar serve` runs the UD Node build output end-to-end.
+
+### Acceptance Checks
+
+- `cedar serve` starts successfully from the built output
+- GraphQL works
+- auth works
+- functions work
+- direct HTTP requests work
+- no Fastify-specific production path is required for this serve mode
+
+## Workstream 8: CLI and DX Cleanup
+
+### Objective
+
+Make the new runtime feel intentional rather than transitional, while making the
+compatibility lane explicit for custom-server apps.
+
+### Tasks
+
+- update CLI startup messaging to show one visible port for the default runtime
+- remove or reduce references to separate web/API dev ports in normal output for
+ standard apps
+- update any generated URLs, docs, or help text that assume proxying
+- ensure error messages mention the unified host where appropriate
+- ensure debugging output still makes it clear whether a request was handled by
+ Vite web logic or Cedar backend logic
+- add explicit messaging for custom-server apps so users understand when Cedar
+ is using the compatibility lane instead of the default unified runtime
+
+### Deliverable
+
+A coherent developer experience that matches the new architecture.
+
+## Implementation Sequence
+
+A practical implementation order is:
+
+### Step 1: Runtime Mapping
+
+Complete Workstream 1 and document the current orchestration points.
+
+### Step 2: Dispatcher Contract
+
+Define and implement the internal dev request dispatcher contract.
+
+### Step 3: Aggregate Backend Runtime
+
+Build the aggregate Cedar API fetch dispatcher and validate it outside the full
+Vite integration if possible.
+
+### Step 4: Vite Dev Integration
+
+Mount the dispatcher into the Vite dev server and get one-port request handling
+working.
+
+### Step 5: Invalidation
+
+Add backend file invalidation and reload behavior.
+
+### Step 6: UD Plugin
+
+Introduce `cedarUniversalDeployPlugin()` in the API server Vite build.
+
+### Step 7: Node Serve Integration
+
+Add `node()` and switch `cedar serve` to the built server entry.
+
+### Step 8: DX Cleanup and Documentation
+
+Update CLI messaging, docs, and migration notes.
+
+This order reduces risk by proving the runtime model before tightening the build
+and serve integration.
+
+## Testing Strategy
+
+## 1. Unit-Level Testing
+
+Test the request dispatcher in isolation.
+
+### Cases
+
+- Vite internal request is passed through
+- GraphQL request is routed to backend runtime
+- auth request is routed to backend runtime
+- function request is routed to backend runtime
+- SPA/document request falls through to web handling
+- unknown request gets the correct fallback behavior
+
+## 2. Integration Testing for Dev Runtime
+
+Test the unified dev host end-to-end for the default runtime lane.
+
+### Cases
+
+- browser loads app from one port
+- GraphQL POST works against same host
+- GraphiQL loads from same host
+- auth callback route works against same host
+- function route works against same host
+- static assets still load
+- HMR still functions after frontend edits
+- backend code changes are reflected on next request
+
+## 3. Serve-Path Testing
+
+Test the Vite-built Node server output for the default runtime lane.
+
+### Cases
+
+- `cedar serve` starts from built output
+- GraphQL works
+- auth works
+- functions work
+- route params and query parsing work
+- cookies and headers are preserved correctly
+
+## 4. Regression Testing
+
+Focus on areas most likely to break:
+
+- auth providers with callback flows
+- GraphiQL tooling
+- function routes with non-GET methods
+- middleware ordering
+- generated route manifest changes
+- direct `curl` requests without browser headers
+- custom-server apps that use `api/src/server.{ts,js}`
+- Fastify plugin registration and custom Fastify routes on the compatibility
+ lane
+
+## Suggested Milestones
+
+## Milestone A: Aggregate Backend Runtime Works
+
+Success means:
+
+- one aggregate fetch dispatcher exists
+- GraphQL, auth, and functions all work through it
+- it can be invoked independently of the final Vite integration
+
+## Milestone B: One-Port Dev Host Works
+
+Success means:
+
+- browser, GraphQL, auth, and functions all work from one visible host
+- Vite HMR still works
+- no separate backend port is required for normal use
+
+## Milestone C: Backend Reload Works Reliably
+
+Success means:
+
+- backend edits are reflected without manual restart
+- stale module behavior is not observed in normal workflows
+
+## Milestone D: `cedar serve` Uses UD Node Output
+
+Success means:
+
+- API server Vite build emits the server entry
+- `cedar serve` launches it successfully
+- the temporary direct server path is no longer needed for the Node serve case
+
+## Risks and Mitigations
+
+## Risk 1: Vite Internal Requests Are Misclassified
+
+### Impact
+
+HMR or module loading breaks in confusing ways.
+
+### Mitigation
+
+- classify Vite internal requests first
+- add explicit tests for Vite-specific paths
+- add debug logging around request classification during development
+
+## Risk 2: Backend Module Invalidation Is Incomplete
+
+### Impact
+
+Developers see stale backend behavior and lose trust in the runtime.
+
+### Mitigation
+
+- start with coarse invalidation at the aggregate entry boundary
+- prefer correctness over fine-grained optimisation
+- log backend invalidation events during early rollout
+
+## Risk 3: Auth Flows Regress
+
+### Impact
+
+Login/logout/callback behavior breaks, often only in certain providers.
+
+### Mitigation
+
+- explicitly test callback-style providers
+- test cookie-based and token-based auth paths
+- preserve existing request context enrichment semantics
+
+## Risk 4: GraphiQL or Direct HTTP Tooling Regresses
+
+### Impact
+
+Developer workflows become worse even if browser flows work.
+
+### Mitigation
+
+- treat GraphiQL and `curl` as first-class acceptance cases
+- test non-browser requests explicitly
+- avoid assumptions that all requests originate from the SPA
+
+## Risk 5: Phase 4 Accidentally Expands into Phase 5
+
+### Impact
+
+Delivery slows down because the team tries to solve per-route provider
+registration too early.
+
+### Mitigation
+
+- keep the aggregate-entry boundary explicit
+- defer per-route UD registration to Phase 5
+- document temporary limitations clearly
+
+## Risk 6: Terminology Confusion Around "SSR"
+
+### Impact
+
+Future maintainers wire `node()` into the wrong Vite config or conflate API
+server builds with Cedar HTML SSR.
+
+### Mitigation
+
+- document the distinction repeatedly
+- use precise naming in config and comments
+- avoid ambiguous labels like "SSR build" without qualification
+
+## Risk 7: Custom Fastify Behaviour Is Silently Lost
+
+### Impact
+
+Apps that rely on `api/src/server.{ts,js}`, `configureFastify`,
+`configureApiServer`, or direct Fastify plugin registration appear to start, but
+some custom routes, hooks, parsers, decorators, or request/reply behaviour stop
+working.
+
+### Mitigation
+
+- preserve an explicit compatibility lane for custom-server apps
+- detect custom-server usage and branch intentionally
+- never silently route custom-server apps through the default unified runtime if
+ that would drop supported behaviour
+- document the boundary between Cedar-owned fetch-native runtime support and
+ Fastify-specific compatibility support
+
+## Open Design Questions to Resolve During Implementation
+
+These do not block writing the plan, but they should be resolved early in
+implementation:
+
+1. What is the exact internal API between the dispatcher and Vite fallback
+ handling?
+2. Which backend file changes should trigger aggregate invalidation directly, and
+ which should rely on dependency tracking?
+3. How should backend runtime errors be surfaced in dev:
+ - terminal only
+ - HTTP response only
+ - both
+4. Should the aggregate backend runtime be lazily initialised on first request
+ or eagerly prepared at dev startup?
+5. Are there any auth providers that currently depend on assumptions about a
+ separate backend origin?
+6. Does GraphiQL require any path or asset handling adjustments when moved fully
+ behind the unified host?
+7. What is the cleanest migration path for any existing CLI flags or docs that
+ expose separate dev ports?
+8. What is the exact runtime-selection rule for deciding when an app stays on
+ the custom Fastify compatibility lane?
+9. Should custom-server apps keep the current split dev model in Phase 4, or is
+ there a safe compatibility wrapper that still preserves Fastify behaviour?
+10. Which current Fastify extension points need a future framework-agnostic
+ replacement, and which should remain explicitly serverful-only?
+
+## Exit Criteria for Phase 4
+
+Phase 4 should be considered complete when all of the following are true:
+
+- `cedar dev` exposes one externally visible host/port for the default runtime
+ path
+- GraphQL requests work directly against that host on the default runtime path
+- auth requests work directly against that host on the default runtime path
+- function requests work directly against that host on the default runtime path
+- browser app loading and HMR still work on the default runtime path
+- backend code changes are reflected without manual restart in normal workflows
+ on the default runtime path
+- the API server Vite build includes `cedarUniversalDeployPlugin()`
+- the API server Vite build includes `node()` from
+ `@universal-deploy/node/vite`
+- `cedar serve` runs the Vite-built Node server entry for the default runtime
+ path
+- Cedar no longer depends on a separately exposed backend port for the standard
+ dev experience
+- custom-server apps still have a documented and supported compatibility path
+- the implementation does not require Cedar HTML SSR/RSC work to be complete
+
+## Deliverables
+
+Phase 4 should produce the following concrete outputs:
+
+- unified one-port dev runtime for the default Cedar runtime lane
+- internal dev request dispatcher
+- aggregate Cedar API fetch dispatcher for dev
+- backend invalidation/reload behavior integrated with the Vite-centric runtime
+- initial `cedarUniversalDeployPlugin()` in `@cedarjs/vite`
+- `@cedarjs/api-server` peer dependency declared by `@cedarjs/vite`
+- API server Vite build wired with `node()` from `@universal-deploy/node/vite`
+- `cedar serve` updated to run the built Node server entry for the default
+ runtime lane
+- documented compatibility lane for apps using custom Fastify server setup
+- updated docs and CLI messaging reflecting both the unified runtime and the
+ compatibility lane
+
+## Recommendation
+
+Implement Phase 4 as a runtime unification phase, not as a provider-expansion
+phase.
+
+The most important outcome is that Cedar development becomes operationally
+single-host and architecturally Vite-centric on the default runtime lane, while
+`cedar serve` moves onto the UD Node build output for that same lane. If that is
+achieved with an aggregate API entry and conservative backend invalidation,
+Phase 4 is successful.
+
+That success condition should not require Cedar to immediately eliminate the
+custom Fastify server path. Existing apps that depend on `api/src/server.{ts,js}`
+or Fastify-specific plugin setup should remain supported through an explicit
+compatibility lane. Phase 5 and later work can then build on a stable default
+runtime foundation while separately addressing longer-term migration away from
+Fastify-specific extension points where appropriate.
+conservative backend invalidation, Phase 4 is successful.
+
+Phase 5 can then build on a stable runtime foundation to formalise per-route UD
+entry registration and provider-facing metadata correctness.
diff --git a/packages/api-server/dist.test.ts b/packages/api-server/dist.test.ts
index 4916639137..55d2102f0a 100644
--- a/packages/api-server/dist.test.ts
+++ b/packages/api-server/dist.test.ts
@@ -11,7 +11,7 @@ describe('dist', () => {
expect(fs.existsSync(path.join(distPath, '__tests__'))).toEqual(false)
})
- it('ships three bins', () => {
+ it('ships six bins', () => {
expect(packageConfig.bin).toMatchInlineSnapshot(`
{
"cedarjs-api-server-watch": "./dist/watch.js",
diff --git a/packages/api-server/package.json b/packages/api-server/package.json
index 7ff79892c4..7966a26e23 100644
--- a/packages/api-server/package.json
+++ b/packages/api-server/package.json
@@ -73,6 +73,18 @@
"types": "./dist/cjs/bothCLIConfigHandler.d.ts",
"default": "./dist/cjs/bothCLIConfigHandler.js"
},
+ "./udDispatcher": {
+ "import": {
+ "types": "./dist/udDispatcher.d.ts",
+ "default": "./dist/udDispatcher.js"
+ }
+ },
+ "./udFetchable": {
+ "import": {
+ "types": "./dist/udFetchable.d.ts",
+ "default": "./dist/udFetchable.js"
+ }
+ },
"./watch": {
"import": {
"types": "./dist/watch.d.ts",
@@ -118,6 +130,8 @@
"@cedarjs/web-server": "workspace:*",
"@fastify/multipart": "9.4.0",
"@fastify/url-data": "6.0.3",
+ "@universal-deploy/node": "^0.1.6",
+ "@universal-deploy/store": "^0.2.1",
"ansis": "4.2.0",
"chokidar": "3.6.0",
"dotenv-defaults": "5.0.2",
@@ -128,7 +142,9 @@
"picoquery": "2.5.0",
"pretty-bytes": "5.6.0",
"pretty-ms": "7.0.1",
+ "rou3": "^0.8.1",
"split2": "4.2.0",
+ "srvx": "^0.11.9",
"yargs": "17.7.2"
},
"devDependencies": {
diff --git a/packages/api-server/src/__tests__/udFetchable.test.ts b/packages/api-server/src/__tests__/udFetchable.test.ts
new file mode 100644
index 0000000000..0641bf8c67
--- /dev/null
+++ b/packages/api-server/src/__tests__/udFetchable.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { CedarHandler, CedarRequestContext } from '@cedarjs/api/runtime'
+import { buildCedarContext } from '@cedarjs/api/runtime'
+
+import { createCedarFetchable } from '../udFetchable.js'
+
+vi.mock('@cedarjs/api/runtime', async (importOriginal) => {
+ const actual = (await importOriginal()) as any
+ return {
+ ...actual,
+ buildCedarContext: vi.fn().mockImplementation(actual.buildCedarContext),
+ }
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('createCedarFetchable', () => {
+ describe('wraps a CedarHandler', () => {
+ it('calls buildCedarContext and the handler, and returns the handler Response', async () => {
+ let capturedCtx: CedarRequestContext | undefined
+
+ const handler: CedarHandler = async (_req, ctx) => {
+ capturedCtx = ctx
+ return new Response('ok', { status: 200 })
+ }
+
+ const fetchable = createCedarFetchable(handler)
+ const request = new Request('http://localhost/test')
+
+ const response = await fetchable.fetch(request)
+
+ expect(buildCedarContext).toHaveBeenCalledWith(request)
+ expect(capturedCtx).toBeDefined()
+ expect(response.status).toBe(200)
+ })
+
+ it('returns the Response from the handler', async () => {
+ const handler: CedarHandler = async () => {
+ return new Response('hello world', {
+ status: 201,
+ headers: { 'x-custom': 'value' },
+ })
+ }
+
+ const fetchable = createCedarFetchable(handler)
+ const response = await fetchable.fetch(
+ new Request('http://localhost/test'),
+ )
+
+ expect(response.status).toBe(201)
+ expect(response.headers.get('x-custom')).toBe('value')
+ expect(await response.text()).toBe('hello world')
+ })
+ })
+
+ describe('passes the correct context to the handler', () => {
+ it('passes query params from the URL', async () => {
+ let capturedCtx: CedarRequestContext | undefined
+
+ const handler: CedarHandler = async (_req, ctx) => {
+ capturedCtx = ctx
+ return new Response('ok')
+ }
+
+ const fetchable = createCedarFetchable(handler)
+ await fetchable.fetch(
+ new Request('http://localhost/test?name=cedar&version=1'),
+ )
+
+ expect(capturedCtx?.query.get('name')).toBe('cedar')
+ expect(capturedCtx?.query.get('version')).toBe('1')
+ })
+
+ it('passes cookies from request headers', async () => {
+ let capturedCtx: CedarRequestContext | undefined
+
+ const handler: CedarHandler = async (_req, ctx) => {
+ capturedCtx = ctx
+ return new Response('ok')
+ }
+
+ const fetchable = createCedarFetchable(handler)
+ await fetchable.fetch(
+ new Request('http://localhost/test', {
+ headers: { cookie: 'session=abc123; theme=dark' },
+ }),
+ )
+
+ expect(capturedCtx?.cookies.get('session')).toBe('abc123')
+ expect(capturedCtx?.cookies.get('theme')).toBe('dark')
+ })
+
+ it('has empty params by default (no route params injected)', async () => {
+ let capturedCtx: CedarRequestContext | undefined
+
+ const handler: CedarHandler = async (_req, ctx) => {
+ capturedCtx = ctx
+ return new Response('ok')
+ }
+
+ const fetchable = createCedarFetchable(handler)
+ await fetchable.fetch(new Request('http://localhost/test'))
+
+ expect(capturedCtx?.params).toEqual({})
+ })
+ })
+})
diff --git a/packages/api-server/src/udDispatcher.ts b/packages/api-server/src/udDispatcher.ts
new file mode 100644
index 0000000000..dae60fbafd
--- /dev/null
+++ b/packages/api-server/src/udDispatcher.ts
@@ -0,0 +1,288 @@
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+
+import type { EntryMeta } from '@universal-deploy/store'
+import fg from 'fast-glob'
+import { addRoute, createRouter, findRoute } from 'rou3'
+
+import type { CedarHandler } from '@cedarjs/api/runtime'
+import { buildCedarContext, requestToLegacyEvent } from '@cedarjs/api/runtime'
+import type { GlobalContext } from '@cedarjs/context'
+import { getAsyncStoreInstance } from '@cedarjs/context/dist/store'
+import { getPaths } from '@cedarjs/project-config'
+
+import type { Fetchable } from './udFetchable.js'
+import { createCedarFetchable } from './udFetchable.js'
+
+const ALL_HTTP_METHODS = [
+ 'GET',
+ 'HEAD',
+ 'POST',
+ 'PUT',
+ 'DELETE',
+ 'PATCH',
+ 'OPTIONS',
+ 'CONNECT',
+ 'TRACE',
+] as const
+const GRAPHQL_METHODS = ['GET', 'POST', 'OPTIONS'] as const
+
+export interface CedarDispatcherOptions {
+ apiRootPath?: string
+ discoverFunctionsGlob?: string | string[]
+}
+
+export interface CedarDispatcherResult {
+ fetchable: Fetchable
+ registrations: EntryMeta[]
+}
+
+/**
+ * Normalizes the api root path so it always starts and ends with a `/`.
+ * e.g. `v1` → `/v1/`, `/v1` → `/v1/`, `/` → `/`
+ */
+function normalizeApiRootPath(rootPath: string): string {
+ let normalized = rootPath
+
+ if (!normalized.startsWith('/')) {
+ normalized = '/' + normalized
+ }
+
+ if (!normalized.endsWith('/')) {
+ normalized = normalized + '/'
+ }
+
+ return normalized
+}
+
+// NOTE: The runtime function-discovery approach used here (scanning
+// `api/dist/functions/` with fast-glob at startup) is now used by two
+// callers:
+// 1. `cedarUniversalDeployPlugin` — the Phase 4 Vite plugin that registers
+// `virtual:cedar-api` in the API server Vite build
+// 2. `cedarDevDispatcherPlugin` — the Phase 4 Vite dev middleware that
+// serves API requests from the unified Vite dev host
+//
+// TODO: Once the API server is fully Vite-built and Phase 5 introduces
+// per-route static entry registration, the fast-glob discovery path can be
+// removed in favour of statically-known routes. Until then, this function
+// remains the shared aggregate dispatcher for both callers.
+/**
+ * Shared aggregate Cedar API dispatcher used by
+ * `cedarUniversalDeployPlugin` (via `virtual:cedar-api`) and
+ * `cedarDevDispatcherPlugin` (the unified Vite dev host middleware).
+ *
+ * Discovers Cedar API functions in `api/dist/functions/`, builds a rou3 router
+ * and a map of route names to Fetchables, then returns a single Fetchable that
+ * routes incoming Fetch-API requests to the correct per-function handler.
+ * Also returns the list of `EntryMeta` registrations so callers can forward
+ * them to `@universal-deploy/store` via `addEntry()`.
+ */
+export async function buildCedarDispatcher(
+ options?: CedarDispatcherOptions,
+): Promise {
+ const normalizedApiRootPath = normalizeApiRootPath(
+ options?.apiRootPath ?? '/',
+ )
+ const discoverFunctionsGlob =
+ options?.discoverFunctionsGlob ?? 'dist/functions/**/*.{ts,js}'
+
+ // Discover function files in api/dist/functions/
+ // deep: 2 is intentional: with cwd=api/, depth 1 is dist/ and depth 2 is
+ // dist/functions/, so one level of subdirectory nesting below
+ // dist/functions/ (e.g. dist/functions/nested/nested.js) is supported but
+ // deeper nesting is not. This matches the behaviour of the Fastify-based
+ // lambdaLoader and @cedarjs/internal's findApiDistFunctions, which carry
+ // the same deep: 2 limit with the explicit note "We don't support deeply
+ // nested api functions, to maximise compatibility with deployment providers".
+ // See packages/internal/src/files.ts
+ const serverFunctions = fg.sync(discoverFunctionsGlob, {
+ cwd: getPaths().api.base,
+ deep: 2,
+ absolute: true,
+ })
+
+ // Put the graphql function first for consistent load ordering
+ const graphqlIdx = serverFunctions.findIndex(
+ (x) => path.basename(x, path.extname(x)) === 'graphql',
+ )
+
+ if (graphqlIdx >= 0) {
+ const [graphqlFn] = serverFunctions.splice(graphqlIdx, 1)
+ serverFunctions.unshift(graphqlFn)
+ }
+
+ // Build fetchable map: routeName -> Fetchable
+ const fetchableMap = new Map()
+
+ // Build rou3 router for URL pattern matching
+ const router = createRouter()
+
+ const registrations: EntryMeta[] = []
+
+ for (const fnPath of serverFunctions) {
+ const routeName = path.basename(fnPath, path.extname(fnPath))
+ const routePath = routeName === 'graphql' ? '/graphql' : `/${routeName}`
+
+ const fnImport = await import(pathToFileURL(fnPath).href)
+
+ // Check if this is a GraphQL function — the babel plugin adds
+ // `__rw_graphqlOptions` to api/dist/functions/graphql.js
+ if (
+ '__rw_graphqlOptions' in fnImport &&
+ fnImport.__rw_graphqlOptions != null
+ ) {
+ const { createGraphQLYoga } = await import('@cedarjs/graphql-server')
+
+ // Cast through unknown to bridge the CJS/ESM module resolution type
+ // mismatch: the static import resolves to CJS types in a CJS build, while
+ // the dynamic import always resolves to ESM types. Deriving the type from
+ // createGraphQLYoga itself guarantees both sides use the same resolution.
+ const graphqlOptions =
+ fnImport.__rw_graphqlOptions as unknown as Parameters<
+ typeof createGraphQLYoga
+ >[0]
+
+ const { yoga } = createGraphQLYoga(graphqlOptions)
+
+ const graphqlFetchable: Fetchable = {
+ async fetch(request: Request): Promise {
+ const cedarContext = await buildCedarContext(request, {
+ authDecoder: graphqlOptions.authDecoder,
+ })
+ const event = await requestToLegacyEvent(request, cedarContext)
+
+ // Phase 1 transitional context bridge: pass both Fetch-native fields
+ // (request, cedarContext) and legacy bridge fields (event,
+ // requestContext) so that Cedar-owned Yoga plugins that have not yet
+ // migrated to the Fetch-native shape continue to work.
+ return yoga.handle(request, {
+ request,
+ cedarContext,
+ event,
+ requestContext: undefined,
+ })
+ },
+ }
+
+ fetchableMap.set(routeName, graphqlFetchable)
+
+ registrations.push({
+ id: routePath,
+ route: routePath,
+ method: [...GRAPHQL_METHODS],
+ })
+
+ for (const method of GRAPHQL_METHODS) {
+ addRoute(router, method, routePath, routeName)
+ addRoute(router, method, `${routePath}/**`, routeName)
+ }
+
+ // Skip regular handler processing for the graphql function
+ continue
+ }
+
+ // Only Fetch-native handlers are supported by the Universal Deploy server.
+ // Functions that export only a legacy Lambda-shaped `handler` are not
+ // WinterTC-compatible and must be migrated to `export async function
+ // handle(request, ctx)` before they can be served by this runtime.
+ const cedarHandler: CedarHandler | undefined = (() => {
+ if ('handle' in fnImport && typeof fnImport.handle === 'function') {
+ return fnImport.handle as CedarHandler
+ }
+
+ if (
+ 'default' in fnImport &&
+ fnImport.default != null &&
+ 'handle' in fnImport.default &&
+ typeof fnImport.default.handle === 'function'
+ ) {
+ return fnImport.default.handle as CedarHandler
+ }
+
+ return undefined
+ })()
+
+ if (!cedarHandler) {
+ console.warn(
+ routeName,
+ 'at',
+ fnPath,
+ 'does not export a Fetch-native `handle` function and will not be' +
+ ' served by the Universal Deploy server. Migrate to' +
+ ' `export async function handle(request, ctx)` or use' +
+ ' `yarn cedar serve` for legacy Lambda-shaped handler support.',
+ )
+ continue
+ }
+
+ const handler = cedarHandler
+
+ fetchableMap.set(routeName, createCedarFetchable(handler))
+
+ registrations.push({
+ id: routePath,
+ route: routePath,
+ // method omitted → matches all HTTP methods per @universal-deploy/store docs
+ })
+
+ for (const method of ALL_HTTP_METHODS) {
+ addRoute(router, method, routePath, routeName)
+ addRoute(router, method, `${routePath}/**`, routeName)
+ }
+ }
+
+ const fetchable: Fetchable = {
+ fetch(request: Request): Promise {
+ return getAsyncStoreInstance().run(
+ new Map(),
+ async () => {
+ const url = new URL(request.url)
+ let routePathname = url.pathname
+
+ // Strip the apiRootPath prefix so that `/api/hello` becomes `/hello`
+ if (
+ normalizedApiRootPath !== '/' &&
+ routePathname.startsWith(normalizedApiRootPath)
+ ) {
+ // normalizedApiRootPath ends with '/', so slice length - 1 to keep
+ // the leading slash on the remaining path segment
+ routePathname = routePathname.slice(
+ normalizedApiRootPath.length - 1,
+ )
+ }
+
+ if (!routePathname.startsWith('/')) {
+ routePathname = '/' + routePathname
+ }
+
+ const match = findRoute(router, request.method, routePathname)
+
+ if (!match) {
+ return new Response('Not Found', { status: 404 })
+ }
+
+ const matchedRouteName = match.data
+ const fnFetchable = fetchableMap.get(matchedRouteName)
+
+ if (!fnFetchable) {
+ return new Response('Not Found', { status: 404 })
+ }
+
+ try {
+ return await fnFetchable.fetch(request)
+ } catch (err) {
+ console.error(
+ 'Unhandled error in fetch handler for route',
+ matchedRouteName,
+ err,
+ )
+ return new Response('Internal Server Error', { status: 500 })
+ }
+ },
+ )
+ },
+ }
+
+ return { fetchable, registrations }
+}
diff --git a/packages/api-server/src/udFetchable.ts b/packages/api-server/src/udFetchable.ts
new file mode 100644
index 0000000000..b3762fccd9
--- /dev/null
+++ b/packages/api-server/src/udFetchable.ts
@@ -0,0 +1,21 @@
+import type { CedarHandler } from '@cedarjs/api/runtime'
+import { buildCedarContext } from '@cedarjs/api/runtime'
+
+export interface Fetchable {
+ fetch(request: Request): Response | Promise
+}
+
+/**
+ * Wraps a CedarHandler in a WinterTC-compatible Fetchable.
+ *
+ * The Fetchable calls buildCedarContext to produce a CedarRequestContext,
+ * then delegates to the handler.
+ */
+export function createCedarFetchable(handler: CedarHandler): Fetchable {
+ return {
+ async fetch(request: Request): Promise {
+ const ctx = await buildCedarContext(request)
+ return handler(request, ctx)
+ },
+ }
+}
diff --git a/packages/cli/src/commands/build/buildHandler.ts b/packages/cli/src/commands/build/buildHandler.ts
index 35f5445150..709fbe89e8 100644
--- a/packages/cli/src/commands/build/buildHandler.ts
+++ b/packages/cli/src/commands/build/buildHandler.ts
@@ -20,6 +20,7 @@ import { loadAndValidateSdls } from '@cedarjs/internal/dist/validateSchema'
import { detectPrerenderRoutes } from '@cedarjs/prerender/detection'
import { type Paths } from '@cedarjs/project-config'
import { timedTelemetry } from '@cedarjs/telemetry'
+import { buildUDApiServer } from '@cedarjs/vite/buildUDApiServer'
import { generatePrismaCommand } from '../../lib/generatePrismaClient.js'
// @ts-expect-error - Types not available for JS files
@@ -233,6 +234,11 @@ export const handler = async ({
title: 'Verifying graphql schema...',
task: loadAndValidateSdls,
},
+ // The API build has two sequential steps:
+ // 1. esbuild compiles api/src/** → api/dist/ (functions, services, etc.)
+ // 2. Vite wraps api/dist/functions/ into a self-contained UD Node server
+ // entry at api/dist/ud/index.js for `cedar serve api`
+ // Step 2 depends on step 1 having completed.
workspace.includes('api') && {
title: 'Building API...',
task: async () => {
@@ -247,6 +253,12 @@ export const handler = async ({
}
},
},
+ workspace.includes('api') && {
+ title: 'Bundling API server entry (Universal Deploy)...',
+ task: async () => {
+ await buildUDApiServer({ verbose })
+ },
+ },
workspace.includes('web') && {
title: 'Building Web...',
task: async () => {
diff --git a/packages/cli/src/commands/dev/devHandler.ts b/packages/cli/src/commands/dev/devHandler.ts
index 975681b6ab..ced14f35db 100644
--- a/packages/cli/src/commands/dev/devHandler.ts
+++ b/packages/cli/src/commands/dev/devHandler.ts
@@ -46,23 +46,12 @@ export const handler = async ({
const serverFile = serverFileExists()
- // Starting values of ports from config (cedar.toml or redwood.toml)
- const apiPreferredPort = parseInt(String(getConfig().api.port))
-
- let webPreferredPort: number | undefined = parseInt(
- String(getConfig().web.port),
- )
-
- // Assume we can have the ports we want
- let apiAvailablePort = apiPreferredPort
- let apiPortChangeNeeded = false
- let webAvailablePort = webPreferredPort
- let webPortChangeNeeded = false
-
- // Check api port, unless there's a serverFile. If there is a serverFile, we
- // don't know what port will end up being used in the end. It's up to the
- // author of the server file to decide and handle that
- if (workspace.includes('api') && !serverFile) {
+ // For the custom-server lane (apps with api/src/server.{ts,js}), we still
+ // need to find a free API port since the server file controls its own
+ // listening and we don't know what port it will use.
+ let apiAvailablePort: number | undefined
+ if (workspace.includes('api') && serverFile) {
+ const apiPreferredPort = parseInt(String(getConfig().api.port))
apiAvailablePort = await getFreePort(apiPreferredPort)
if (apiAvailablePort === -1) {
@@ -70,10 +59,14 @@ export const handler = async ({
message: `Could not determine a free port for the api server`,
})
}
-
- apiPortChangeNeeded = apiAvailablePort !== apiPreferredPort
}
+ let webPreferredPort: number | undefined = parseInt(
+ String(getConfig().web.port),
+ )
+ let webAvailablePort = webPreferredPort
+ let webPortChangeNeeded = false
+
// Check web port
if (workspace.includes('web')) {
// Extract any ports the user forwarded to the dev server and prefer that
@@ -87,10 +80,7 @@ export const handler = async ({
webPreferredPort = port ? parseInt(port, 10) : undefined
}
- webAvailablePort = await getFreePort(webPreferredPort, [
- apiPreferredPort,
- apiAvailablePort,
- ])
+ webAvailablePort = await getFreePort(webPreferredPort)
if (webAvailablePort === -1) {
exitWithError(undefined, {
@@ -102,19 +92,16 @@ export const handler = async ({
}
// Check for port conflict and exit with message if found
- if (apiPortChangeNeeded || webPortChangeNeeded) {
+ if (webPortChangeNeeded) {
const message = [
- 'The currently configured ports for the development server are',
- 'unavailable. Suggested changes to your ports, which can be changed in',
- 'cedar.toml (or redwood.toml), are:\n',
- apiPortChangeNeeded && ` - API to use port ${apiAvailablePort} instead`,
- apiPortChangeNeeded && 'of your currently configured',
- apiPortChangeNeeded && `${apiPreferredPort}\n`,
- webPortChangeNeeded && ` - Web to use port ${webAvailablePort} instead`,
- webPortChangeNeeded && 'of your currently configured',
- webPortChangeNeeded && `${webPreferredPort}\n`,
- '\nCannot run the development server until your configured ports are',
- 'changed or become available.',
+ 'The currently configured port for the development server is',
+ 'unavailable. Suggested change to your port, which can be changed in',
+ 'cedar.toml (or redwood.toml):\n',
+ ` - Web to use port ${webAvailablePort} instead`,
+ 'of your currently configured',
+ `${webPreferredPort}\n`,
+ '\nCannot run the development server until your configured port is',
+ 'changed or becomes available.',
]
.filter(Boolean)
.join(' ')
@@ -130,23 +117,9 @@ export const handler = async ({
errorTelemetry(process.argv, `Error generating prisma client: ${message}`)
console.error(c.error(message))
}
-
- // Again, if a server file is configured, we don't know what port it'll end
- // up using
- if (!serverFile) {
- try {
- await shutdownPort(apiAvailablePort)
- } catch (e) {
- const message = getErrorMessage(e)
- errorTelemetry(process.argv, `Error shutting down "api": ${message}`)
- console.error(
- `Error whilst shutting down "api" port: ${c.error(message)}`,
- )
- }
- }
}
- if (workspace.includes('web')) {
+ if (workspace.includes('web') && webAvailablePort !== undefined) {
try {
await shutdownPort(webAvailablePort)
} catch (e) {
@@ -209,7 +182,13 @@ export const handler = async ({
runWhen?: () => boolean
})[] = []
- if (workspace.includes('api')) {
+ // For the custom-server compatibility lane (apps with api/src/server.{ts,js}),
+ // we still start a separate API process because those apps use Fastify-specific
+ // extension points (configureApiServer, direct plugin registration, etc.) that
+ // are not yet covered by the unified Vite dev runtime. For standard Cedar apps
+ // the cedarDevDispatcherPlugin handles API requests directly inside the Vite
+ // dev server — no separate API process is needed.
+ if (workspace.includes('api') && serverFile) {
jobs.push({
name: 'api',
command: [
@@ -218,7 +197,7 @@ export const handler = async ({
` --watch "${cedarConfigPath}"`,
` --exec "yarn ${serverWatchCommand}`,
` --port ${apiAvailablePort}`,
- ` ${getApiDebugFlag(apiDebugPort, apiAvailablePort)}`,
+ ` ${getApiDebugFlag(apiDebugPort, apiAvailablePort!)}`,
' | rw-log-formatter"',
]
.join(' ')
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index 7c9f406e4d..bf4585003b 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -1,5 +1,6 @@
+import { fork } from 'node:child_process'
import fs from 'node:fs'
-import path from 'path'
+import path from 'node:path'
import { terminalLink } from 'termi-link'
import type { Argv } from 'yargs'
@@ -27,6 +28,7 @@ type ServeArgv = Record & {
socket?: string
apiRootPath?: string
apiHost?: string
+ ud?: boolean
}
export const builder = async (yargs: Argv) => {
@@ -69,7 +71,18 @@ export const builder = async (yargs: Argv) => {
.command({
command: 'api',
description: apiServerCLIConfig.description,
- builder: apiServerCLIConfig.builder,
+ builder: (yargs: Argv) => {
+ if (typeof apiServerCLIConfig.builder === 'function') {
+ apiServerCLIConfig.builder(yargs)
+ }
+ return yargs.option('ud', {
+ // UD serving is the default. Pass --no-ud to use the legacy Fastify server instead.
+ description:
+ 'Use the Universal Deploy server (srvx). This is the default; pass --no-ud to use Fastify instead.',
+ type: 'boolean',
+ default: true,
+ })
+ },
handler: async (argv: ServeArgv) => {
recordTelemetryAttributes({
command: 'serve',
@@ -79,6 +92,58 @@ export const builder = async (yargs: Argv) => {
apiRootPath: argv.apiRootPath,
})
+ if (argv.ud) {
+ // Launch the Vite-built Universal Deploy Node server entry produced
+ // by `cedar build api`. The entry at api/dist/ud/index.js is a
+ // self-contained srvx server that imports virtual:ud:catch-all,
+ // resolved by cedarUniversalDeployPlugin to Cedar's aggregate fetch
+ // dispatcher.
+ const udEntryPath = path.join(getPaths().api.dist, 'ud', 'index.js')
+
+ if (!fs.existsSync(udEntryPath)) {
+ console.error(
+ c.error(
+ `\n Universal Deploy server entry not found at ${udEntryPath}.\n` +
+ ' Please run `yarn cedar build api` before serving.\n',
+ ),
+ )
+ process.exit(1)
+ }
+
+ const udArgs: string[] = []
+
+ if (argv.port) {
+ udArgs.push('--port', String(argv.port))
+ }
+
+ if (argv.host) {
+ udArgs.push('--host', argv.host)
+ }
+
+ await new Promise((resolve, reject) => {
+ const child = fork(udEntryPath, udArgs, {
+ execArgv: process.execArgv,
+ env: {
+ ...process.env,
+ NODE_ENV: process.env.NODE_ENV ?? 'production',
+ PORT: argv.port ? String(argv.port) : process.env.PORT,
+ HOST: argv.host ?? process.env.HOST,
+ },
+ })
+
+ child.on('error', reject)
+ child.on('exit', (code) => {
+ if (code !== 0) {
+ reject(new Error(`UD server exited with code ${code}`))
+ } else {
+ resolve()
+ }
+ })
+ })
+
+ return
+ }
+
// Run the server file, if it exists, api side only
if (serverFileExists()) {
const { apiServerFileHandler } = await import('./serveApiHandler.js')
diff --git a/packages/vite/package.json b/packages/vite/package.json
index e370c2786b..7b9bfe3508 100644
--- a/packages/vite/package.json
+++ b/packages/vite/package.json
@@ -33,6 +33,9 @@
"require": "./dist/cjs/buildFeServer.js",
"import": "./dist/buildFeServer.js"
},
+ "./buildUDApiServer": {
+ "import": "./dist/buildUDApiServer.js"
+ },
"./build": {
"import": "./dist/build/build.js",
"default": "./dist/cjs/build/build.js"
@@ -73,6 +76,7 @@
"@cedarjs/testing": "workspace:*",
"@cedarjs/web": "workspace:*",
"@swc/core": "1.15.24",
+ "@universal-deploy/node": "^0.1.6",
"@vitejs/plugin-react": "4.7.0",
"@whatwg-node/fetch": "0.10.13",
"@whatwg-node/server": "0.10.18",
@@ -112,6 +116,14 @@
"typescript": "5.9.3",
"vitest": "3.2.4"
},
+ "peerDependencies": {
+ "@cedarjs/api-server": "workspace:*"
+ },
+ "peerDependenciesMeta": {
+ "@cedarjs/api-server": {
+ "optional": false
+ }
+ },
"engines": {
"node": ">=24"
},
diff --git a/packages/vite/src/buildUDApiServer.ts b/packages/vite/src/buildUDApiServer.ts
new file mode 100644
index 0000000000..187becddf3
--- /dev/null
+++ b/packages/vite/src/buildUDApiServer.ts
@@ -0,0 +1,94 @@
+import path from 'node:path'
+
+import { getPaths } from '@cedarjs/project-config'
+
+export interface BuildUDApiServerOptions {
+ verbose?: boolean
+ apiRootPath?: string
+}
+
+/**
+ * Builds the API server Universal Deploy Node entry using Vite.
+ *
+ * Runs a Vite server build that:
+ * 1. Installs `cedarUniversalDeployPlugin()` to register `virtual:cedar-api`
+ * and resolve `virtual:ud:catch-all` → Cedar's aggregate fetch dispatcher
+ * 2. Installs `node()` from `@universal-deploy/node/vite` to emit a
+ * self-contained Node server entry at `api/dist/ud/index.js`
+ *
+ * The emitted entry can be launched directly: node api/dist/ud/index.js
+ * That is what `cedar serve api` does.
+ *
+ * NOTE: The Vite "ssr" build target used here is a server-side module build
+ * concern — it is NOT related to Cedar HTML SSR or RSC. "ssr" simply means
+ * Vite produces a Node-compatible bundle rather than a browser bundle.
+ */
+export const buildUDApiServer = async ({
+ verbose = false,
+ apiRootPath,
+}: BuildUDApiServerOptions = {}) => {
+ // Dynamic imports so that vite and the UD plugins are only loaded when
+ // this function is actually called (keeps cold-start cost of importing
+ // @cedarjs/vite low for callers that only need the web build path).
+ const { build } = await import('vite')
+ const { cedarUniversalDeployPlugin } =
+ await import('./plugins/vite-plugin-cedar-universal-deploy.js')
+ const { node } = await import('@universal-deploy/node/vite')
+
+ const rwPaths = getPaths()
+
+ // The UD Node server entry is placed under api/dist/ud/ so it does not
+ // collide with the existing esbuild output under api/dist/.
+ const outDir = path.join(rwPaths.api.dist, 'ud')
+
+ await build({
+ // No configFile — we configure everything inline so this build is
+ // self-contained and does not require a vite.config.ts in api/.
+ configFile: false,
+ envFile: false,
+ logLevel: verbose ? 'info' : 'warn',
+
+ plugins: [
+ // Registers virtual:cedar-api with @universal-deploy/store and resolves
+ // virtual:ud:catch-all → virtual:cedar-api → Cedar's aggregate fetchable.
+ cedarUniversalDeployPlugin({ apiRootPath }),
+
+ // Emits a self-contained Node server entry (api/dist/ud/index.js) that
+ // imports virtual:ud:catch-all and starts an srvx HTTP server.
+ // This is a Vite server-build concern, not Cedar HTML SSR.
+ ...node(),
+ ],
+
+ // The ssr environment is the Vite mechanism for server-side builds.
+ // Reminder: "ssr" here means "server-side module execution", NOT
+ // Cedar HTML SSR / streaming / RSC.
+ environments: {
+ ssr: {
+ build: {
+ outDir,
+ // Ensure @universal-deploy/node is bundled into the output so the
+ // emitted entry is self-contained.
+ rollupOptions: {
+ output: {
+ // Produce a single-file entry where possible; srvx chunks are
+ // split by the node() plugin automatically.
+ entryFileNames: '[name].js',
+ },
+ },
+ },
+ resolve: {
+ // Do not externalise @universal-deploy/node — the node() plugin
+ // requires it to be bundled into the server entry.
+ noExternal: ['@universal-deploy/node'],
+ },
+ },
+ },
+
+ build: {
+ // Write the server entry to api/dist/ud/
+ outDir,
+ // This is a server (Node) build, not a browser build.
+ ssr: true,
+ },
+ })
+}
diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts
index 243287b4c9..8c20969d57 100644
--- a/packages/vite/src/index.ts
+++ b/packages/vite/src/index.ts
@@ -10,11 +10,11 @@ import {
} from '@cedarjs/testing/web/vitest'
import { cedarCellTransform } from './plugins/vite-plugin-cedar-cell.js'
+import { cedarDevDispatcherPlugin } from './plugins/vite-plugin-cedar-dev-dispatcher.js'
import { cedarEntryInjectionPlugin } from './plugins/vite-plugin-cedar-entry-injection.js'
import { cedarHtmlEnvPlugin } from './plugins/vite-plugin-cedar-html-env.js'
import { cedarNodePolyfills } from './plugins/vite-plugin-cedar-node-polyfills.js'
import { cedarRemoveFromBundle } from './plugins/vite-plugin-cedar-remove-from-bundle.js'
-import { cedarWaitForApiServer } from './plugins/vite-plugin-cedar-wait-for-api-server.js'
import { cedarjsResolveCedarStyleImportsPlugin } from './plugins/vite-plugin-cedarjs-resolve-cedar-style-imports.js'
import { cedarTransformJsAsJsx } from './plugins/vite-plugin-jsx-loader.js'
import { cedarMergedConfig } from './plugins/vite-plugin-merged-config.js'
@@ -32,6 +32,10 @@ export { cedarjsJobPathInjectorPlugin } from './plugins/vite-plugin-cedarjs-job-
export { cedarTransformJsAsJsx } from './plugins/vite-plugin-jsx-loader.js'
export { cedarMergedConfig } from './plugins/vite-plugin-merged-config.js'
export { cedarSwapApolloProvider } from './plugins/vite-plugin-swap-apollo-provider.js'
+export { cedarUniversalDeployPlugin } from './plugins/vite-plugin-cedar-universal-deploy.js'
+export { cedarDevDispatcherPlugin } from './plugins/vite-plugin-cedar-dev-dispatcher.js'
+/** @deprecated The default cedar() plugin array now uses cedarDevDispatcherPlugin. This export is kept for apps that reference cedarWaitForApiServer directly. */
+export { cedarWaitForApiServer } from './plugins/vite-plugin-cedar-wait-for-api-server.js'
type PluginOptions = {
mode?: string | undefined
@@ -69,7 +73,7 @@ export function cedar({ mode }: PluginOptions = {}): PluginOption[] {
mode === 'test' && cedarJsRouterImportTransformPlugin(),
mode === 'test' && createAuthImportTransformPlugin(),
mode === 'test' && autoImportsPlugin(),
- cedarWaitForApiServer(),
+ cedarDevDispatcherPlugin(),
cedarNodePolyfills(),
cedarHtmlEnvPlugin(),
cedarEntryInjectionPlugin(),
diff --git a/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts b/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts
new file mode 100644
index 0000000000..40dac9de50
--- /dev/null
+++ b/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts
@@ -0,0 +1,220 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+
+import type { Plugin, ViteDevServer } from 'vite'
+
+import { getConfig } from '@cedarjs/project-config'
+
+type Fetchable = { fetch(request: Request): Response | Promise }
+
+let cachedDispatcher: Fetchable | null = null
+// Each invalidation increments this counter. The in-flight build closure
+// captures the generation at start and checks it before writing
+// cachedDispatcher, so a superseded build never overwrites a newer one.
+let dispatcherGeneration = 0
+let buildPromise: Promise | null = null
+
+async function getDispatcher(): Promise {
+ if (cachedDispatcher !== null) {
+ return cachedDispatcher
+ }
+
+ if (buildPromise !== null) {
+ return buildPromise
+ }
+
+ // Capture the current generation so we can detect if we've been
+ // invalidated by the time the build finishes.
+ const generationAtStart = dispatcherGeneration
+
+ buildPromise = (async () => {
+ // Recompile api/src/ -> api/dist/ before loading the dispatcher, so the
+ // dispatcher always reads fresh build artifacts. We use rebuildApi when a
+ // build context already exists (incremental rebuild is faster), and fall
+ // back to a full buildApi on the very first run or after a clean.
+ try {
+ const { rebuildApi, buildApi } =
+ await import('@cedarjs/internal/dist/build/api')
+ try {
+ await rebuildApi()
+ } catch {
+ // rebuildApi can throw if there is no existing build context yet
+ // (e.g. first run). Fall back to a full build.
+ await buildApi()
+ }
+ } catch (err) {
+ console.warn(
+ '[cedar-dev-dispatcher] API compilation failed; serving with last-known-good dist:',
+ err,
+ )
+ }
+
+ const { buildCedarDispatcher } =
+ await import('@cedarjs/api-server/udDispatcher')
+ const { fetchable } = await buildCedarDispatcher()
+
+ // Only commit if we are still the current generation. If invalidate() was
+ // called while we were building, a newer build will be (or already is)
+ // in-flight and we must not overwrite cachedDispatcher with our stale
+ // result.
+ if (generationAtStart === dispatcherGeneration) {
+ cachedDispatcher = fetchable
+ buildPromise = null
+ }
+
+ return fetchable
+ })()
+
+ return buildPromise
+}
+
+function invalidateDispatcher() {
+ cachedDispatcher = null
+ buildPromise = null
+ // Increment so any in-flight build can detect it has been superseded.
+ dispatcherGeneration++
+}
+
+function isViteInternalRequest(url: string): boolean {
+ return (
+ url.startsWith('/@') ||
+ url.startsWith('/__vite') ||
+ url.startsWith('/__hmr') ||
+ url.includes('?import') ||
+ url.includes('?t=') ||
+ url.includes('?v=')
+ )
+}
+
+function isApiRequest(url: string): boolean {
+ const cedarConfig = getConfig()
+ const apiUrl = cedarConfig.web.apiUrl.replace(/\/$/, '')
+ const apiGqlUrl = cedarConfig.web.apiGraphQLUrl ?? apiUrl + '/graphql'
+
+ return (
+ url.startsWith(apiUrl) ||
+ url === apiGqlUrl ||
+ url.startsWith(apiGqlUrl + '/') ||
+ url.startsWith(apiGqlUrl + '?')
+ )
+}
+
+async function nodeRequestToFetch(req: IncomingMessage): Promise {
+ const host = req.headers.host ?? 'localhost'
+ const url = `http://${host}${req.url ?? '/'}`
+
+ const headers = new Headers()
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (value === undefined) {
+ continue
+ }
+
+ if (Array.isArray(value)) {
+ for (const v of value) {
+ headers.append(key, v)
+ }
+ } else {
+ headers.set(key, value)
+ }
+ }
+
+ const method = (req.method ?? 'GET').toUpperCase()
+ const hasBody = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
+
+ let body: Buffer | undefined
+
+ if (hasBody) {
+ body = await new Promise((resolve, reject) => {
+ const chunks: Buffer[] = []
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
+ req.on('end', () => resolve(Buffer.concat(chunks)))
+ req.on('error', reject)
+ })
+ }
+
+ return new Request(url, {
+ method,
+ headers,
+ body: hasBody && body && body.length > 0 ? new Uint8Array(body) : undefined,
+ })
+}
+
+async function fetchResponseToNode(
+ fetchRes: Response,
+ res: ServerResponse,
+): Promise {
+ res.statusCode = fetchRes.status
+
+ fetchRes.headers.forEach((value, key) => {
+ res.setHeader(key, value)
+ })
+
+ const bodyBuffer = await fetchRes.arrayBuffer()
+
+ if (bodyBuffer.byteLength > 0) {
+ res.end(Buffer.from(bodyBuffer))
+ } else {
+ res.end()
+ }
+}
+
+export function cedarDevDispatcherPlugin(): Plugin {
+ return {
+ name: 'cedar-dev-dispatcher',
+ apply: 'serve',
+
+ configureServer(server: ViteDevServer) {
+ server.watcher.on('change', (filePath: string) => {
+ if (filePath.includes('/api/src/')) {
+ invalidateDispatcher()
+ }
+ })
+
+ server.middlewares.use(
+ async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
+ const url = req.url ?? '/'
+
+ if (isViteInternalRequest(url)) {
+ return next()
+ }
+
+ if (!isApiRequest(url)) {
+ return next()
+ }
+
+ try {
+ const dispatcher = await getDispatcher()
+ const fetchRequest = await nodeRequestToFetch(req)
+ const fetchResponse = await dispatcher.fetch(fetchRequest)
+ await fetchResponseToNode(fetchResponse, res)
+ } catch (err) {
+ console.error(
+ '[cedar-dev-dispatcher] Error handling API request:',
+ err,
+ )
+
+ if (!res.headersSent) {
+ res.writeHead(500, { 'Content-Type': 'application/json' })
+ }
+
+ res.end(
+ JSON.stringify(
+ {
+ errors: [
+ {
+ message:
+ err instanceof Error
+ ? err.message
+ : 'Internal Server Error',
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ )
+ }
+ },
+ )
+ },
+ }
+}
diff --git a/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts b/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts
new file mode 100644
index 0000000000..36df9bad1e
--- /dev/null
+++ b/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts
@@ -0,0 +1,56 @@
+import { addEntry, catchAllEntry } from '@universal-deploy/store'
+import type { Plugin } from 'vite'
+
+export interface CedarUniversalDeployPluginOptions {
+ apiRootPath?: string
+}
+
+const VIRTUAL_CEDAR_API = 'virtual:cedar-api'
+const RESOLVED_VIRTUAL_CEDAR_API = '\0virtual:cedar-api'
+
+export function cedarUniversalDeployPlugin(
+ options: CedarUniversalDeployPluginOptions = {},
+): Plugin {
+ const { apiRootPath } = options
+
+ return {
+ name: 'cedar-universal-deploy',
+ apply: 'build',
+
+ buildStart() {
+ addEntry({
+ id: VIRTUAL_CEDAR_API,
+ route: '/**',
+ })
+ },
+
+ resolveId(id) {
+ if (id === catchAllEntry) {
+ return RESOLVED_VIRTUAL_CEDAR_API
+ }
+
+ if (id === VIRTUAL_CEDAR_API) {
+ return RESOLVED_VIRTUAL_CEDAR_API
+ }
+
+ return undefined
+ },
+
+ load(id) {
+ if (id !== RESOLVED_VIRTUAL_CEDAR_API) {
+ return undefined
+ }
+
+ const apiRootPathArg =
+ apiRootPath !== undefined
+ ? `{ apiRootPath: ${JSON.stringify(apiRootPath)} }`
+ : 'undefined'
+
+ return `
+import { buildCedarDispatcher } from '@cedarjs/api-server/udDispatcher';
+const { fetchable } = await buildCedarDispatcher(${apiRootPathArg});
+export default fetchable;
+`
+ },
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 2813d536fb..04be5d684b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2171,6 +2171,8 @@ __metadata:
"@types/dotenv-defaults": "npm:^5.0.0"
"@types/split2": "npm:4.2.3"
"@types/yargs": "npm:17.0.35"
+ "@universal-deploy/node": "npm:^0.1.6"
+ "@universal-deploy/store": "npm:^0.2.1"
ansis: "npm:4.2.0"
chokidar: "npm:3.6.0"
dotenv-defaults: "npm:5.0.2"
@@ -2184,7 +2186,9 @@ __metadata:
pino-abstract-transport: "npm:1.2.0"
pretty-bytes: "npm:5.6.0"
pretty-ms: "npm:7.0.1"
+ rou3: "npm:^0.8.1"
split2: "npm:4.2.0"
+ srvx: "npm:^0.11.9"
tsx: "npm:4.21.0"
typescript: "npm:5.9.3"
vitest: "npm:3.2.4"
@@ -3762,6 +3766,7 @@ __metadata:
"@types/react": "npm:^18.2.55"
"@types/ws": "npm:^8"
"@types/yargs-parser": "npm:21.0.3"
+ "@universal-deploy/node": "npm:^0.1.6"
"@vitejs/plugin-react": "npm:4.7.0"
"@whatwg-node/fetch": "npm:0.10.13"
"@whatwg-node/server": "npm:0.10.18"
@@ -3791,6 +3796,11 @@ __metadata:
vitest: "npm:3.2.4"
ws: "npm:8.20.0"
yargs-parser: "npm:21.1.1"
+ peerDependencies:
+ "@cedarjs/api-server": "workspace:*"
+ peerDependenciesMeta:
+ "@cedarjs/api-server":
+ optional: false
bin:
rw-dev-fe: ./dist/devFeServer.js
rw-serve-fe: ./dist/runFeServer.js
@@ -11520,6 +11530,37 @@ __metadata:
languageName: node
linkType: hard
+"@universal-deploy/node@npm:^0.1.6":
+ version: 0.1.6
+ resolution: "@universal-deploy/node@npm:0.1.6"
+ dependencies:
+ "@universal-deploy/store": "npm:^0.2.1"
+ magic-string: "npm:^0.30.21"
+ srvx: "npm:^0.11.9"
+ peerDependencies:
+ vite: ">=7.1"
+ peerDependenciesMeta:
+ vite:
+ optional: true
+ checksum: 10c0/2fcabae33a015644c7cb24f2a90e61cf4c10bbd505493bfb1cb5ccf6599974bc9b14343a057ff487748eb55967ebda92632d17412ed8b7fde347adb70de60c34
+ languageName: node
+ linkType: hard
+
+"@universal-deploy/store@npm:^0.2.1":
+ version: 0.2.1
+ resolution: "@universal-deploy/store@npm:0.2.1"
+ dependencies:
+ rou3: "npm:^0.8.1"
+ srvx: "npm:*"
+ peerDependencies:
+ srvx: "*"
+ peerDependenciesMeta:
+ srvx:
+ optional: true
+ checksum: 10c0/8079a2d41d17b5b9a8d3dc5859ca18875d989fb695d592bee7a8dff13dfdf34af682384c4cc577ed10b2fbba9686953bdcb6ce4fb528548f10c5a8f9191ed8fe
+ languageName: node
+ linkType: hard
+
"@vitejs/plugin-react@npm:4.7.0":
version: 4.7.0
resolution: "@vitejs/plugin-react@npm:4.7.0"
@@ -26809,6 +26850,13 @@ __metadata:
languageName: unknown
linkType: soft
+"rou3@npm:^0.8.1":
+ version: 0.8.1
+ resolution: "rou3@npm:0.8.1"
+ checksum: 10c0/c8728cf3c41833db0e20cbadba07b3c678b8b9fb12db1d8803f275a7a6cce02d0be9bee79367575883f65659c9c0ed1001e6527146ed27772e439e5d6c68d264
+ languageName: node
+ linkType: hard
+
"run-applescript@npm:^7.0.0":
version: 7.1.0
resolution: "run-applescript@npm:7.1.0"
@@ -27654,6 +27702,15 @@ __metadata:
languageName: node
linkType: hard
+"srvx@npm:*, srvx@npm:^0.11.9":
+ version: 0.11.15
+ resolution: "srvx@npm:0.11.15"
+ bin:
+ srvx: bin/srvx.mjs
+ checksum: 10c0/3f72be7bfb321ad21ae7698a721f1a16b855313d1fa8498a0d68adbec65f8f2d2c5a83cf37849f6489c7403870535a70958976636df3d5274cd785b61b7aa635
+ languageName: node
+ linkType: hard
+
"ssh2@npm:^1.14.0":
version: 1.17.0
resolution: "ssh2@npm:1.17.0"