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"