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 0a29b40316..552a82da27 100644
--- a/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md
+++ b/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md
@@ -110,29 +110,36 @@ CLIENT CELL (GraphQL via Apollo):
```
cedar dev:
- concurrently ─┬─ cedar-unified-dev (single process, both sides)
- │ ├─ Vite SSR dev server for API (Fastify in-process,
- │ │ Babel transforms via Vite plugin, HMR via module
- │ │ graph invalidation – no rebuild, no restart)
- │ └─ Vite client dev server for Web (SPA, HMR)
- └─ cedar-gen-watch (regenerate types on SDL or Prisma schema
- change)
-
- Fallback (api-only or web-only, streamingSsr, custom serverFile, or missing api/src or web/src): separate processes
- api: cedar-api-server-watch (CJS projects) or cedarjs-api-server-watch (ESM projects) (chokidar + esbuild, kept for SSR/RSC)
- web: cedar-vite-dev (SPA) or cedar-dev-fe (Streaming SSR)
+ Default (no flags):
+ concurrently ─┬─ api: cedar-api-server-watch (CJS) or cedarjs-api-server-watch (ESM)
+ │ (chokidar + esbuild, kept for SSR/RSC)
+ ├─ web: cedar-vite-dev (SPA) or cedar-dev-fe (Streaming SSR)
+ └─ cedar-gen-watch (regenerate types on SDL or Prisma schema
+ change)
+
+ With --ud (opt-in unified dev):
+ concurrently ─┬─ cedar-unified-dev (single process, both sides)
+ │ ├─ Vite SSR dev server for API (Fastify in-process,
+ │ │ Babel transforms via Vite plugin, HMR via module
+ │ │ graph invalidation – no rebuild, no restart)
+ │ └─ Vite client dev server for Web (SPA, HMR)
+ └─ cedar-gen-watch
*SSR/RSC: cedar-vite-dev adds Express + Vite SSR servers. See [SSR-RSC-DOC].
cedar build:
prisma gen → GraphQL types → validate SDLs →
API (Vite SSR build → api/dist/, preserveModules, Babel plugin) →
+ UD (Vite SSR build → api/dist/ud/index.js, self-contained Node entry, only
+ when --ud is passed) →
Web (Vite → web/dist/) → prerender marked routes
*SSR/RSC: adds route hooks build, route manifest, SSR client+server builds.
Vite plugins: cell transform | entry injection | html env | node polyfills |
- auto-imports | import-dir | js-as-jsx | merged config | api-babel-transform
+ auto-imports | import-dir | js-as-jsx | merged config | api-babel-transform |
+ cedar-universal-deploy | cedar-dev-dispatcher (not in use yet, prepared for
+ future work)
*SSR/RSC: adds RSC transforms
```
@@ -204,45 +211,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. `startApiDevServer()` → Vite SSR dev server + Fastify in-process with HMR for the API side. `buildApiWithVite()` → Vite SSR production build. \*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/buildApiWithVite/dev/generate. Route extraction. |
-| api-server | Fastify. Auto-discover Lambda functions. Mount GraphQL. Custom server.ts. Exports `requestHandlers` used by the Vite API dev server. |
-| 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. `startApiDevServer()` → Vite SSR dev server + Fastify in-process with HMR for the API side. `buildApiWithVite()` → Vite SSR production build. `buildUDApiServer()` → self-contained Universal Deploy Node entry. `cedarUniversalDeployPlugin` for UD build; `cedarDevDispatcherPlugin` prepared for UD single-port dev. \*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/buildApiWithVite/dev/generate. Route extraction. |
+| api-server | Fastify. Auto-discover Lambda functions. Mount GraphQL. Custom server.ts. Exports `requestHandlers` used by the Vite API dev server. Opt-in srvx/WinterTC path via `cedar serve api --ud`. |
+| 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
@@ -259,6 +266,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`
+- `buildUDApiServer()` — Vite SSR build that produces a self-contained Node
+ server entry at `api/dist/ud/index.js`, in `@cedarjs/vite`
+- `cedarUniversalDeployPlugin()` — Vite plugin that registers Cedar's aggregate
+ API entry with the UD store, in `@cedarjs/vite`
+- `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` (forks the Vite-built `api/dist/ud/index.js` entry)
+
+#### 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` and `cedar-ud-server` — the temporary srvx runtime stand-in
+ from Phase 3. Phase 4 replaces them with `buildUDApiServer()`, a Vite SSR
+ build that produces a self-contained Node server entry via
+ `@universal-deploy/node/vite`'s `node()` plugin.
+- `cedar serve api --ud` CLI flag — updated in Phase 4 to fork the Vite-built
+ `api/dist/ud/index.js` entry instead of invoking the deleted `createUDServer`.
+ The flag remains **opt-in** (not the default) to preserve backward
+ compatibility with existing Fastify setups.
+
+**Keep in Phase 4:**
+
+- `buildCedarDispatcher` (`packages/api-server/src/udDispatcher.ts`) —
+ still needed at build time by `cedarUniversalDeployPlugin` to resolve
+ `virtual:ud:catch-all` → Cedar's aggregate fetchable. It is bundled into
+ the Vite-built output, not executed at runtime in production.
**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`.
+Full `@universal-deploy/node` end-to-end arrives in Phase 4.
---
@@ -747,77 +813,202 @@ Depends on Phases 2 and 3.
#### Goal
-Replace the current web+API split dev model with a single Vite-hosted
-development entrypoint.
+Introduce a unified `cedar dev` command that runs both web and API sides
+from a single CLI entrypoint, while preserving a compatibility path for
+existing apps that depend on custom Fastify server setup. The dev
+runtime still uses two ports (`8910` web + `8911` API) in this phase;
+moving to a single visible port is Phase 5 work.
#### Work
-- Eliminate the `8910 → proxy → 8911` mental model
-- Route page, GraphQL, auth, and function requests through one
- externally visible dev host
-- Integrate backend handler execution into the Vite dev runtime
- (likely via Vite's `server.middlewareMode` or custom plugin)
+- Make `cedar-unified-dev` available via an opt-in `--ud` flag on
+ `cedar dev`: one CLI process that orchestrates the web Vite dev
+ server and the API dev server together. The default `cedar dev`
+ behaviour (without `--ud`) remains unchanged.
+- Keep the existing proxy model (`8910 → proxy → 8911`) for the default
+ Cedar runtime path in this phase
+- Move API code compilation into the Vite module graph (via Vite SSR +
+ Babel transform) so API functions get true HMR without nodemon
+ restarts
- 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)
+ 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
+- `cedar dev --ud` starts both web and API dev servers in one process
+- API code compiled through Vite's module graph with true HMR (when `--ud` is used)
+- `@universal-deploy/node` wired end-to-end: Vite builds a
+ self-contained server entry; `cedar serve api --ud` forks it
+- A documented compatibility path for apps with custom Fastify server
+ setup
#### Exit Criteria
-- Cedar dev no longer requires a separately exposed backend port
-- Requests to functions and GraphQL can be made directly against the
- Vite dev host
+- `cedar dev --ud` runs both web and API dev servers from one CLI command
+- API functions receive Vite HMR without nodemon process restarts (when `--ud` is used)
+- `cedar serve api --ud` runs an `@universal-deploy/node`-built server entry,
+ completing the Phase 3 goal of making Fastify-free serving possible
+- 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). Developers see one port, one
-process, simpler mental model. Config files may need minor updates.
+**User-facing impact**: Medium (positive). Developers get one CLI command
+and faster API HMR. The port model is still two ports (`8910` + `8911`);
+the single-port simplification arrives in Phase 5. Existing apps with
+custom Fastify setup remain on a compatibility path until a later migration
+story exists. Config files may need minor updates.
---
-### Phase 5: Formalise the Cedar UD Vite Plugin
+### Phase 5: Idiomatic Vite Full-Stack Integration
-**Effort: M (Medium)**
+**Effort: L (Large)**
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.
+Close the architectural gap between Phase 4's incremental bridge and an
+idiomatic Vite full-stack integration. Phase 4 delivered user-facing wins
+(one `cedar dev` command, API HMR, Vite-built serve output) using two HTTP
+listeners in dev and three separate `viteBuild()` calls in production. Phase
+5 makes the underlying architecture match what the Vite team recommends for
+full-stack frameworks.
+
+#### Two Workstreams
+
+**1. Single-listener dev server**
+
+Replace the two-listener dev model with one Vite dev server on a single
+visible port. API requests are handled inline via Vite middleware rather
+than by a separate Fastify listener. This eliminates the last proxy/port
+split, simplifies auth flows and CORS, and aligns Cedar with Nuxt,
+SvelteKit, and other Vite full-stack frameworks.
+
+- Install `cedarDevDispatcherPlugin` (already built and exported in
+ Phase 4) into the web Vite dev server's `configureServer` middleware
+ stack. When the plugin is active, API requests are served inline
+ without proxying to a separate port.
+- Remove the separate Fastify API listener from `cedar-unified-dev`;
+ the web Vite server becomes the only visible HTTP listener.
+
+**2. `buildApp()` with declared environments**
+
+Replace the three standalone `viteBuild()` calls with a single `buildApp()`
+invocation that declares `client` and `api` environments. Both environments
+share one module graph, one transform pipeline, and consistent resolution.
+This reduces build time, eliminates silent divergence between client and API
+builds, and prepares the infrastructure for a future SSR environment.
+
+#### Deliverables
+
+- refactored `cedar-unified-dev` using a single Vite dev server with inline
+ API middleware (no separate API listener)
+- refactored `cedar build` using `buildApp()` with `client` and `api`
+ environments
+- updated documentation reflecting the single-port dev model and unified build
+
+#### Exit Criteria
+
+- `cedar dev` runs on one visible port with no separate API listener
+- `cedar build` uses `buildApp()` with declared environments in a single pass
+- All existing Phase 4 functionality continues to work
+- The custom Fastify compatibility lane is unaffected
+
+**User-facing impact**: Low. Internal architecture alignment; no new user
+features.
+
+---
+
+### Phase 6: Formalise the Cedar UD Vite Plugin
+
+**Effort: M (Medium)**
+
+Depends on Phase 5.
+
+#### Goal
+
+Expand `cedarUniversalDeployPlugin()` 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 the Vite integration idiomatic; Phase 6 makes the plugin correct and
+provider-discoverable.
+
+#### Current state after Phase 5
+
+`cedarUniversalDeployPlugin()` exists 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
@@ -830,11 +1021,11 @@ integration complete, correct, and provider-discoverable.
---
-### Phase 6: Rebuild SSR on the New Runtime
+### Phase 7: Rebuild SSR on the New Runtime
**Effort: XL (Extra Large)**
-Design work can begin **during Phases 4–5**. Implementation depends on
+Design work can begin **during Phases 5–6**. Implementation depends on
Phase 1 (handler contract and middleware model).
#### Goal
@@ -871,11 +1062,11 @@ SSR-specific configuration.
---
-### Phase 7: Provider Validation
+### Phase 8: Provider Validation
**Effort: L (Large)**
-Depends on Phases 5 and 6.
+Depends on Phases 6 and 7.
#### Goal
@@ -885,9 +1076,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:
@@ -911,17 +1102,18 @@ targets Cedar cares about.
## Phase Summary
-| Phase | Description | Effort | Parallel? | User-Facing? |
-| ----- | --------------------- | ------ | --------- | ------------ |
-| 1 | Fetch-native handlers | L | — | No (shim) |
-| 2 | Route discovery | M | With 3 | No |
-| 3 | UD adapter adoption | M | With 2 | No |
-| 4 | Vite-centric dev | XL | — | Yes |
-| 5 | UD registration | M | — | No |
-| 6 | SSR rebuild | XL | Design‡ | Yes |
-| 7 | Provider validation | L | — | Yes |
+| Phase | Description | Effort | Parallel? | User-Facing? |
+| ----- | -------------------------- | ------ | --------- | ------------ |
+| 1 | Fetch-native handlers | L | — | No (shim) |
+| 2 | Route discovery | M | With 3 | No |
+| 3 | UD adapter adoption | M | With 2 | No |
+| 4 | Vite-centric dev | XL | — | Yes |
+| 5 | Idiomatic Vite integration | L | — | No |
+| 6 | UD registration | M | — | No |
+| 7 | SSR rebuild | XL | Design‡ | Yes |
+| 8 | Provider validation | L | — | Yes |
-‡ Design work can overlap with Phases 4–5.
+‡ Design work can overlap with Phases 5–6.
## Transitional Developer Experience
@@ -938,13 +1130,19 @@ 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. Internal architecture
+alignment only.
-**After Phase 5**: No visible change for developers. UD integration is
+**After Phase 6**: No visible change for developers. UD integration is
framework-internal.
-**After Phases 6–7**: Full SSR support on the new runtime. Deploy to
+**After Phases 7–8**: Full SSR support on the new runtime. Deploy to
supported providers.
## Migration Path
@@ -1003,24 +1201,27 @@ comments for manual review.
### Migration Guide
A migration guide should accompany each phase that has user-facing
-impact (Phases 4, 6, 7). The guide should cover:
+impact (Phases 4, 7, 8). The guide should cover:
- What changed and why
- 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 | None |
+| 7 | SSR config migration |
+| 8 | Deploy config updates |
## Risks
@@ -1038,6 +1239,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
@@ -1095,5 +1299,5 @@ the middleware model, the remaining UD work becomes implementation
detail instead of architectural guesswork.
Phase 2 and Phase 3 can proceed in parallel immediately after Phase 1.
-Phase 6 design work can start during Phases 4–5. Take advantage of
+Phase 7 design work can start during Phases 5–6. Take advantage of
this parallelism to reduce the overall timeline.
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..173ed8d8e6
--- /dev/null
+++ b/docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md
@@ -0,0 +1,1196 @@
+# 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 introducing a unified `cedar dev`
+command that runs both web and API sides from one CLI entrypoint, while
+moving API code compilation into the Vite module graph for true HMR.
+
+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 (nodemon + esbuild)
+- requests move through a proxy boundary
+- backend invalidation requires full process restarts
+
+Phase 4 improves this by:
+
+- making `cedar-unified-dev` available via an opt-in `--ud` flag on `cedar dev`
+ (one CLI process orchestrating both sides)
+- compiling API code through Vite's module graph so functions get HMR without
+ nodemon restarts
+- wiring `@universal-deploy/node` into the API server build so `cedar serve api --ud`
+ runs a Vite-built entry instead of the temporary direct-server path
+
+Phase 4 still uses **two ports** (`8910` web + `8911` API) and still proxies
+API requests. The single visible port and inline API middleware belong to
+Phase 5.
+
+This phase is also where the temporary Phase 3 scaffolding starts turning into a
+real runtime architecture. In particular:
+
+- the API runtime should execute inside a Vite-centric development environment
+- 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`
+
+Phase 4 is still not the phase where Cedar fully formalises per-route UD entry
+registration. That belongs to Phase 6. 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. **Dev command split**
+ - Developers currently run two separate commands or rely on `concurrently`
+ orchestration. Phase 4 introduces one `cedar dev` command that starts both
+ sides.
+
+2. **No HMR for API code**
+ - Backend code changes currently trigger nodemon process restarts. Phase 4
+ moves API compilation into the Vite module graph so functions get true
+ HMR without restarts.
+
+3. **Serve path split**
+ - `cedar serve` should move onto the same UD-oriented build output that the
+ broader integration is targeting.
+
+4. **Architecture split**
+ - Cedar should stop treating the API runtime as a special non-Vite island in
+ development.
+
+5. **Proxy/port split**
+ - _This is intentionally not solved in Phase 4._ The two-port model persists
+ here because eliminating it requires a full rewrite of the dev server
+ composition (single Vite listener with inline API middleware). That is a
+ Phase 5 goal.
+
+## 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 `cedar dev` command orchestrating both sides
+- backend execution integrated into the Vite module graph for true HMR
+- 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
+- the single visible port and inline API middleware are explicitly Phase 5 goals,
+ not Phase 4 deliverables
+
+It also preserves the phase boundary:
+
+- Phase 4 delivers a working aggregate-entry plugin and runtime
+- Phase 6 expands that into full per-route registration and provider-facing
+ correctness
+
+## Goals
+
+### Primary Goals
+
+- Keep `cedar dev` backward-compatible by default; make the unified Vite-centric
+ runtime available via an opt-in `--ud` flag
+- Route web and API traffic through one development dispatcher when `--ud` is used
+- 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 6 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 is available as an opt-in path (via `--ud`) for
+standard Cedar apps, while the default `cedar dev` behaviour is unchanged. 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 development architecture looks like this:
+
+- `cedar dev` (default) still starts separate web and API dev servers,
+ preserving backward compatibility
+- `cedar dev --ud` (opt-in) starts both the web Vite dev server and the
+ API dev server in one CLI process
+- the web Vite dev server on `8910` handles browser-facing requests as usual
+- API requests are proxied from `8910` to the API dev server on `8911`
+- the API dev server compiles backend modules through Vite's module graph
+ (Vite SSR + Babel transform + Fastify), giving true HMR without nodemon
+ restarts
+- 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 unified runtime.
+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.
+
+**Note**: The single visible port and inline API middleware are Phase 5 goals.
+Phase 4 intentionally keeps the proxy/port split to deliver HMR and unified
+orchestration without a full dev server rewrite.
+
+### Conceptual Request Flow in Dev
+
+The intended request flow in Phase 4 is:
+
+1. request arrives at the web Vite dev host on port `8910`
+2. Vite's proxy forwards API requests to the separate API dev server on port
+ `8911`
+3. the API dev server (Vite SSR + Fastify) classifies and dispatches the
+ request to the appropriate handler
+4. response is returned through the proxy back to the browser
+
+The important change from the pre-Phase-4 state is that the API side now
+compiles through Vite's module graph (giving true HMR) and both servers are
+orchestrated by a single `cedar dev` command. The proxy/port split still
+exists; eliminating it (single visible port, inline middleware) is Phase 5.
+
+### 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 6 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 unified runtime is opt-in (via `--ud`) for 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.
+
+## Vite Architecture Alignment
+
+The Vite team recommends a specific architecture for full-stack frameworks:
+
+- **Dev**: a **single** Vite dev server with an API middleware mounted directly on it. API requests hit the same origin/port as the web dev server and are handled inline by Vite middleware — no separate HTTP listener for the API.
+- **Build**: Vite's **`buildApp`** API (or the builder `buildApp()` hook) used to build the **client** and **SSR/custom** environments together in a single build pass, with environments declared in `vite.config`.
+
+### Current Implementation Gap
+
+The Phase 4 implementation as it exists today is an **incremental step** toward that architecture, but it does not yet match it:
+
+- **Dev**: `cedar-unified-dev` still runs **two HTTP listeners** in one Node process:
+ 1. A Vite SSR dev server (`middlewareMode: true`) + a Fastify app listening on `apiPort`
+ 2. A regular Vite client dev server listening on `webPort`
+ This means the browser still conceptually targets two ports, even though they are orchestrated by one CLI command.
+- **Build**: `buildApiWithVite()` calls `viteBuild()` **standalone** for the API side. It does not yet use `buildApp()` or the builder API with declared environments. Web and API are built as two separate Vite invocations, not as coordinated environments within one `buildApp` pass.
+
+This is acceptable for Phase 4 because it delivers the core operational wins (one CLI command, Vite module graph for API code, HMR) without requiring a full rewrite of the dev server composition. A future phase should close the gap by moving to a true single-listener Vite dev server with inline API middleware, and by adopting `buildApp()` with client + API environments.
+
+## 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 (prepared, not active)
+
+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 layer is implemented as `cedarDevDispatcherPlugin` and is fully built and
+exported from `@cedarjs/vite`, but it is **not installed** in Phase 4's dev
+server. It will be wired into the web Vite dev server's middleware stack in
+Phase 5 when the two-port model is replaced by single-port inline dispatch.
+
+### 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 **prepares** Option A by implementing `cedarDevDispatcherPlugin`, a Vite
+middleware plugin that classifies and dispatches API requests inline. However,
+the plugin is **not wired into the dev server** in Phase 4.
+
+Instead, Phase 4 keeps the two-port model for stability:
+
+- `cedar-unified-dev` starts two listeners (web Vite dev server + API Vite SSR
+ dev server with Fastify)
+- API requests are proxied from the web port to the API port
+- `cedarDevDispatcherPlugin` is built and exported but not installed
+
+The actual switch to single-port inline middleware is a **Phase 5** goal. Phase
+4 intentionally ships a working two-port unified dev command rather than
+risking regressions by changing both the orchestration and the transport layer
+at once.
+
+## 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 6 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 6 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.
+
+### The Default Production Path Has No Fastify
+
+An important implication of Lane A is that **there is no Fastify in production on the default path**. When `cedar serve` runs the Vite-built output through `@universal-deploy/node`, the HTTP server is `srvx` (WinterTC-compatible), not Fastify. There is no `server.register()`, no Fastify plugin system, and no reply/request lifecycle to hook into.
+
+This means:
+
+- **Fastify plugins are not portable to the default lane**. A user who needs a Fastify-specific plugin must either write an equivalent as Cedar middleware (see Phase 1 middleware model) or stay on Lane B (custom Fastify compatibility).
+- **Deployment-level concerns belong to the UD adapter**, not the Cedar app. Compression, TLS termination, rate limiting, edge headers, and static file serving are the responsibility of `@universal-deploy/node` (or the relevant cloud adapter), not `handleRequest()`.
+
+### Testing Deployment Concerns: `cedar dev` vs `cedar serve`
+
+Because deployment-level behavior lives in the UD adapter, it is **not exercised during `cedar dev`** on the default lane. The Vite dev server runs app logic only. If a user wants to verify that compression is active, that CORS headers are correct, or that the static asset pipeline behaves as expected in production, they must run **`cedar serve`**.
+
+This represents a mental model shift from the old architecture:
+
+| | Old architecture | New default architecture |
+| ----------------------------- | ------------------------- | ------------------------------------------- |
+| **Dev** | Fastify (app + plugins) | Vite (app logic only) |
+| **Production** | Fastify (app + plugins) | UD adapter (`srvx` + Cedar `fetch` handler) |
+| **Where to test compression** | `cedar dev` (same server) | `cedar serve` (adapter layer) |
+
+If this split proves too painful in practice, Cedar can add optional dev conveniences (e.g. a compression middleware in the dev dispatcher), but that should be explicitly framed as a dev aid, not the production code path.
+
+## 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 when `--ud` is used
+- 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 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 standard (non-custom-server)
+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 6
+
+### 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 6
+- 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 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?
+11. What is the migration path from the current two-listener dev model
+ (`webPort` + `apiPort`) to the Vite-recommended single-listener model with
+ inline API middleware? Is this a Phase 4 follow-up, or does it belong to
+ Phase 6?
+12. When should Cedar adopt `buildApp()` with declared `client` and `api`
+ environments instead of separate `viteBuild()` calls for each side?
+
+## Exit Criteria for Phase 4
+
+Phase 4 should be considered complete when all of the following are true:
+
+- `cedar dev` starts both web and API dev servers from one CLI command
+- GraphQL requests work against the API dev server on the default runtime path
+- auth requests work against the API dev server on the default runtime path
+- function requests work against the API dev server 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
+- 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 `cedar dev` command that orchestrates web + API dev servers
+- API dev server running through Vite's module graph (Vite SSR + Babel +
+ Fastify) with true HMR
+- aggregate Cedar API fetch dispatcher for the serve path
+- 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 6 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.
+
+Phase 5 closes the architectural gap between Phase 4's incremental bridge and
+an idiomatic Vite full-stack integration. See
+`universal-deploy-phase-5-detailed-plan.md` for details.
diff --git a/docs/implementation-plans/universal-deploy-phase-5-detailed-plan.md b/docs/implementation-plans/universal-deploy-phase-5-detailed-plan.md
new file mode 100644
index 0000000000..aaeab1bbbe
--- /dev/null
+++ b/docs/implementation-plans/universal-deploy-phase-5-detailed-plan.md
@@ -0,0 +1,275 @@
+# Detailed Plan: Universal Deploy Phase 5 — Idiomatic Vite Full-Stack Integration
+
+## Summary
+
+Phase 4 delivered a working Vite-centric full-stack runtime: one `cedar dev`
+command, API HMR through Vite's SSR module graph, and a Vite-built UD Node
+server entry for `cedar serve`. However, the underlying architecture is an
+**incremental bridge** that still deviates from what the Vite team recommends
+for full-stack frameworks.
+
+Phase 5 closes that architectural gap by making Cedar's Vite integration
+**idiomatic**:
+
+- **Dev**: one Vite dev server with a single visible port and API middleware
+ inline — no separate HTTP listener for the API side
+- **Build**: Vite's `buildApp()` API (or the builder `buildApp()` hook) used to
+ build the **client** and **api** environments together in a single build pass,
+ with environments declared in `vite.config`
+
+This is foundational infrastructure, not a user-facing feature. It makes later
+phases (per-route UD registration, SSR rebuild) simpler and more robust by
+ensuring Cedar's Vite integration follows the same patterns as the rest of the
+Vite full-stack ecosystem.
+
+## Why Phase 5 Exists
+
+Phase 4 took the shortest path to user-facing wins. It runs two HTTP listeners
+in dev (web Vite server + API Vite SSR + Fastify) and uses three separate
+`viteBuild()` calls in production. That was the right trade-off for Phase 4,
+but it leaves technical debt that compounds if not addressed before the next
+major milestones.
+
+### Two problems to solve
+
+**1. Two-listener dev model**
+
+`cedar-unified-dev` starts:
+
+- a Vite client dev server on `webPort`
+- a Vite SSR dev server (`middlewareMode: true`) + Fastify on `apiPort`
+
+The browser still conceptually targets two origins. Auth flows, CORS, and
+cookie handling are more complex than they need to be because the API is not
+served from the same origin as the web assets.
+
+**2. Fragmented build pipeline**
+
+`buildApiWithVite()`, `buildUDApiServer()`, and the web client build each call
+`viteBuild()` standalone. There are three independent Vite builds with no shared
+module graph, no shared transform pipeline, and no coordinated invalidation.
+
+## Goals
+
+### Primary Goals
+
+- Replace the two-listener dev model with a **single Vite dev server** that
+ handles both web and API requests on one visible port
+- Reimplement API request handling as **Vite middleware** (via
+ `configureServer` hook or equivalent) rather than a separate Fastify listener
+- Adopt **`buildApp()` with declared environments** for production builds,
+ replacing standalone `viteBuild()` calls for each side
+- Ensure the custom Fastify compatibility lane (Lane B) is **not affected** by
+ these changes
+
+### Secondary Goals
+
+- Preserve all existing Phase 4 dev behavior: HMR, GraphQL, auth, functions,
+ GraphiQL, direct `curl`
+- Maintain backward compatibility for the `cedar dev` CLI contract
+- Keep build output paths stable so `cedar serve` continues to work unchanged
+
+## Non-Goals
+
+- Adding new user-facing features (this is an internal architecture phase)
+- Changing the Cedar handler contract or middleware model
+- Rebuilding SSR or RSC (that is Phase 7)
+- Formalizing per-route UD registration (that is Phase 6)
+- Removing the custom Fastify compatibility lane
+- Supporting arbitrary Fastify plugins in the default runtime path
+
+## Workstreams
+
+## Workstream 1: Single-Listener Dev Server
+
+### Objective
+
+Move API request handling from a separate Fastify listener into the Vite dev
+server's middleware pipeline.
+
+### Current State
+
+`apiDevServer.ts` creates a Vite SSR dev server (`middlewareMode: true`) and
+mounts a Fastify app on a separate port. `cedarDevDispatcherPlugin` exists in
+`@cedarjs/vite` but is not installed in the dev server. The Fastify app handles:
+
+- body parsing (`fastify-raw-body`)
+- URL data extraction (`fastify-url-data`)
+- route matching to the `LAMBDA_FUNCTIONS` registry
+- GraphQL Yoga streaming via `createFetchRequestFromFastify`
+- content-type parsing for form data and multipart
+
+### Target State
+
+A single `createServer()` call in `cedar-unified-dev` that:
+
+- starts one Vite dev server on the visible port
+- installs `cedarDevDispatcherPlugin` (already built in Phase 4) into the
+ `configureServer` middleware pipeline
+- routes API requests to Cedar's aggregate fetch dispatcher directly
+- falls through to Vite's normal web handling for non-API requests
+
+### Tasks
+
+- install `cedarDevDispatcherPlugin` into the web Vite dev server's
+ `configureServer` hook (the plugin was built in Phase 4 but not wired up)
+- replace Fastify routing with fetch-native request classification and dispatch
+- implement body parsing as a utility function (or use a WHATWG-compatible
+ parser) rather than a Fastify plugin
+- mount GraphQL Yoga directly inside the middleware pipeline using its
+ `handle(request, context)` method, which already expects a Fetch `Request`
+- preserve the `LAMBDA_FUNCTIONS` registry and HMR invalidation logic — only
+ the HTTP transport layer changes
+- ensure request context enrichment (cookies, params, query, auth state) still
+ flows correctly without Fastify's `req`/`reply` objects
+- preserve error surfacing: backend errors should still be visible in both
+ terminal and HTTP response where appropriate
+
+### Blockers to Resolve
+
+- GraphQL Yoga's `handle()` method expects a Fetch `Request` and returns a
+ Fetch `Response`. This is already nearly the target shape, but the current
+ code wraps it in `getAsyncStoreInstance().run()` inside a Fastify handler.
+ That AsyncLocalStorage context needs to be established in the middleware
+ pipeline instead.
+- The `requestHandler` helper from `@cedarjs/api-server/requestHandlers` is
+ currently coupled to Fastify `req`/`reply` objects. It may need a thin
+ fetch-native wrapper, or the helper itself may need to be split into
+ transport-agnostic and Fastify-specific variants.
+
+### Deliverable
+
+- `cedar dev` runs on a single visible port with no separate API listener
+- API requests (GraphQL, auth, functions) are handled inline via Vite middleware
+- Web requests (assets, HMR, SPA fallback) continue through Vite's normal path
+
+## Workstream 2: `buildApp()` with Declared Environments
+
+### Objective
+
+Replace the three separate `viteBuild()` invocations with a single `buildApp()`
+call that declares `client` and `api` environments.
+
+### Current State
+
+Production build uses three standalone Vite builds:
+
+1. `buildApiWithVite()` — builds API functions with `ssr: true` and
+ `preserveModules: true`
+2. `buildUDApiServer()` — builds the UD Node server entry with the
+ `cedarUniversalDeployPlugin` and `node()` plugin
+3. `cedar-vite-build` binary — builds the web client bundle
+
+These share no module graph, no transform pipeline, and no invalidation.
+Alias resolution, Babel plugin ordering, and externalization logic can diverge
+silently.
+
+### Target State
+
+A unified Vite config that declares:
+
+```ts
+// Simplified illustration
+export default defineConfig({
+ environments: {
+ client: {
+ // web browser bundle (SPA assets)
+ build: {
+ outDir: 'web/dist',
+ // ...
+ },
+ },
+ api: {
+ // server-side API entry (Cedar aggregate fetchable)
+ build: {
+ ssr: true,
+ outDir: 'api/dist',
+ // ...
+ },
+ },
+ },
+})
+```
+
+A single `buildApp()` call builds both environments from the same module graph.
+
+### Tasks
+
+- evaluate Vite's `buildApp()` API stability and feature completeness for Cedar's
+ use case (check current Vite version support)
+- merge the three existing build configurations into one unified config with
+ declared environments
+- ensure `node()` from `@universal-deploy/node/vite` works correctly within the
+ `buildApp()` environment model
+- ensure the web client build's special requirements (cwd, PostCSS/Tailwind
+ resolution, etc.) are preserved in the unified config
+- verify that output paths remain stable so `cedar serve` does not need changes
+- add the `api` environment to the Vite config used by `cedar build`
+
+### Blockers to Resolve
+
+- `buildApp()` may not be fully stable or documented in the Vite version Cedar
+ pins. This needs investigation before committing to the migration.
+- The web client build currently runs via a separate binary
+ (`cedar-vite-build`) with its own config file. That binary changes `cwd`
+ to the web directory for PostCSS/Tailwind correctness. The unified build
+ needs to preserve that behavior or find an alternative.
+
+### Deliverable
+
+- `cedar build` uses a single `buildApp()` invocation for both client and api
+ environments
+- Output directories and artifacts remain compatible with `cedar serve`
+
+## Suggested Sequencing
+
+1. **Single-listener dev first** — this is the higher-impact change for daily
+ developer experience and should be validated before layering `buildApp()` on
+ top of it
+2. **`buildApp()` second** — the build consolidation is less user-visible and
+ can be done in parallel with single-listener testing, but it should not be
+ released before single-listener is stable
+
+## Relationship to Other Phases
+
+- **Phase 4**: this phase replaces the incremental bridge with the idiomatic
+ architecture. Phase 4 must be stable before starting this work.
+- **Phase 6 (UD per-route registration)**: single-listener makes per-route
+ dispatch simpler because there's only one request classification layer to
+ reason about. The dispatcher that routes to per-route entries is the same
+ middleware that currently routes to the aggregate entry.
+- **Phase 7 (SSR rebuild)**: `buildApp()` with declared environments is
+ prerequisite for SSR because SSR will add a third environment (`ssr` for
+ HTML streaming / RSC). The build infrastructure must already support
+ multiple environments.
+
+## Exit Criteria
+
+- `cedar dev` runs a single Vite dev server on one visible port for the default
+ runtime path
+- API requests are handled inline via Vite middleware, not by a separate
+ Fastify listener
+- `cedar build` uses `buildApp()` with declared `client` and `api`
+ environments in a single build pass
+- All existing Phase 4 functionality (HMR, GraphQL, auth, functions, GraphiQL,
+ direct `curl`, `cedar serve`) continues to work
+- The custom Fastify compatibility lane is unaffected
+
+## Risks
+
+- `buildApp()` API may not be mature enough in the pinned Vite version
+- Moving body parsing and request enrichment out of Fastify may surface edge
+ cases in auth providers or GraphQL Yoga plugins that currently depend on
+ Fastify-specific request shapes
+- Single-listener dev may complicate debugging if API and web errors are
+ interleaved in the same Vite server output
+- The web client build's `cwd` sensitivity (PostCSS/Tailwind) may resist
+ merging into a unified config
+
+## Deliverables
+
+- refactored `cedar-unified-dev` using single Vite dev server with inline API
+ middleware
+- refactored `cedar build` using `buildApp()` with `client` and `api`
+ environments
+- updated documentation reflecting the single-port dev model and unified build
diff --git a/packages/api-server/package.json b/packages/api-server/package.json
index 43a11a3b90..2930018b11 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",
@@ -130,6 +142,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",
@@ -140,7 +154,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",
"termi-link": "1.1.0",
"yargs": "17.7.2"
},
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..6d3a60da7c
--- /dev/null
+++ b/packages/api-server/src/udDispatcher.ts
@@ -0,0 +1,296 @@
+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[]
+ /**
+ * Cache-bust token appended to dynamic ESM imports. Use this in dev to
+ * bypass Node.js's ESM module cache after a rebuild. Production builds
+ * should omit this.
+ */
+ cacheBust?: string | number
+}
+
+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 used by
+// `cedarUniversalDeployPlugin` — the Phase 4 Vite plugin that registers
+// `virtual:cedar-api` in the API server Vite build.
+//
+// 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.
+/**
+ * Shared aggregate Cedar API dispatcher used by
+ * `cedarUniversalDeployPlugin` (via `virtual:cedar-api`).
+ *
+ * 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}`
+
+ // In dev we append a cache-bust query to bypass Node.js's ESM module
+ // cache so that rebuilt function files are re-evaluated on every
+ // invalidation. Production builds omit this and rely on normal caching.
+ const importUrl = options?.cacheBust
+ ? `${pathToFileURL(fnPath).href}?t=${options.cacheBust}`
+ : pathToFileURL(fnPath).href
+ const fnImport = await import(importUrl)
+
+ // 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 } = await 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/__tests__/serve.test.js b/packages/cli/src/commands/__tests__/serve.test.js
index 83aeda3d93..e4beb5b4e6 100644
--- a/packages/cli/src/commands/__tests__/serve.test.js
+++ b/packages/cli/src/commands/__tests__/serve.test.js
@@ -89,7 +89,9 @@ describe('yarn cedar serve', () => {
it('Should proxy serve api with params to api-server handler', async () => {
const parser = yargs().command('serve [side]', false, builder)
- await parser.parse('serve api --port 5555 --apiRootPath funkyFunctions')
+ await parser.parse(
+ 'serve api --port 5555 --apiRootPath funkyFunctions --no-ud',
+ )
expect(apiServerCLIConfig.handler).toHaveBeenCalledWith(
expect.objectContaining({
@@ -104,7 +106,9 @@ describe('yarn cedar serve', () => {
const parser = yargs().command('serve [side]', false, builder)
- await parser.parse('serve api --port 5555 --apiRootPath funkyFunctions')
+ await parser.parse(
+ 'serve api --port 5555 --apiRootPath funkyFunctions --no-ud',
+ )
expect(apiServerCLIConfigHandler.handler).toHaveBeenCalledWith(
expect.objectContaining({
@@ -118,7 +122,7 @@ describe('yarn cedar serve', () => {
const parser = yargs().command('serve [side]', false, builder)
await parser.parse(
- 'serve api --port 5555 --rootPath funkyFunctions/nested/',
+ 'serve api --port 5555 --rootPath funkyFunctions/nested/ --no-ud',
)
expect(apiServerCLIConfig.handler).toHaveBeenCalledWith(
@@ -135,7 +139,7 @@ describe('yarn cedar serve', () => {
const parser = yargs().command('serve [side]', false, builder)
await parser.parse(
- 'serve api --port 5555 --rootPath funkyFunctions/nested/',
+ 'serve api --port 5555 --rootPath funkyFunctions/nested/ --no-ud',
)
expect(apiServerCLIConfigHandler.handler).toHaveBeenCalledWith(
diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts
index 831b6451dc..7ca690df9e 100644
--- a/packages/cli/src/commands/build.ts
+++ b/packages/cli/src/commands/build.ts
@@ -45,6 +45,12 @@ export const builder = (yargs: Argv) => {
default: true,
description: 'Generate the Prisma client',
})
+ .option('ud', {
+ type: 'boolean',
+ default: false,
+ description:
+ 'Build the Universal Deploy server entry (api/dist/ud/index.js).',
+ })
.middleware(() => {
const check = checkNodeVersion()
diff --git a/packages/cli/src/commands/build/__tests__/build.test.ts b/packages/cli/src/commands/build/__tests__/build.test.ts
index 0c03d59d8c..f93b3119ba 100644
--- a/packages/cli/src/commands/build/__tests__/build.test.ts
+++ b/packages/cli/src/commands/build/__tests__/build.test.ts
@@ -226,6 +226,24 @@ test('Should run prerender for web (packagesWorkspace disabled)', async () => {
)
})
+test('UD server entry task is included when --ud is passed', async () => {
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ await handler({ ud: true })
+
+ const firstCallArg = vi.mocked(Listr).mock.calls[0][0]
+ const tasks = Array.isArray(firstCallArg) ? firstCallArg : [firstCallArg]
+ expect(tasks.map((x: ListrTask) => x.title)).toMatchInlineSnapshot(`
+ [
+ "Generating Prisma Client...",
+ "Verifying graphql schema...",
+ "Building API...",
+ "Bundling API server entry (Universal Deploy)...",
+ "Building Web...",
+ ]
+ `)
+})
+
test('Generating gqlorm schema task is included when experimental.gqlorm.enabled is true', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
mockGetConfig.mockReturnValue({
diff --git a/packages/cli/src/commands/build/buildHandler.ts b/packages/cli/src/commands/build/buildHandler.ts
index 933dc21e62..3baa1ab4c6 100644
--- a/packages/cli/src/commands/build/buildHandler.ts
+++ b/packages/cli/src/commands/build/buildHandler.ts
@@ -23,6 +23,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
@@ -117,6 +118,7 @@ export interface BuildHandlerOptions {
verbose?: boolean
prisma?: boolean
prerender?: boolean
+ ud?: boolean
}
export const handler = async ({
@@ -124,6 +126,7 @@ export const handler = async ({
verbose = false,
prisma = true,
prerender = true,
+ ud = false,
}: BuildHandlerOptions) => {
recordTelemetryAttributes({
command: 'build',
@@ -236,6 +239,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 () => {
@@ -243,6 +251,13 @@ export const handler = async ({
await buildApiWithVite()
},
},
+ ud &&
+ 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.ts b/packages/cli/src/commands/dev.ts
index 114c17dcfb..b4d99e9daa 100644
--- a/packages/cli/src/commands/dev.ts
+++ b/packages/cli/src/commands/dev.ts
@@ -44,6 +44,13 @@ export const builder = (yargs: Argv) => {
'with no value it defaults to 1 prepended to the api port (e.g. api ' +
'port 8913 -> debug port 18913).',
})
+ .option('ud', {
+ type: 'boolean',
+ default: false,
+ description:
+ 'Use the unified Vite dev server that handles both web and API in a ' +
+ 'single process (experimental).',
+ })
.middleware(() => {
const check = checkNodeVersion()
diff --git a/packages/cli/src/commands/dev/__tests__/dev.test.ts b/packages/cli/src/commands/dev/__tests__/dev.test.ts
index cf56534be5..ab0d53522b 100644
--- a/packages/cli/src/commands/dev/__tests__/dev.test.ts
+++ b/packages/cli/src/commands/dev/__tests__/dev.test.ts
@@ -11,7 +11,9 @@ import type * as ProjectConfig from '@cedarjs/project-config'
import { generatePrismaClient } from '../../../lib/generatePrismaClient.js'
// @ts-expect-error - Types not available for JS files
import { getPaths } from '../../../lib/index.js'
+import { getFreePort } from '../../../lib/ports'
import '../../../lib/mockTelemetry.js'
+import { serverFileExists } from '../../../lib/project.js'
import { handler } from '../devHandler.js'
let mockCedarToml = ''
@@ -88,7 +90,7 @@ vi.mock('../../../lib/ports', () => {
// We're not actually going to use the port, so it's fine to just say it's
// free. It prevents the tests from failing if the ports are already in use
// (probably by some external `yarn cedar dev` process)
- getFreePort: (port: number) => port,
+ getFreePort: vi.fn((port: number) => port),
}
})
@@ -202,8 +204,8 @@ describe('yarn cedar dev', () => {
mockCedarToml = ''
})
- it('Should run unified dev server (both api and web) by default', async () => {
- await handler({ workspace: ['api', 'web'] })
+ it('Should run unified dev server when --ud is passed', async () => {
+ await handler({ workspace: ['api', 'web'], ud: true })
expect(generatePrismaClient).toHaveBeenCalledTimes(1)
@@ -222,9 +224,24 @@ describe('yarn cedar dev', () => {
expect(apiCommand).toBeUndefined()
})
- it('Should include the gen watcher alongside the unified dev server', async () => {
+ it('Should fall back to separate api+web servers by default (no --ud)', async () => {
await handler({ workspace: ['api', 'web'] })
+ expect(generatePrismaClient).toHaveBeenCalledTimes(1)
+
+ const { webCommand, apiCommand } = findSeparateCommands()
+ expect(webCommand?.command).toContain('cedar-vite-dev')
+ expect(apiCommand?.command).toContain('cedar-api-server-watch')
+
+ // No unified dev command should be present
+ const concurrentlyArgs = vi.mocked(concurrently).mock.lastCall![0]
+ const devCommand = find(concurrentlyArgs, { name: 'dev' })
+ expect(devCommand).toBeUndefined()
+ })
+
+ it('Should include the gen watcher alongside the unified dev server', async () => {
+ await handler({ workspace: ['api', 'web'], ud: true })
+
const concurrentlyArgs = vi.mocked(concurrently).mock.lastCall![0]
const genCommand = find(concurrentlyArgs, { name: 'gen' })
@@ -385,4 +402,19 @@ describe('yarn cedar dev', () => {
'--debug-port 11337',
)
})
+
+ it('Excludes the reserved api port when selecting the web port in the custom-server lane', async () => {
+ vi.mocked(serverFileExists).mockReturnValue(true)
+
+ await handler({ workspace: ['api', 'web'] })
+
+ // Custom server files manage their own API port, so Cedar does not check
+ // it — but the web port selection still excludes the configured API port.
+ expect(getFreePort).toHaveBeenCalledTimes(1)
+ expect(getFreePort).toHaveBeenNthCalledWith(1, 8910, [8911, 8911])
+
+ // The configured API port must still be forwarded in the command.
+ const { apiCommand } = findSeparateCommands()
+ expect(apiCommand?.command).toContain('--port 8911')
+ })
})
diff --git a/packages/cli/src/commands/dev/devHandler.ts b/packages/cli/src/commands/dev/devHandler.ts
index 2d3a8b722e..5ef16bae16 100644
--- a/packages/cli/src/commands/dev/devHandler.ts
+++ b/packages/cli/src/commands/dev/devHandler.ts
@@ -28,6 +28,7 @@ interface DevHandlerOptions {
forward?: string
generate?: boolean
apiDebugPort?: number
+ ud?: boolean
}
export const handler = async ({
@@ -35,6 +36,7 @@ export const handler = async ({
forward = '',
generate = true,
apiDebugPort,
+ ud = false,
}: DevHandlerOptions) => {
recordTelemetryAttributes({
command: 'dev',
@@ -46,34 +48,35 @@ 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 apiAvailablePort: number | undefined
+ let apiPortChangeNeeded = false
+
+ if (workspace.includes('api')) {
+ if (!serverFile) {
+ // Check api port availability. If there's a serverFile we don't know
+ // what port will end up being used — it's up to the author to decide.
+ apiAvailablePort = await getFreePort(apiPreferredPort)
+
+ if (apiAvailablePort === -1) {
+ exitWithError(undefined, {
+ message: `Could not determine a free port for the api server`,
+ })
+ }
+
+ apiPortChangeNeeded = apiAvailablePort !== apiPreferredPort
+ } else {
+ // Forward the configured port even though we don't verify it's free.
+ apiAvailablePort = apiPreferredPort
+ }
+ }
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) {
- apiAvailablePort = await getFreePort(apiPreferredPort)
-
- if (apiAvailablePort === -1) {
- exitWithError(undefined, {
- message: `Could not determine a free port for the api server`,
- })
- }
-
- apiPortChangeNeeded = apiAvailablePort !== apiPreferredPort
- }
-
// Check web port
if (workspace.includes('web')) {
// Extract any ports the user forwarded to the dev server and prefer that
@@ -87,10 +90,12 @@ export const handler = async ({
webPreferredPort = port ? parseInt(port, 10) : undefined
}
- webAvailablePort = await getFreePort(webPreferredPort, [
- apiPreferredPort,
- apiAvailablePort,
- ])
+ webAvailablePort = await getFreePort(
+ webPreferredPort,
+ apiAvailablePort !== undefined
+ ? [apiPreferredPort, apiAvailablePort]
+ : [apiPreferredPort],
+ )
if (webAvailablePort === -1) {
exitWithError(undefined, {
@@ -102,19 +107,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 +132,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) {
@@ -198,6 +186,10 @@ export const handler = async ({
//
// When only web is included, fall back to the standalone Vite dev server.
const buildUnifiedDevCommand = () => {
+ if (!ud) {
+ return null
+ }
+
if (streamingSsrEnabled) {
// Streaming SSR has its own dev server setup
return null
@@ -234,6 +226,26 @@ export const handler = async ({
const unifiedDevCommand = buildUnifiedDevCommand()
+ // In fallback (non-unified) mode the web Vite dev server proxy targets the
+ // configured API port. If the API silently binds to a different free port,
+ // all proxied API requests will fail with no diagnostic output.
+ if (!unifiedDevCommand && apiPortChangeNeeded) {
+ const message = [
+ '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',
+ ` - API to use port ${apiAvailablePort} instead`,
+ 'of your currently configured',
+ `${apiPreferredPort}\n`,
+ '\nCannot run the development server until your configured port is',
+ 'changed or becomes available.',
+ ]
+ .filter(Boolean)
+ .join(' ')
+
+ exitWithError(undefined, { message })
+ }
+
const jobs: (Partial & {
name: string
command: string
@@ -242,10 +254,11 @@ export const handler = async ({
if (unifiedDevCommand) {
// Unified dev mode: one node process handles both web (Vite client) and API
- // (Vite SSR + Fastify) with true HMR – no nodemon, no separate watcher.
+ // (Vite SSR + Fastify in-process) with true HMR – no nodemon, no separate watcher.
jobs.push({
name: 'dev',
command: unifiedDevCommand,
+
env: {
NODE_ENV: 'development',
NODE_OPTIONS: getDevNodeOptions(),
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index 7c9f406e4d..d097d66454 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,19 @@ 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 opt-in. Pass --ud to use the new srvx server instead
+ // of the legacy Fastify server.
+ description:
+ 'Use the Universal Deploy server (srvx). Pass --ud to opt in; the default is Fastify.',
+ type: 'boolean',
+ default: false,
+ })
+ },
handler: async (argv: ServeArgv) => {
recordTelemetryAttributes({
command: 'serve',
@@ -79,6 +93,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 23c92c822e..5ceeee0a9a 100644
--- a/packages/vite/package.json
+++ b/packages/vite/package.json
@@ -39,6 +39,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"
@@ -85,6 +88,7 @@
"@cedarjs/web": "workspace:*",
"@fastify/url-data": "6.0.3",
"@swc/core": "1.15.30",
+ "@universal-deploy/node": "^0.1.6",
"@vitejs/plugin-react": "4.7.0",
"@whatwg-node/fetch": "0.10.13",
"@whatwg-node/server": "0.10.18",
diff --git a/packages/vite/src/buildUDApiServer.ts b/packages/vite/src/buildUDApiServer.ts
new file mode 100644
index 0000000000..0bbf022c54
--- /dev/null
+++ b/packages/vite/src/buildUDApiServer.ts
@@ -0,0 +1,92 @@
+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: {
+ // 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..70e4b734b0 100644
--- a/packages/vite/src/index.ts
+++ b/packages/vite/src/index.ts
@@ -32,6 +32,9 @@ 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'
+export { cedarWaitForApiServer } from './plugins/vite-plugin-cedar-wait-for-api-server.js'
type PluginOptions = {
mode?: string | undefined
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..657ca5041a
--- /dev/null
+++ b/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts
@@ -0,0 +1,296 @@
+/**
+ * NOTE: This plugin is fully implemented but not yet wired into the dev server.
+ * Phase 4's unified dev server (`cedar-unified-dev`) still uses the two-port
+ * model (web Vite dev server + separate Fastify API listener via
+ * `startApiDevServer`). This plugin will be installed into the web Vite dev
+ * server's middleware stack in Phase 5 to achieve true single-port dev, where
+ * API requests are dispatched inline without a separate listener.
+ */
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import net from 'node:net'
+import path from 'node:path'
+
+import type { Plugin, ViteDevServer } from 'vite'
+
+import { getConfig, getPaths } 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) {
+ await buildPromise
+ // After awaiting, cachedDispatcher may have been populated by a newer
+ // build that started after we began waiting. If not, we were invalidated
+ // and need to trigger a fresh build.
+ return cachedDispatcher ?? getDispatcher()
+ }
+
+ // 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')
+ // Pass a cache-bust token so that rebuilt API functions are re-imported
+ // rather than served from Node.js's ESM module cache.
+ const { fetchable } = await buildCedarDispatcher({ cacheBust: Date.now() })
+
+ // 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
+ }
+
+ return fetchable
+ })()
+
+ try {
+ await buildPromise
+ } finally {
+ // Only clear buildPromise if no invalidate happened during our build.
+ // If invalidate DID happen, buildPromise is already null (set by
+ // invalidateDispatcher), and a new build may already be in flight.
+ if (generationAtStart === dispatcherGeneration) {
+ buildPromise = null
+ }
+ }
+
+ if (cachedDispatcher !== null) {
+ return cachedDispatcher
+ }
+
+ // We were invalidated during build. Recurse to get the fresh dispatcher.
+ return getDispatcher()
+}
+
+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=')
+ )
+}
+
+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()
+ }
+}
+
+let apiServerIsUp: boolean | undefined
+
+async function checkApiPort(host: string, port: number): Promise {
+ return new Promise((resolve) => {
+ const socket = new net.Socket()
+ socket.setTimeout(100)
+
+ socket.on('connect', () => {
+ socket.destroy()
+ resolve(true)
+ })
+
+ socket.on('timeout', () => {
+ socket.destroy()
+ resolve(false)
+ })
+
+ socket.on('error', () => {
+ socket.destroy()
+ resolve(false)
+ })
+
+ socket.connect(port, host)
+ })
+}
+
+export function cedarDevDispatcherPlugin(): Plugin {
+ return {
+ name: 'cedar-dev-dispatcher',
+ apply: 'serve',
+
+ configureServer(server: ViteDevServer) {
+ // Cache config once at server init instead of per-request.
+ const cedarConfig = getConfig()
+ const apiUrl = cedarConfig.web.apiUrl.replace(/\/$/, '')
+ const apiGqlUrl = cedarConfig.web.apiGraphQLUrl ?? apiUrl + '/graphql'
+ const apiPort = cedarConfig.api.port
+ const apiHost = cedarConfig.api.host || '127.0.0.1'
+
+ function isApiRequest(url: string): boolean {
+ return (
+ url === apiUrl ||
+ url.startsWith(apiUrl + '/') ||
+ url.startsWith(apiUrl + '?') ||
+ url === apiGqlUrl ||
+ url.startsWith(apiGqlUrl + '/') ||
+ url.startsWith(apiGqlUrl + '?')
+ )
+ }
+
+ server.watcher.on('change', (filePath: string) => {
+ if (filePath.startsWith(getPaths().api.src + path.sep)) {
+ 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()
+ }
+
+ // If a separate API dev server is already running (e.g. in unified
+ // dev mode or when api and web are started as separate processes),
+ // let Vite's proxy handle the request instead of intercepting it
+ // ourselves. This avoids double-compilation and ensures legacy
+ // Lambda-shaped handlers are still served.
+
+ if (apiServerIsUp === undefined) {
+ apiServerIsUp = await checkApiPort(apiHost, apiPort)
+ }
+
+ if (apiServerIsUp) {
+ 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 f82eb991d1..f6febc220a 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"
termi-link: "npm:1.1.0"
tsx: "npm:4.21.0"
typescript: "npm:5.9.3"
@@ -3777,6 +3781,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"
@@ -11598,6 +11603,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"
@@ -26970,6 +27006,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"
@@ -27815,6 +27858,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"