From e1c5192087c32633f6af4b773f1b9e1a9f01d557 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 22 Apr 2026 15:25:09 +0200 Subject: [PATCH 01/14] feat(ud): Phase 2 --- ...iversal-deploy-integration-plan-refined.md | 22 +- packages/api-server/dist.test.ts | 3 +- packages/api-server/package.json | 22 ++ .../src/__tests__/udFetchable.test.ts | 110 ++++++++ packages/api-server/src/createUDServer.ts | 244 ++++++++++++++++++ packages/api-server/src/udBin.ts | 39 +++ packages/api-server/src/udCLIConfig.ts | 54 ++++ packages/api-server/src/udFetchable.ts | 21 ++ packages/graphql-server/src/types.ts | 1 - packages/vite/package.json | 1 + packages/vite/src/index.ts | 1 + .../vite-plugin-cedar-universal-deploy.ts | 66 +++++ yarn.lock | 36 +++ 13 files changed, 607 insertions(+), 13 deletions(-) create mode 100644 packages/api-server/src/__tests__/udFetchable.test.ts create mode 100644 packages/api-server/src/createUDServer.ts create mode 100644 packages/api-server/src/udBin.ts create mode 100644 packages/api-server/src/udCLIConfig.ts create mode 100644 packages/api-server/src/udFetchable.ts create mode 100644 packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts diff --git a/docs/implementation-plans/universal-deploy-integration-plan-refined.md b/docs/implementation-plans/universal-deploy-integration-plan-refined.md index 3568b7915f..02b286c625 100644 --- a/docs/implementation-plans/universal-deploy-integration-plan-refined.md +++ b/docs/implementation-plans/universal-deploy-integration-plan-refined.md @@ -21,7 +21,7 @@ duplicated. Cedar uses two distinct handler shapes: `handle(request, ctx)` as the authoring surface for app developers, and `export default { fetch }` as -the WinterCG-compatible deployment artifact that Cedar's build tooling +the WinterTC-compatible deployment artifact that Cedar's build tooling emits. These are intentionally different — see Guiding Principle 6 for details. @@ -150,7 +150,7 @@ layers: developers and middleware authors. The `ctx` parameter carries Cedar-specific enrichments no platform provides natively. - **Deployment artifact** — `export default { fetch(request) }`, the - WinterCG-compatible shape Cedar's build tooling emits for provider + WinterTC-compatible shape Cedar's build tooling emits for provider consumption. The transformation between these layers is Cedar's responsibility. App @@ -240,7 +240,7 @@ export async function handle( ``` **The deployment artifact** is what Cedar's build tooling emits for -WinterCG-compatible runtimes — app developers never write this directly: +WinterTC-compatible runtimes — app developers never write this directly: ```ts // Generated by Cedar's build tooling @@ -256,10 +256,10 @@ These are intentionally different because: - `CedarRequestContext` carries enrichments (parsed cookies, route params, auth state) that no platform provides natively and that do - not belong on a standard WinterCG `Request` object. -- The deployment artifact conforms to the WinterCG standard so Cedar + not belong on a standard WinterTC `Request` object. +- The deployment artifact conforms to the WinterTC standard so Cedar outputs are consumable by Cloudflare Workers, Deno Deploy, Bun, - Netlify Edge Functions, and any other WinterCG-compliant runtime. + Netlify Edge Functions, and any other WinterTC-compliant runtime. - The transformation between layers is Cedar's responsibility, not the app developer's. Cedar generates the right artifact for the target platform. @@ -337,7 +337,7 @@ of responsibility between Cedar and Universal Deploy. **Cedar's responsibility:** -1. Emit WinterCG-compatible deployment artifacts — modules that export +1. Emit WinterTC-compatible deployment artifacts — modules that export a `Fetchable` object matching UD's interface: ```ts // Generated by Cedar's build tooling @@ -366,7 +366,7 @@ UD provides adapters that read from its store and handle all deployment-target-specific wiring: - `@universal-deploy/adapter-node` — wraps store entries with `srvx` - (a WinterCG-compatible Node.js HTTP server) and `sirv` for static + (a WinterTC-compatible Node.js HTTP server) and `sirv` for static assets. Handles baremetal and VPS self-hosting. - `@universal-deploy/adapter-netlify` — wires Cedar's entries into Netlify's deployment pipeline via `@netlify/vite-plugin`. @@ -411,7 +411,7 @@ is UD's domain. ### Production -- Cedar emits WinterCG-compatible `Fetchable` entries and registers +- Cedar emits WinterTC-compatible `Fetchable` entries and registers them with `@universal-deploy/store` - UD's adapters consume the store entries and produce deployment-target-specific artifacts @@ -689,7 +689,7 @@ Can proceed **in parallel with Phase 2** after Phase 1. #### Goal Replace Fastify as Cedar's production runtime by emitting -WinterCG-compatible `Fetchable` entries and wiring them into UD's +WinterTC-compatible `Fetchable` entries and wiring them into UD's adapter ecosystem. Cedar builds no adapters of its own. #### Work @@ -1075,7 +1075,7 @@ what kind of thing a handler is) is unaffected by this choice. **Why two shapes and not one**: Framework developers and deployment providers strongly prefer `export default { async fetch(request) }` as the deployment artifact. Cedar agrees — and that is exactly what Cedar's -build tooling should emit for WinterCG-compatible targets. But app +build tooling should emit for WinterTC-compatible targets. But app developers need `handle(request, ctx)` because `ctx` carries Cedar-specific enrichments (parsed cookies, route params, auth state) that no platform provides natively. Making app developers write diff --git a/packages/api-server/dist.test.ts b/packages/api-server/dist.test.ts index 4916639137..45aa7ec2c5 100644 --- a/packages/api-server/dist.test.ts +++ b/packages/api-server/dist.test.ts @@ -11,9 +11,10 @@ describe('dist', () => { expect(fs.existsSync(path.join(distPath, '__tests__'))).toEqual(false) }) - it('ships three bins', () => { + it('ships four bins', () => { expect(packageConfig.bin).toMatchInlineSnapshot(` { + "cedar-ud-server": "./dist/udBin.js", "cedarjs-api-server-watch": "./dist/watch.js", "cedarjs-log-formatter": "./dist/logFormatter/bin.js", "cedarjs-server": "./dist/bin.js", diff --git a/packages/api-server/package.json b/packages/api-server/package.json index 7ff79892c4..5f54468a8c 100644 --- a/packages/api-server/package.json +++ b/packages/api-server/package.json @@ -73,6 +73,24 @@ "types": "./dist/cjs/bothCLIConfigHandler.d.ts", "default": "./dist/cjs/bothCLIConfigHandler.js" }, + "./udServer": { + "import": { + "types": "./dist/createUDServer.d.ts", + "default": "./dist/createUDServer.js" + } + }, + "./udCLIConfig": { + "import": { + "types": "./dist/udCLIConfig.d.ts", + "default": "./dist/udCLIConfig.js" + } + }, + "./udFetchable": { + "import": { + "types": "./dist/udFetchable.d.ts", + "default": "./dist/udFetchable.js" + } + }, "./watch": { "import": { "types": "./dist/watch.d.ts", @@ -87,6 +105,7 @@ "main": "./dist/createServer.js", "types": "./dist/createServer.d.ts", "bin": { + "cedar-ud-server": "./dist/udBin.js", "cedarjs-api-server-watch": "./dist/watch.js", "cedarjs-log-formatter": "./dist/logFormatter/bin.js", "cedarjs-server": "./dist/bin.js", @@ -118,6 +137,7 @@ "@cedarjs/web-server": "workspace:*", "@fastify/multipart": "9.4.0", "@fastify/url-data": "6.0.3", + "@universal-deploy/store": "^0.2.1", "ansis": "4.2.0", "chokidar": "3.6.0", "dotenv-defaults": "5.0.2", @@ -128,7 +148,9 @@ "picoquery": "2.5.0", "pretty-bytes": "5.6.0", "pretty-ms": "7.0.1", + "rou3": "^0.8.1", "split2": "4.2.0", + "srvx": "^0.11.9", "yargs": "17.7.2" }, "devDependencies": { diff --git a/packages/api-server/src/__tests__/udFetchable.test.ts b/packages/api-server/src/__tests__/udFetchable.test.ts new file mode 100644 index 0000000000..0641bf8c67 --- /dev/null +++ b/packages/api-server/src/__tests__/udFetchable.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { CedarHandler, CedarRequestContext } from '@cedarjs/api/runtime' +import { buildCedarContext } from '@cedarjs/api/runtime' + +import { createCedarFetchable } from '../udFetchable.js' + +vi.mock('@cedarjs/api/runtime', async (importOriginal) => { + const actual = (await importOriginal()) as any + return { + ...actual, + buildCedarContext: vi.fn().mockImplementation(actual.buildCedarContext), + } +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('createCedarFetchable', () => { + describe('wraps a CedarHandler', () => { + it('calls buildCedarContext and the handler, and returns the handler Response', async () => { + let capturedCtx: CedarRequestContext | undefined + + const handler: CedarHandler = async (_req, ctx) => { + capturedCtx = ctx + return new Response('ok', { status: 200 }) + } + + const fetchable = createCedarFetchable(handler) + const request = new Request('http://localhost/test') + + const response = await fetchable.fetch(request) + + expect(buildCedarContext).toHaveBeenCalledWith(request) + expect(capturedCtx).toBeDefined() + expect(response.status).toBe(200) + }) + + it('returns the Response from the handler', async () => { + const handler: CedarHandler = async () => { + return new Response('hello world', { + status: 201, + headers: { 'x-custom': 'value' }, + }) + } + + const fetchable = createCedarFetchable(handler) + const response = await fetchable.fetch( + new Request('http://localhost/test'), + ) + + expect(response.status).toBe(201) + expect(response.headers.get('x-custom')).toBe('value') + expect(await response.text()).toBe('hello world') + }) + }) + + describe('passes the correct context to the handler', () => { + it('passes query params from the URL', async () => { + let capturedCtx: CedarRequestContext | undefined + + const handler: CedarHandler = async (_req, ctx) => { + capturedCtx = ctx + return new Response('ok') + } + + const fetchable = createCedarFetchable(handler) + await fetchable.fetch( + new Request('http://localhost/test?name=cedar&version=1'), + ) + + expect(capturedCtx?.query.get('name')).toBe('cedar') + expect(capturedCtx?.query.get('version')).toBe('1') + }) + + it('passes cookies from request headers', async () => { + let capturedCtx: CedarRequestContext | undefined + + const handler: CedarHandler = async (_req, ctx) => { + capturedCtx = ctx + return new Response('ok') + } + + const fetchable = createCedarFetchable(handler) + await fetchable.fetch( + new Request('http://localhost/test', { + headers: { cookie: 'session=abc123; theme=dark' }, + }), + ) + + expect(capturedCtx?.cookies.get('session')).toBe('abc123') + expect(capturedCtx?.cookies.get('theme')).toBe('dark') + }) + + it('has empty params by default (no route params injected)', async () => { + let capturedCtx: CedarRequestContext | undefined + + const handler: CedarHandler = async (_req, ctx) => { + capturedCtx = ctx + return new Response('ok') + } + + const fetchable = createCedarFetchable(handler) + await fetchable.fetch(new Request('http://localhost/test')) + + expect(capturedCtx?.params).toEqual({}) + }) + }) +}) diff --git a/packages/api-server/src/createUDServer.ts b/packages/api-server/src/createUDServer.ts new file mode 100644 index 0000000000..d461800220 --- /dev/null +++ b/packages/api-server/src/createUDServer.ts @@ -0,0 +1,244 @@ +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { addEntry } from '@universal-deploy/store' +import fg from 'fast-glob' +import { addRoute, createRouter, findRoute } from 'rou3' +import { serve } from 'srvx' +import type { Server } from 'srvx' + +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 type { GraphQLYogaOptions } from '@cedarjs/graphql-server' +import { getPaths } from '@cedarjs/project-config' + +import type { Fetchable } from './udFetchable.js' +import { createCedarFetchable } from './udFetchable.js' + +export interface CreateUDServerOptions { + port?: number + host?: string + apiRootPath?: string + discoverFunctionsGlob?: string | string[] +} + +/** + * 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 +} + +/** + * Creates a WinterTC-compatible HTTP server using srvx that serves Cedar API + * functions discovered in `api/dist/functions/`. + * + * Each function is wrapped in a Fetchable and registered with the + * `@universal-deploy/store` via `addEntry()`. The srvx fetch handler routes + * incoming requests to the correct Fetchable using rou3 for URL pattern + * matching. + */ +export async function createUDServer( + options?: CreateUDServerOptions, +): Promise { + const port = options?.port ?? 8911 + const host = options?.host + const normalizedApiRootPath = normalizeApiRootPath( + options?.apiRootPath ?? '/', + ) + const discoverFunctionsGlob = + options?.discoverFunctionsGlob ?? 'dist/functions/**/*.{ts,js}' + + // Discover function files in api/dist/functions/ + 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) === 'graphql.js', + ) + + 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() + + for (const fnPath of serverFunctions) { + const routeName = path.basename(fnPath).replace('.js', '') + const routePath = routeName === 'graphql' ? '/graphql' : `/${routeName}` + + const fnImport = await import(pathToFileURL(fnPath).href) + + // Check if this is a GraphQL function — the babel plugin adds + // `__rw_graphqlOptions` to api/dist/functions/graphql.js + if ( + '__rw_graphqlOptions' in fnImport && + fnImport.__rw_graphqlOptions != null + ) { + const { createGraphQLYoga } = await import('@cedarjs/graphql-server') + const graphqlOptions = fnImport.__rw_graphqlOptions as GraphQLYogaOptions + + const { yoga } = createGraphQLYoga(graphqlOptions) + + const graphqlFetchable: Fetchable = { + async fetch(request: Request): Promise { + const cedarContext = await buildCedarContext(request, { + authDecoder: graphqlOptions.authDecoder, + }) + const event = await requestToLegacyEvent(request, cedarContext) + + // Phase 1 transitional context bridge: pass both Fetch-native fields + // (request, cedarContext) and legacy bridge fields (event, + // requestContext) so that Cedar-owned Yoga plugins that have not yet + // migrated to the Fetch-native shape continue to work. + return yoga.handle(request, { + request, + cedarContext, + event, + requestContext: undefined, + }) + }, + } + + fetchableMap.set(routeName, graphqlFetchable) + + const graphqlMethods = ['GET', 'POST', 'OPTIONS'] as const + + addEntry({ + id: routePath, + route: routePath, + method: [...graphqlMethods], + }) + + for (const method of graphqlMethods) { + 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 rw serve` for legacy Lambda-shaped handler support.', + ) + continue + } + + const handler = cedarHandler + + fetchableMap.set(routeName, createCedarFetchable(handler)) + + const regularMethods = ['GET', 'POST'] as const + + addEntry({ + id: routePath, + route: routePath, + method: [...regularMethods], + }) + + for (const method of regularMethods) { + addRoute(router, method, routePath, routeName) + addRoute(router, method, `${routePath}/**`, routeName) + } + } + + const server = serve({ + port, + hostname: host, + 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 fetchable = fetchableMap.get(matchedRouteName) + + if (!fetchable) { + return new Response('Not Found', { status: 404 }) + } + + return fetchable.fetch(request) + }, + ) + }, + }) + + await server.ready() + + return server +} diff --git a/packages/api-server/src/udBin.ts b/packages/api-server/src/udBin.ts new file mode 100644 index 0000000000..8f48687d46 --- /dev/null +++ b/packages/api-server/src/udBin.ts @@ -0,0 +1,39 @@ +import path from 'node:path' + +import { config } from 'dotenv-defaults' +import { hideBin } from 'yargs/helpers' +import yargs from 'yargs/yargs' + +import { getPaths } from '@cedarjs/project-config' + +import { + description as udDescription, + builder as udBuilder, + handler as udHandler, +} from './udCLIConfig.js' + +if (!process.env.CEDAR_ENV_FILES_LOADED) { + config({ + path: path.join(getPaths().base, '.env'), + defaults: path.join(getPaths().base, '.env.defaults'), + multiline: true, + }) + + process.env.CEDAR_ENV_FILES_LOADED = 'true' +} + +process.env.NODE_ENV ??= 'production' + +yargs(hideBin(process.argv)) + .scriptName('cedar-ud-server') + .strict() + .alias('h', 'help') + .alias('v', 'version') + .command( + '$0', + udDescription, + // @ts-expect-error The yargs types seem wrong; it's ok for builder to be a function + udBuilder, + udHandler, + ) + .parse() diff --git a/packages/api-server/src/udCLIConfig.ts b/packages/api-server/src/udCLIConfig.ts new file mode 100644 index 0000000000..d924a19550 --- /dev/null +++ b/packages/api-server/src/udCLIConfig.ts @@ -0,0 +1,54 @@ +import type { Argv } from 'yargs' + +type UDParsedOptions = { + port?: number + host?: string + apiRootPath?: string +} + +export const description = + 'Start a Universal Deploy server for serving the Cedar API' + +export function builder(yargs: Argv) { + yargs.options({ + port: { + description: 'The port to listen at', + type: 'number', + alias: 'p', + default: 8911, + }, + host: { + description: + 'The host to listen at. Note that you most likely want this to be ' + + "'0.0.0.0' in production", + type: 'string', + }, + apiRootPath: { + description: 'Root path where your api functions are served', + type: 'string', + alias: ['api-root-path', 'rootPath', 'root-path'], + default: '/', + }, + }) +} + +export async function handler(options: UDParsedOptions) { + const timeStart = Date.now() + + // See https://github.com/webdiscus/ansis#troubleshooting + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { default: ansis } = await import('ansis') + + console.log(ansis.dim.italic('Starting Universal Deploy Server...')) + + const { createUDServer } = await import('./createUDServer.js') + + await createUDServer({ + port: options.port, + host: options.host, + apiRootPath: options.apiRootPath, + }) + + console.log(ansis.dim.italic('Took ' + (Date.now() - timeStart) + ' ms')) +} 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/graphql-server/src/types.ts b/packages/graphql-server/src/types.ts index cee7d09075..76cf90b81e 100644 --- a/packages/graphql-server/src/types.ts +++ b/packages/graphql-server/src/types.ts @@ -79,7 +79,6 @@ export interface CedarGraphQLContext { event?: APIGatewayProxyEvent requestContext?: LambdaContext | undefined currentUser?: ThenArg> | AuthContextPayload | null - request?: Request [index: string]: unknown } diff --git a/packages/vite/package.json b/packages/vite/package.json index e370c2786b..ae4863d9d2 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -73,6 +73,7 @@ "@cedarjs/testing": "workspace:*", "@cedarjs/web": "workspace:*", "@swc/core": "1.15.24", + "@universal-deploy/store": "^0.2.1", "@vitejs/plugin-react": "4.7.0", "@whatwg-node/fetch": "0.10.13", "@whatwg-node/server": "0.10.18", diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index 243287b4c9..a0f31463a3 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -32,6 +32,7 @@ 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' type PluginOptions = { mode?: string | undefined 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..0623ab3c99 --- /dev/null +++ b/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts @@ -0,0 +1,66 @@ +import { addEntry } from '@universal-deploy/store' +import type { Plugin } from 'vite' + +// The virtual module ID for the Cedar API Universal Deploy entry. +const CEDAR_API_VIRTUAL_ENTRY_ID = 'virtual:cedar-api' + +/** + * Cedar Vite plugin for Universal Deploy integration (Phase 3 / Phase 5). + * + * Registers Cedar's API endpoint as a Universal Deploy server entry so that + * UD-aware adapters can discover and bundle it. In Phase 5 this plugin will be + * expanded to register individual route entries derived from Cedar's route + * manifest. + * + * @see docs/implementation-plans/universal-deploy-integration-plan-refined.md + */ +export function cedarUniversalDeployPlugin(): Plugin { + let entriesRegistered = false + + return { + name: 'cedar:universal-deploy', + config: { + order: 'pre', + handler() { + if (entriesRegistered) { + return + } + + entriesRegistered = true + + addEntry({ + id: CEDAR_API_VIRTUAL_ENTRY_ID, + route: ['/api/**', '/graphql', '/graphql/**'], + method: ['GET', 'POST', 'OPTIONS', 'PUT', 'DELETE', 'PATCH'], + }) + }, + }, + resolveId(id) { + if (id === CEDAR_API_VIRTUAL_ENTRY_ID) { + return id + } + + return undefined + }, + load(id) { + if (id === CEDAR_API_VIRTUAL_ENTRY_ID) { + // Phase 3 stub: returns a Fetchable that responds with 501 Not + // Implemented. In Phase 5 this will be replaced with a proper Cedar + // API dispatcher derived from the Cedar route manifest and Cedar server + // entries. + return ` +export default { + async fetch(_request) { + return new Response( + 'Cedar API virtual entry: not yet implemented (Phase 5)', + { status: 501 }, + ) + }, +} +` + } + + return undefined + }, + } +} diff --git a/yarn.lock b/yarn.lock index 2813d536fb..8665cede2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2171,6 +2171,7 @@ __metadata: "@types/dotenv-defaults": "npm:^5.0.0" "@types/split2": "npm:4.2.3" "@types/yargs": "npm:17.0.35" + "@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 +2185,9 @@ __metadata: pino-abstract-transport: "npm:1.2.0" pretty-bytes: "npm:5.6.0" pretty-ms: "npm:7.0.1" + rou3: "npm:^0.8.1" split2: "npm:4.2.0" + srvx: "npm:^0.11.9" tsx: "npm:4.21.0" typescript: "npm:5.9.3" vitest: "npm:3.2.4" @@ -2201,6 +2204,7 @@ __metadata: rw-api-server-watch: ./dist/cjs/watch.js rw-log-formatter: ./dist/cjs/logFormatter/bin.js rw-server: ./dist/cjs/bin.js + rw-ud-server: ./dist/udBin.js languageName: unknown linkType: soft @@ -3762,6 +3766,7 @@ __metadata: "@types/react": "npm:^18.2.55" "@types/ws": "npm:^8" "@types/yargs-parser": "npm:21.0.3" + "@universal-deploy/store": "npm:^0.2.1" "@vitejs/plugin-react": "npm:4.7.0" "@whatwg-node/fetch": "npm:0.10.13" "@whatwg-node/server": "npm:0.10.18" @@ -11520,6 +11525,21 @@ __metadata: languageName: node linkType: hard +"@universal-deploy/store@npm:^0.2.1": + version: 0.2.1 + resolution: "@universal-deploy/store@npm:0.2.1" + dependencies: + rou3: "npm:^0.8.1" + srvx: "npm:*" + peerDependencies: + srvx: "*" + peerDependenciesMeta: + srvx: + optional: true + checksum: 10c0/8079a2d41d17b5b9a8d3dc5859ca18875d989fb695d592bee7a8dff13dfdf34af682384c4cc577ed10b2fbba9686953bdcb6ce4fb528548f10c5a8f9191ed8fe + languageName: node + linkType: hard + "@vitejs/plugin-react@npm:4.7.0": version: 4.7.0 resolution: "@vitejs/plugin-react@npm:4.7.0" @@ -26809,6 +26829,13 @@ __metadata: languageName: unknown linkType: soft +"rou3@npm:^0.8.1": + version: 0.8.1 + resolution: "rou3@npm:0.8.1" + checksum: 10c0/c8728cf3c41833db0e20cbadba07b3c678b8b9fb12db1d8803f275a7a6cce02d0be9bee79367575883f65659c9c0ed1001e6527146ed27772e439e5d6c68d264 + languageName: node + linkType: hard + "run-applescript@npm:^7.0.0": version: 7.1.0 resolution: "run-applescript@npm:7.1.0" @@ -27654,6 +27681,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" From 8fc043cdeb6ff3c8d50fe19bca39f0a87a7243ad Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 22 Apr 2026 17:38:18 +0200 Subject: [PATCH 02/14] review fixes --- packages/api-server/src/createUDServer.ts | 17 +++++++++++++---- yarn.lock | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/api-server/src/createUDServer.ts b/packages/api-server/src/createUDServer.ts index d461800220..e96540c396 100644 --- a/packages/api-server/src/createUDServer.ts +++ b/packages/api-server/src/createUDServer.ts @@ -71,7 +71,7 @@ export async function createUDServer( // Put the graphql function first for consistent load ordering const graphqlIdx = serverFunctions.findIndex( - (x) => path.basename(x) === 'graphql.js', + (x) => path.basename(x, path.extname(x)) === 'graphql', ) if (graphqlIdx >= 0) { @@ -86,7 +86,7 @@ export async function createUDServer( const router = createRouter() for (const fnPath of serverFunctions) { - const routeName = path.basename(fnPath).replace('.js', '') + const routeName = path.basename(fnPath, path.extname(fnPath)) const routePath = routeName === 'graphql' ? '/graphql' : `/${routeName}` const fnImport = await import(pathToFileURL(fnPath).href) @@ -170,7 +170,7 @@ export async function createUDServer( '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 rw serve` for legacy Lambda-shaped handler support.', + ' `yarn cedar serve` for legacy Lambda-shaped handler support.', ) continue } @@ -232,7 +232,16 @@ export async function createUDServer( return new Response('Not Found', { status: 404 }) } - return fetchable.fetch(request) + try { + return await fetchable.fetch(request) + } catch (err) { + console.error( + 'Unhandled error in fetch handler for route', + matchedRouteName, + err, + ) + return new Response('Internal Server Error', { status: 500 }) + } }, ) }, diff --git a/yarn.lock b/yarn.lock index 8665cede2a..94c38daae3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2198,13 +2198,13 @@ __metadata: "@cedarjs/graphql-server": optional: true bin: + cedar-ud-server: ./dist/udBin.js cedarjs-api-server-watch: ./dist/watch.js cedarjs-log-formatter: ./dist/logFormatter/bin.js cedarjs-server: ./dist/bin.js rw-api-server-watch: ./dist/cjs/watch.js rw-log-formatter: ./dist/cjs/logFormatter/bin.js rw-server: ./dist/cjs/bin.js - rw-ud-server: ./dist/udBin.js languageName: unknown linkType: soft From 9eb97aee5b3f2b02e3da5ad4a571a8b6a498f988 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 22 Apr 2026 17:56:22 +0200 Subject: [PATCH 03/14] fix build --- packages/api-server/src/createUDServer.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/api-server/src/createUDServer.ts b/packages/api-server/src/createUDServer.ts index e96540c396..66165c1fb9 100644 --- a/packages/api-server/src/createUDServer.ts +++ b/packages/api-server/src/createUDServer.ts @@ -11,7 +11,6 @@ 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 type { GraphQLYogaOptions } from '@cedarjs/graphql-server' import { getPaths } from '@cedarjs/project-config' import type { Fetchable } from './udFetchable.js' @@ -98,7 +97,14 @@ export async function createUDServer( fnImport.__rw_graphqlOptions != null ) { const { createGraphQLYoga } = await import('@cedarjs/graphql-server') - const graphqlOptions = fnImport.__rw_graphqlOptions as GraphQLYogaOptions + // Cast through unknown to bridge the CJS/ESM module resolution type + // mismatch: the static import resolves to CJS types in a CJS build, while + // the dynamic import always resolves to ESM types. Deriving the type from + // createGraphQLYoga itself guarantees both sides use the same resolution. + const graphqlOptions = + fnImport.__rw_graphqlOptions as unknown as Parameters< + typeof createGraphQLYoga + >[0] const { yoga } = createGraphQLYoga(graphqlOptions) From 511b26fdfc0e0636acc9fca72d71917457f150da Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 09:20:11 +0200 Subject: [PATCH 04/14] Update implementation and plan --- ...iversal-deploy-integration-plan-refined.md | 239 ++++++++++++---- packages/api-server/package.json | 6 + packages/api-server/src/createUDServer.ts | 246 ++-------------- packages/api-server/src/udDispatcher.ts | 266 ++++++++++++++++++ packages/cli/src/commands/serve.ts | 30 +- .../vite-plugin-cedar-universal-deploy.ts | 59 ++-- 6 files changed, 548 insertions(+), 298 deletions(-) create mode 100644 packages/api-server/src/udDispatcher.ts diff --git a/docs/implementation-plans/universal-deploy-integration-plan-refined.md b/docs/implementation-plans/universal-deploy-integration-plan-refined.md index a799c73c48..759e958f97 100644 --- a/docs/implementation-plans/universal-deploy-integration-plan-refined.md +++ b/docs/implementation-plans/universal-deploy-integration-plan-refined.md @@ -365,7 +365,7 @@ of responsibility between Cedar and Universal Deploy. UD provides adapters that read from its store and handle all deployment-target-specific wiring: -- `@universal-deploy/adapter-node` — wraps store entries with `srvx` +- `@universal-deploy/node` — wraps store entries with `srvx` (a WinterTC-compatible Node.js HTTP server) and `sirv` for static assets. Handles baremetal and VPS self-hosting. - `@universal-deploy/adapter-netlify` — wires Cedar's entries into @@ -418,7 +418,7 @@ is UD's domain. - Cedar owns zero deployment adapters — Node, Netlify, Vercel, Cloudflare, and any future targets are UD's responsibility - Nginx or another reverse proxy can sit in front for self-hosting; - the Node runtime is provided by `@universal-deploy/adapter-node` + the Node runtime is provided by `@universal-deploy/node` ## Implementation Phases @@ -689,53 +689,152 @@ Can proceed **in parallel with Phase 2** after Phase 1. #### Goal Replace Fastify as Cedar's production runtime by emitting -WinterTC-compatible `Fetchable` entries and wiring them into UD's -adapter ecosystem. Cedar builds no adapters of its own. +WinterTC-compatible `Fetchable` entries that UD adapters can consume, +and providing a working srvx-based API server as the immediate +Fastify replacement. Cedar builds no adapters of its own. + +Phase 3 delivers the runtime dispatch infrastructure and the virtual +module wiring that UD adapters need. Full end-to-end validation using +`@universal-deploy/node` proper is deferred to Phase 4, because +`@universal-deploy/node` requires Cedar's API to be built with Vite — +which does not happen until Phase 4. #### Work -- Implement `buildCedarContext(request)` — the internal enrichment - step that produces `CedarRequestContext` from a standard `Request` -- Implement Cedar's build tooling to wrap each `handleRequest()` export in a - `Fetchable`: - ```ts - // Generated output per Cedar server entry - export default { - async fetch(request: Request): Promise { - 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 +- Implement the `virtual:cedar-api` virtual module in + `cedarUniversalDeployPlugin()` (`@cedarjs/vite`): exports the + `Fetchable` from `buildCedarDispatcher()` as the module's default + export, making Cedar's API consumable as a standard UD entry +- Resolve `virtual:ud:catch-all` → `virtual:cedar-api` in the Vite + plugin: the UD catch-all ID is the virtual module that + `@universal-deploy/node/serve` imports to start its srvx server; + pre-wiring it to `virtual:cedar-api` now means Phase 4 can plug in + `@universal-deploy/node` without touching the Vite plugin + +#### 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 server build, the hookup is +straightforward: + +1. Add `node()` from `@universal-deploy/node/vite` to the + **server-side Vite build config** — not to `cedarUniversalDeployPlugin()`, + which belongs to the web client build +2. `virtual:ud:catch-all` is already wired to `virtual:cedar-api` + in `cedarUniversalDeployPlugin()` (done in Phase 3) +3. `cedar serve` runs the Vite-built output directly + +**Naming caution for Phase 4**: Vite calls its Node.js server build +environment **"SSR"** regardless of whether it renders HTML. This is +confusing in Cedar's context, where "SSR" specifically means React +streaming / RSC. The Vite "SSR environment" output that +`@universal-deploy/node` produces is purely the API server entry — it +has no connection to Cedar's HTML SSR feature. Do not add `node()` to +any Vite config that also builds the HTML SSR entry. #### Deliverables -- `buildCedarContext` utility in a shared framework package -- Build tooling that emits `Fetchable` entries per Cedar server entry -- `@universal-deploy/store` integration (`addEntry` calls at build time) -- Validated self-hosting via `@universal-deploy/adapter-node` +- `buildCedarDispatcher(options)` — runtime function discovery and + Fetchable dispatch, in `@cedarjs/api-server` +- `createUDServer(options)` — srvx-based API server wrapping the + dispatcher, in `@cedarjs/api-server` +- `cedar-ud-server` binary and `cedar serve api --ud` flag — serve + the Cedar API without Fastify +- `virtual:cedar-api` virtual module in `cedarUniversalDeployPlugin()` + — exports the Cedar API Fetchable for UD adapter consumption +- `virtual:ud:catch-all` → `virtual:cedar-api` wired in the Vite + plugin, ready for Phase 4's `@universal-deploy/node` hookup #### Exit Criteria -- Cedar can run in production on Node without Fastify, using - `@universal-deploy/adapter-node` -- Cedar's server entries are registered in the UD store at build time -- `yarn rw serve` no longer depends on the Fastify-first API server - architecture +- Cedar can run in production on Node without Fastify via + `cedar serve api --ud` or the `cedar-ud-server` binary +- Cedar's API entry is registered in the UD store and exposed as + `virtual:cedar-api`, consumable by any UD adapter +- `virtual:ud:catch-all` resolves correctly so that plugging in + `node()` from `@universal-deploy/node/vite` in Phase 4 requires no + further changes to `cedarUniversalDeployPlugin()` + +#### Temporary scaffolding introduced in Phase 3 + +Several pieces of Phase 3 are deliberate scaffolding — they make Cedar +work without Fastify today while the Vite-based build pipeline that +`@universal-deploy/node` requires does not yet exist. They should be +removed or replaced in the phases noted below. + +**Remove / replace in Phase 4:** + +- `createUDServer` (`packages/api-server/src/createUDServer.ts`) — + the srvx runtime stand-in for `@universal-deploy/node`. Phase 4 + replaces it with a Vite-built server entry produced by + `@universal-deploy/node/vite`'s `node()` plugin. Once `cedar serve` + runs that built output, `createUDServer` has no remaining purpose + and should be deleted. +- `udBin.ts` / `udCLIConfig.ts` / the `cedar-ud-server` binary — + these exist solely to invoke `createUDServer`. They go away together + with it in Phase 4, unless a non-Vite standalone serve mode is + deliberately kept. +- `cedar serve api --ud` CLI flag (`packages/cli/src/commands/serve.ts`) + — the temporary bridge that routes to `createUDServer` instead of + Fastify. Phase 4 should make UD serving the default and remove the + flag entirely. +- `buildCedarDispatcher` (`packages/api-server/src/udDispatcher.ts`) — + the runtime function-discovery function (uses `fast-glob` to scan + `api/dist/functions/` at startup). In Phase 4 the API is built and + bundled by Vite, so runtime discovery is no longer needed; the + function can be deleted. If a non-Vite standalone mode is kept, + `buildCedarDispatcher` can be retained for that path only. + +**Replace in Phase 5:** + +- `virtual:ud:catch-all` → `virtual:cedar-api` single re-export in + `cedarUniversalDeployPlugin()` — works only because Phase 3 uses a + single aggregate entry. Phase 5 replaces it with a generated + multi-route dispatcher that imports each per-route entry and routes + via rou3 (matching what `@universal-deploy/vite`'s `catchAll()` + plugin does). +- The single `addEntry({ id: 'virtual:cedar-api', route: ['/api/**', ...] })` + call in `cedarUniversalDeployPlugin()` — Phase 5 replaces this with + per-route `addEntry()` calls derived from Cedar's route manifest + (Phase 2). The hardcoded route list is a stopgap. **User-facing impact**: None for most developers. Self-hosting users -get a simpler, Fastify-free production server backed by UD's node -adapter. +can opt in to the Fastify-free srvx server via `cedar serve api --ud` +or the `cedar-ud-server` binary. Full `@universal-deploy/node` +end-to-end arrives in Phase 4. --- @@ -761,18 +860,30 @@ development entrypoint. entries - Preserve strong DX for browser requests, direct `curl` requests, and GraphQL tooling (e.g., GraphiQL must still work) +- Wire in `@universal-deploy/node` for production serving: add + `node()` from `@universal-deploy/node/vite` to the **server-side** + Vite build config (the config that builds API functions — not + `cedarUniversalDeployPlugin()`, which lives in the web build). The + `virtual:ud:catch-all` → `virtual:cedar-api` redirect is already in + place from Phase 3. After this, `cedar serve` runs the Vite-built + server entry instead of `createUDServer` #### Deliverables - One visible development port - One dev request dispatcher - One shared module graph for frontend and backend development +- `@universal-deploy/node` wired end-to-end: Vite builds a + self-contained server entry; `cedar serve` runs it #### 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 serve` runs an `@universal-deploy/node`-built server entry, + completing the Phase 3 goal of removing Fastify from the production + path entirely **User-facing impact**: High (positive). Developers see one port, one process, simpler mental model. Config files may need minor updates. @@ -787,37 +898,57 @@ Depends on Phase 4. #### Goal -Promote the initial `addEntry()` wiring from Phase 3 into a -first-class Cedar Vite plugin in `@cedarjs/vite`. Phase 3 gets Cedar -running without Fastify using UD's adapters; Phase 5 makes the -integration complete, correct, and provider-discoverable. +Expand `cedarUniversalDeployPlugin()` (already in `@cedarjs/vite` +since Phase 3) from a single aggregate entry into a complete, +per-route registration that UD adapters and provider plugins can +rely on. Phase 3 ships a working plugin with one catch-all entry; +Phase 5 makes it correct and provider-discoverable. + +#### Current state after Phase 3 + +`cedarUniversalDeployPlugin()` already 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 `buildCedarDispatcher()` + as a Fetchable so UD adapters can consume the Cedar API +- `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 3 +- Validate the plugin against `@universal-deploy/node` and `@universal-deploy/adapter-netlify` - Document the plugin's role so future UD adapter authors know what Cedar registers and in what shape #### Deliverables -- `@cedarjs/vite` Cedar UD plugin +- `cedarUniversalDeployPlugin()` expanded with per-route entries + from Cedar's route manifest - All Cedar server entries registered via `addEntry()` with complete metadata at Vite/plugin time - Cedar's route manifest and UD's store in sync from a single source of truth +- Validated against `@universal-deploy/node` end-to-end #### Exit Criteria @@ -885,9 +1016,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: diff --git a/packages/api-server/package.json b/packages/api-server/package.json index 5f54468a8c..3fe419b64f 100644 --- a/packages/api-server/package.json +++ b/packages/api-server/package.json @@ -73,6 +73,12 @@ "types": "./dist/cjs/bothCLIConfigHandler.d.ts", "default": "./dist/cjs/bothCLIConfigHandler.js" }, + "./udDispatcher": { + "import": { + "types": "./dist/udDispatcher.d.ts", + "default": "./dist/udDispatcher.js" + } + }, "./udServer": { "import": { "types": "./dist/createUDServer.d.ts", diff --git a/packages/api-server/src/createUDServer.ts b/packages/api-server/src/createUDServer.ts index 66165c1fb9..8366fc1f34 100644 --- a/packages/api-server/src/createUDServer.ts +++ b/packages/api-server/src/createUDServer.ts @@ -1,255 +1,49 @@ -import path from 'node:path' -import { pathToFileURL } from 'node:url' - import { addEntry } from '@universal-deploy/store' -import fg from 'fast-glob' -import { addRoute, createRouter, findRoute } from 'rou3' import { serve } from 'srvx' import type { Server } from 'srvx' -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' +import type { CedarDispatcherOptions } from './udDispatcher.js' +import { buildCedarDispatcher } from './udDispatcher.js' -export interface CreateUDServerOptions { +export interface CreateUDServerOptions extends CedarDispatcherOptions { port?: number host?: string - apiRootPath?: string - discoverFunctionsGlob?: string | string[] -} - -/** - * 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 } +// TODO Phase 4 — remove this function. It is temporary scaffolding that +// stands in for `@universal-deploy/node` while Cedar's API is built with +// Babel/esbuild rather than Vite. Once Phase 4 moves the API to a Vite build +// and wires in `node()` from `@universal-deploy/node/vite`, `cedar serve` +// will run the Vite-built server entry directly and this function has no +// remaining purpose. See the Phase 3 "Temporary scaffolding" section in +// docs/implementation-plans/universal-deploy-integration-plan-refined.md /** * Creates a WinterTC-compatible HTTP server using srvx that serves Cedar API - * functions discovered in `api/dist/functions/`. - * - * Each function is wrapped in a Fetchable and registered with the - * `@universal-deploy/store` via `addEntry()`. The srvx fetch handler routes - * incoming requests to the correct Fetchable using rou3 for URL pattern - * matching. + * functions discovered in `api/dist/functions/`. Function discovery and + * routing are delegated to buildCedarDispatcher. Each discovered function is + * also registered with the @universal-deploy/store via addEntry() for UD + * tooling introspection. */ export async function createUDServer( options?: CreateUDServerOptions, ): Promise { const port = options?.port ?? 8911 const host = options?.host - const normalizedApiRootPath = normalizeApiRootPath( - options?.apiRootPath ?? '/', - ) - const discoverFunctionsGlob = - options?.discoverFunctionsGlob ?? 'dist/functions/**/*.{ts,js}' - // Discover function files in api/dist/functions/ - const serverFunctions = fg.sync(discoverFunctionsGlob, { - cwd: getPaths().api.base, - deep: 2, - absolute: true, + const { fetchable, registrations } = await buildCedarDispatcher({ + apiRootPath: options?.apiRootPath, + discoverFunctionsGlob: options?.discoverFunctionsGlob, }) - // 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() - - for (const fnPath of serverFunctions) { - const routeName = path.basename(fnPath, path.extname(fnPath)) - const routePath = routeName === 'graphql' ? '/graphql' : `/${routeName}` - - const fnImport = await import(pathToFileURL(fnPath).href) - - // Check if this is a GraphQL function — the babel plugin adds - // `__rw_graphqlOptions` to api/dist/functions/graphql.js - if ( - '__rw_graphqlOptions' in fnImport && - fnImport.__rw_graphqlOptions != null - ) { - const { createGraphQLYoga } = await import('@cedarjs/graphql-server') - // Cast through unknown to bridge the CJS/ESM module resolution type - // mismatch: the static import resolves to CJS types in a CJS build, while - // the dynamic import always resolves to ESM types. Deriving the type from - // createGraphQLYoga itself guarantees both sides use the same resolution. - const graphqlOptions = - fnImport.__rw_graphqlOptions as unknown as Parameters< - typeof createGraphQLYoga - >[0] - - const { yoga } = createGraphQLYoga(graphqlOptions) - - const graphqlFetchable: Fetchable = { - async fetch(request: Request): Promise { - const cedarContext = await buildCedarContext(request, { - authDecoder: graphqlOptions.authDecoder, - }) - const event = await requestToLegacyEvent(request, cedarContext) - - // Phase 1 transitional context bridge: pass both Fetch-native fields - // (request, cedarContext) and legacy bridge fields (event, - // requestContext) so that Cedar-owned Yoga plugins that have not yet - // migrated to the Fetch-native shape continue to work. - return yoga.handle(request, { - request, - cedarContext, - event, - requestContext: undefined, - }) - }, - } - - fetchableMap.set(routeName, graphqlFetchable) - - const graphqlMethods = ['GET', 'POST', 'OPTIONS'] as const - - addEntry({ - id: routePath, - route: routePath, - method: [...graphqlMethods], - }) - - for (const method of graphqlMethods) { - 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)) - - const regularMethods = ['GET', 'POST'] as const - - addEntry({ - id: routePath, - route: routePath, - method: [...regularMethods], - }) - - for (const method of regularMethods) { - addRoute(router, method, routePath, routeName) - addRoute(router, method, `${routePath}/**`, routeName) - } + for (const registration of registrations) { + addEntry(registration) } const server = serve({ port, hostname: host, 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 fetchable = fetchableMap.get(matchedRouteName) - - if (!fetchable) { - return new Response('Not Found', { status: 404 }) - } - - try { - return await fetchable.fetch(request) - } catch (err) { - console.error( - 'Unhandled error in fetch handler for route', - matchedRouteName, - err, - ) - return new Response('Internal Server Error', { status: 500 }) - } - }, - ) + return Promise.resolve(fetchable.fetch(request)) }, }) diff --git a/packages/api-server/src/udDispatcher.ts b/packages/api-server/src/udDispatcher.ts new file mode 100644 index 0000000000..44162ecab2 --- /dev/null +++ b/packages/api-server/src/udDispatcher.ts @@ -0,0 +1,266 @@ +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' + +export interface CedarDispatcherOptions { + apiRootPath?: string + discoverFunctionsGlob?: string | string[] +} + +export interface CedarDispatcherResult { + fetchable: Fetchable + registrations: EntryMeta[] +} + +/** + * Normalizes the api root path so it always starts and ends with a `/`. + * e.g. `v1` → `/v1/`, `/v1` → `/v1/`, `/` → `/` + */ +function normalizeApiRootPath(rootPath: string): string { + let normalized = rootPath + + if (!normalized.startsWith('/')) { + normalized = '/' + normalized + } + + if (!normalized.endsWith('/')) { + normalized = normalized + '/' + } + + return normalized +} + +// TODO Phase 4 — the runtime function-discovery approach used here (scanning +// `api/dist/functions/` with fast-glob at startup) is temporary scaffolding +// for the period when Cedar's API is built with Babel/esbuild rather than +// Vite. Once Phase 4 moves the API to a Vite build, functions are bundled +// statically at build time and runtime discovery is no longer needed. At that +// point this function can be deleted (or retained only for a deliberate +// non-Vite standalone-serve mode). See the Phase 3 "Temporary scaffolding" +// section in docs/implementation-plans/universal-deploy-integration-plan-refined.md +/** + * Shared inner routing logic used by both `createUDServer` (which wraps it in + * srvx) and the Vite plugin's `virtual:cedar-api` module. + * + * 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/ + const serverFunctions = fg.sync(discoverFunctionsGlob, { + cwd: getPaths().api.base, + deep: 2, + absolute: true, + }) + + // Put the graphql function first for consistent load ordering + const graphqlIdx = serverFunctions.findIndex( + (x) => path.basename(x, path.extname(x)) === 'graphql', + ) + + if (graphqlIdx >= 0) { + const [graphqlFn] = serverFunctions.splice(graphqlIdx, 1) + serverFunctions.unshift(graphqlFn) + } + + // Build fetchable map: routeName -> Fetchable + const fetchableMap = new Map() + + // Build rou3 router for URL pattern matching + const router = createRouter() + + const registrations: EntryMeta[] = [] + + for (const fnPath of serverFunctions) { + const routeName = path.basename(fnPath, path.extname(fnPath)) + const routePath = routeName === 'graphql' ? '/graphql' : `/${routeName}` + + const fnImport = await import(pathToFileURL(fnPath).href) + + // Check if this is a GraphQL function — the babel plugin adds + // `__rw_graphqlOptions` to api/dist/functions/graphql.js + if ( + '__rw_graphqlOptions' in fnImport && + fnImport.__rw_graphqlOptions != null + ) { + const { createGraphQLYoga } = await import('@cedarjs/graphql-server') + + // Cast through unknown to bridge the CJS/ESM module resolution type + // mismatch: the static import resolves to CJS types in a CJS build, while + // the dynamic import always resolves to ESM types. Deriving the type from + // createGraphQLYoga itself guarantees both sides use the same resolution. + const graphqlOptions = + fnImport.__rw_graphqlOptions as unknown as Parameters< + typeof createGraphQLYoga + >[0] + + const { yoga } = createGraphQLYoga(graphqlOptions) + + const graphqlFetchable: Fetchable = { + async fetch(request: Request): Promise { + const cedarContext = await buildCedarContext(request, { + authDecoder: graphqlOptions.authDecoder, + }) + const event = await requestToLegacyEvent(request, cedarContext) + + // Phase 1 transitional context bridge: pass both Fetch-native fields + // (request, cedarContext) and legacy bridge fields (event, + // requestContext) so that Cedar-owned Yoga plugins that have not yet + // migrated to the Fetch-native shape continue to work. + return yoga.handle(request, { + request, + cedarContext, + event, + requestContext: undefined, + }) + }, + } + + fetchableMap.set(routeName, graphqlFetchable) + + const graphqlMethods = ['GET', 'POST', 'OPTIONS'] as const + + registrations.push({ + id: routePath, + route: routePath, + method: [...graphqlMethods], + }) + + for (const method of graphqlMethods) { + 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)) + + const regularMethods = ['GET', 'POST'] as const + + registrations.push({ + id: routePath, + route: routePath, + method: [...regularMethods], + }) + + for (const method of regularMethods) { + 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/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 7c9f406e4d..79e13dfc34 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -27,6 +27,7 @@ type ServeArgv = Record & { socket?: string apiRootPath?: string apiHost?: string + ud?: boolean } export const builder = async (yargs: Argv) => { @@ -69,7 +70,23 @@ 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', { + // TODO(Phase 4): remove this flag. It is temporary scaffolding that + // bridges to createUDServer while Cedar's API is not yet Vite-built. + // Phase 4 wires in @universal-deploy/node/vite and makes UD serving + // the default, at which point this flag has no remaining purpose. + // See the Phase 3 "Temporary scaffolding" section in + // docs/implementation-plans/universal-deploy-integration-plan-refined.md + description: + 'Use the Universal Deploy server (srvx) instead of Fastify', + type: 'boolean', + default: false, + }) + }, handler: async (argv: ServeArgv) => { recordTelemetryAttributes({ command: 'serve', @@ -79,6 +96,17 @@ export const builder = async (yargs: Argv) => { apiRootPath: argv.apiRootPath, }) + if (argv.ud) { + const { handler: udHandler } = + await import('@cedarjs/api-server/udCLIConfig') + await udHandler({ + port: argv.port, + host: argv.host, + apiRootPath: argv.apiRootPath, + }) + return + } + // Run the server file, if it exists, api side only if (serverFileExists()) { const { apiServerFileHandler } = await import('./serveApiHandler.js') diff --git a/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts b/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts index 0623ab3c99..352eb683d2 100644 --- a/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts +++ b/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts @@ -4,13 +4,31 @@ import type { Plugin } from 'vite' // The virtual module ID for the Cedar API Universal Deploy entry. const CEDAR_API_VIRTUAL_ENTRY_ID = 'virtual:cedar-api' +// The UD catch-all virtual module ID. @universal-deploy/node/serve imports +// this at runtime to get the single Fetchable that handles all routes. +const UD_CATCH_ALL_ID = 'virtual:ud:catch-all' + /** * Cedar Vite plugin for Universal Deploy integration (Phase 3 / Phase 5). * * Registers Cedar's API endpoint as a Universal Deploy server entry so that - * UD-aware adapters can discover and bundle it. In Phase 5 this plugin will be - * expanded to register individual route entries derived from Cedar's route - * manifest. + * UD-aware adapters can discover and bundle it. The virtual module uses + * `buildCedarDispatcher` from `@cedarjs/api-server/udDispatcher` to build a + * WinterTC-compatible Fetchable dispatcher at build time. + * + * Also resolves the UD catch-all virtual module (`virtual:ud:catch-all`) to + * the Cedar API entry (`virtual:cedar-api`). UD adapters such as + * `@universal-deploy/node` import `virtual:ud:catch-all` from their server + * entry to obtain the single Fetchable that handles all routes. Cedar uses one + * aggregate entry, so the catch-all is a simple re-export. In Phase 5 this + * will be replaced with a generated multi-route catch-all derived from Cedar's + * route manifest. + * + * Note: the `@universal-deploy/node` Vite plugin (`node()`) is intentionally + * NOT included here. That plugin targets Vite's server build environment and + * is meant to be added separately when Cedar adopts a Vite-based full-stack + * build pipeline (Phase 4). Cedar's API side is currently built with + * Babel/esbuild, not Vite. * * @see docs/implementation-plans/universal-deploy-integration-plan-refined.md */ @@ -28,6 +46,11 @@ export function cedarUniversalDeployPlugin(): Plugin { entriesRegistered = true + // TODO(Phase 5): replace this single aggregate entry with per-route + // addEntry() calls derived from Cedar's route manifest (Phase 2). The + // hardcoded route list and single virtual:cedar-api entry are temporary + // scaffolding. See the Phase 3 "Temporary scaffolding" section in + // docs/implementation-plans/universal-deploy-integration-plan-refined.md addEntry({ id: CEDAR_API_VIRTUAL_ENTRY_ID, route: ['/api/**', '/graphql', '/graphql/**'], @@ -36,28 +59,30 @@ export function cedarUniversalDeployPlugin(): Plugin { }, }, resolveId(id) { - if (id === CEDAR_API_VIRTUAL_ENTRY_ID) { + if (id === CEDAR_API_VIRTUAL_ENTRY_ID || id === UD_CATCH_ALL_ID) { return id } return undefined }, load(id) { + if (id === UD_CATCH_ALL_ID) { + // TODO(Phase 5): replace this simple re-export with a generated + // multi-route dispatcher that imports each per-route entry and routes + // via rou3 — matching what @universal-deploy/vite's catchAll() plugin + // does for frameworks with multiple entries. This single re-export only + // works because Phase 3 uses one aggregate virtual:cedar-api entry. See + // the Phase 3 "Temporary scaffolding" section in + // docs/implementation-plans/universal-deploy-integration-plan-refined.md + return `export { default } from '${CEDAR_API_VIRTUAL_ENTRY_ID}'` + } + if (id === CEDAR_API_VIRTUAL_ENTRY_ID) { - // Phase 3 stub: returns a Fetchable that responds with 501 Not - // Implemented. In Phase 5 this will be replaced with a proper Cedar - // API dispatcher derived from the Cedar route manifest and Cedar server - // entries. return ` -export default { - async fetch(_request) { - return new Response( - 'Cedar API virtual entry: not yet implemented (Phase 5)', - { status: 501 }, - ) - }, -} -` + import { buildCedarDispatcher } from '@cedarjs/api-server/udDispatcher' + const { fetchable } = await buildCedarDispatcher() + export default fetchable + ` } return undefined From c4198f9ace3d0c6d08af6f61927b856e247e64fd Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 09:43:57 +0200 Subject: [PATCH 05/14] review fixes --- packages/api-server/src/udDispatcher.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/api-server/src/udDispatcher.ts b/packages/api-server/src/udDispatcher.ts index 44162ecab2..1120d1a9b6 100644 --- a/packages/api-server/src/udDispatcher.ts +++ b/packages/api-server/src/udDispatcher.ts @@ -14,6 +14,20 @@ import { getPaths } from '@cedarjs/project-config' import type { Fetchable } from './udFetchable.js' import { createCedarFetchable } from './udFetchable.js' +type HttpMethod = Extract, string> + +const ALL_HTTP_METHODS: HttpMethod[] = [ + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'DELETE', + 'PATCH', + 'OPTIONS', + 'CONNECT', + 'TRACE', +] + export interface CedarDispatcherOptions { apiRootPath?: string discoverFunctionsGlob?: string | string[] @@ -196,15 +210,13 @@ export async function buildCedarDispatcher( fetchableMap.set(routeName, createCedarFetchable(handler)) - const regularMethods = ['GET', 'POST'] as const - registrations.push({ id: routePath, route: routePath, - method: [...regularMethods], + // method omitted → matches all HTTP methods per @universal-deploy/store docs }) - for (const method of regularMethods) { + for (const method of ALL_HTTP_METHODS) { addRoute(router, method, routePath, routeName) addRoute(router, method, `${routePath}/**`, routeName) } From bec17ebd376897ca2522e2f310dbb52d069b18dd Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 10:17:39 +0200 Subject: [PATCH 06/14] test title fix and add missing peerdep --- packages/api-server/dist.test.ts | 2 +- packages/vite/package.json | 3 +++ yarn.lock | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/api-server/dist.test.ts b/packages/api-server/dist.test.ts index 45aa7ec2c5..5cfab6e16d 100644 --- a/packages/api-server/dist.test.ts +++ b/packages/api-server/dist.test.ts @@ -11,7 +11,7 @@ describe('dist', () => { expect(fs.existsSync(path.join(distPath, '__tests__'))).toEqual(false) }) - it('ships four bins', () => { + it('ships seven bins', () => { expect(packageConfig.bin).toMatchInlineSnapshot(` { "cedar-ud-server": "./dist/udBin.js", diff --git a/packages/vite/package.json b/packages/vite/package.json index ae4863d9d2..c931228cb9 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -113,6 +113,9 @@ "typescript": "5.9.3", "vitest": "3.2.4" }, + "peerDependencies": { + "@cedarjs/api-server": "workspace:*" + }, "engines": { "node": ">=24" }, diff --git a/yarn.lock b/yarn.lock index 94c38daae3..2546cbbd1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3796,6 +3796,8 @@ __metadata: vitest: "npm:3.2.4" ws: "npm:8.20.0" yargs-parser: "npm:21.1.1" + peerDependencies: + "@cedarjs/api-server": "workspace:*" bin: rw-dev-fe: ./dist/devFeServer.js rw-serve-fe: ./dist/runFeServer.js From c5fae00d252f0893b6ea95e3f330fc640e8621fe Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 12:02:57 +0200 Subject: [PATCH 07/14] Tighten up phase3 --- ...iversal-deploy-integration-plan-refined.md | 107 ++++++++---------- packages/api-server/src/udBin.ts | 15 +-- packages/api-server/src/udCLIConfig.ts | 6 +- packages/api-server/src/udDispatcher.ts | 13 +-- packages/vite/package.json | 4 - packages/vite/src/index.ts | 1 - .../vite-plugin-cedar-universal-deploy.ts | 91 --------------- 7 files changed, 61 insertions(+), 176 deletions(-) delete mode 100644 packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts diff --git a/docs/implementation-plans/universal-deploy-integration-plan-refined.md b/docs/implementation-plans/universal-deploy-integration-plan-refined.md index 759e958f97..20232bafc5 100644 --- a/docs/implementation-plans/universal-deploy-integration-plan-refined.md +++ b/docs/implementation-plans/universal-deploy-integration-plan-refined.md @@ -390,7 +390,7 @@ runs inside every `fetch()` wrapper Cedar emits. A Cedar app's production deployment looks like this: ``` -Cedar build tooling emits: export default { fetch } (Fetchable, per entry) +Cedar build tooling emits: export default { fetch } (Fetchable, per entry) Cedar registers with: @universal-deploy/store (addEntry) UD adapter consumes: store entries (e.g. adapter-node, adapter-netlify) Platform receives: provider-specific artifact (UD's problem, not Cedar's) @@ -438,9 +438,9 @@ Phases are not strictly sequential. After Phase 1 completes: ``` Phase 1 ──┬── Phase 2 ──┐ - │ ├── Phase 4 ── Phase 5 ──┐ - └── Phase 3 ──┘ ├── Phase 7 - Phase 6 (design: Phase 4–5) ──┘ + │ ├── Phase 4 ── Phase 5 ──┐ + └── Phase 3 ──┘ ├── Phase 7 + Phase 6 (design: Phase 4–5) ──┘ ``` --- @@ -711,15 +711,6 @@ which does not happen until Phase 4. 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 -- Implement the `virtual:cedar-api` virtual module in - `cedarUniversalDeployPlugin()` (`@cedarjs/vite`): exports the - `Fetchable` from `buildCedarDispatcher()` as the module's default - export, making Cedar's API consumable as a standard UD entry -- Resolve `virtual:ud:catch-all` → `virtual:cedar-api` in the Vite - plugin: the UD catch-all ID is the virtual module that - `@universal-deploy/node/serve` imports to start its srvx server; - pre-wiring it to `virtual:cedar-api` now means Phase 4 can plug in - `@universal-deploy/node` without touching the Vite plugin #### Why `@universal-deploy/node` proper is a Phase 4 concern @@ -747,15 +738,19 @@ 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 server build, the hookup is +When Phase 4 gives Cedar a Vite-based API server build, the hookup is straightforward: -1. Add `node()` from `@universal-deploy/node/vite` to the - **server-side Vite build config** — not to `cedarUniversalDeployPlugin()`, - which belongs to the web client build -2. `virtual:ud:catch-all` is already wired to `virtual:cedar-api` - in `cedarUniversalDeployPlugin()` (done in Phase 3) -3. `cedar serve` runs the Vite-built output directly +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 @@ -773,20 +768,11 @@ any Vite config that also builds the HTML SSR entry. dispatcher, in `@cedarjs/api-server` - `cedar-ud-server` binary and `cedar serve api --ud` flag — serve the Cedar API without Fastify -- `virtual:cedar-api` virtual module in `cedarUniversalDeployPlugin()` - — exports the Cedar API Fetchable for UD adapter consumption -- `virtual:ud:catch-all` → `virtual:cedar-api` wired in the Vite - plugin, ready for Phase 4's `@universal-deploy/node` hookup #### Exit Criteria - Cedar can run in production on Node without Fastify via `cedar serve api --ud` or the `cedar-ud-server` binary -- Cedar's API entry is registered in the UD store and exposed as - `virtual:cedar-api`, consumable by any UD adapter -- `virtual:ud:catch-all` resolves correctly so that plugging in - `node()` from `@universal-deploy/node/vite` in Phase 4 requires no - further changes to `cedarUniversalDeployPlugin()` #### Temporary scaffolding introduced in Phase 3 @@ -818,19 +804,6 @@ removed or replaced in the phases noted below. function can be deleted. If a non-Vite standalone mode is kept, `buildCedarDispatcher` can be retained for that path only. -**Replace in Phase 5:** - -- `virtual:ud:catch-all` → `virtual:cedar-api` single re-export in - `cedarUniversalDeployPlugin()` — works only because Phase 3 uses a - single aggregate entry. Phase 5 replaces it with a generated - multi-route dispatcher that imports each per-route entry and routes - via rou3 (matching what `@universal-deploy/vite`'s `catchAll()` - plugin does). -- The single `addEntry({ id: 'virtual:cedar-api', route: ['/api/**', ...] })` - call in `cedarUniversalDeployPlugin()` — Phase 5 replaces this with - per-route `addEntry()` calls derived from Cedar's route manifest - (Phase 2). The hardcoded route list is a stopgap. - **User-facing impact**: None for most developers. Self-hosting users can opt in to the Fastify-free srvx server via `cedar serve api --ud` or the `cedar-ud-server` binary. Full `@universal-deploy/node` @@ -860,13 +833,30 @@ development entrypoint. entries - Preserve strong DX for browser requests, direct `curl` requests, and GraphQL tooling (e.g., GraphiQL must still work) -- Wire in `@universal-deploy/node` for production serving: add - `node()` from `@universal-deploy/node/vite` to the **server-side** - Vite build config (the config that builds API functions — not - `cedarUniversalDeployPlugin()`, which lives in the web build). The - `virtual:ud:catch-all` → `virtual:cedar-api` redirect is already in - place from Phase 3. After this, `cedar serve` runs the Vite-built - server entry instead of `createUDServer` +- 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 @@ -898,21 +888,22 @@ Depends on Phase 4. #### Goal -Expand `cedarUniversalDeployPlugin()` (already in `@cedarjs/vite` -since Phase 3) from a single aggregate entry into a complete, -per-route registration that UD adapters and provider plugins can -rely on. Phase 3 ships a working plugin with one catch-all entry; +Expand `cedarUniversalDeployPlugin()` (introduced in Phase 4 as part +of the API server Vite build) from a single aggregate entry into a +complete, per-route registration that UD adapters and provider plugins +can rely on. Phase 4 ships a working plugin with one catch-all entry; Phase 5 makes it correct and provider-discoverable. -#### Current state after Phase 3 +#### Current state after Phase 4 -`cedarUniversalDeployPlugin()` already exists and provides: +`cedarUniversalDeployPlugin()` exists (introduced in Phase 4) and +provides: - A single aggregate `virtual:cedar-api` entry registered with `addEntry()`, covering all Cedar API routes via one catch-all Fetchable -- `virtual:cedar-api` virtual module: exports `buildCedarDispatcher()` - as a Fetchable so UD adapters can consume the Cedar API +- `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 @@ -934,7 +925,7 @@ Phase 5 makes it correct and provider-discoverable. 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 3 + 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 diff --git a/packages/api-server/src/udBin.ts b/packages/api-server/src/udBin.ts index 8f48687d46..f19089e715 100644 --- a/packages/api-server/src/udBin.ts +++ b/packages/api-server/src/udBin.ts @@ -6,11 +6,7 @@ import yargs from 'yargs/yargs' import { getPaths } from '@cedarjs/project-config' -import { - description as udDescription, - builder as udBuilder, - handler as udHandler, -} from './udCLIConfig.js' +import { description, builder, handler } from './udCLIConfig.js' if (!process.env.CEDAR_ENV_FILES_LOADED) { config({ @@ -31,9 +27,10 @@ yargs(hideBin(process.argv)) .alias('v', 'version') .command( '$0', - udDescription, - // @ts-expect-error The yargs types seem wrong; it's ok for builder to be a function - udBuilder, - udHandler, + description, + // @ts-expect-error The yargs types aren't very good; it's ok for builder to + // be a function + builder, + handler, ) .parse() diff --git a/packages/api-server/src/udCLIConfig.ts b/packages/api-server/src/udCLIConfig.ts index d924a19550..8361704c65 100644 --- a/packages/api-server/src/udCLIConfig.ts +++ b/packages/api-server/src/udCLIConfig.ts @@ -1,3 +1,4 @@ +import ansis from 'ansis' import type { Argv } from 'yargs' type UDParsedOptions = { @@ -35,11 +36,6 @@ export function builder(yargs: Argv) { export async function handler(options: UDParsedOptions) { const timeStart = Date.now() - // See https://github.com/webdiscus/ansis#troubleshooting - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const { default: ansis } = await import('ansis') - console.log(ansis.dim.italic('Starting Universal Deploy Server...')) const { createUDServer } = await import('./createUDServer.js') diff --git a/packages/api-server/src/udDispatcher.ts b/packages/api-server/src/udDispatcher.ts index 1120d1a9b6..05166870c0 100644 --- a/packages/api-server/src/udDispatcher.ts +++ b/packages/api-server/src/udDispatcher.ts @@ -14,9 +14,7 @@ import { getPaths } from '@cedarjs/project-config' import type { Fetchable } from './udFetchable.js' import { createCedarFetchable } from './udFetchable.js' -type HttpMethod = Extract, string> - -const ALL_HTTP_METHODS: HttpMethod[] = [ +const ALL_HTTP_METHODS = [ 'GET', 'HEAD', 'POST', @@ -26,7 +24,8 @@ const ALL_HTTP_METHODS: HttpMethod[] = [ 'OPTIONS', 'CONNECT', 'TRACE', -] +] as const +const GRAPHQL_METHODS = ['GET', 'POST', 'OPTIONS'] as const export interface CedarDispatcherOptions { apiRootPath?: string @@ -155,15 +154,13 @@ export async function buildCedarDispatcher( fetchableMap.set(routeName, graphqlFetchable) - const graphqlMethods = ['GET', 'POST', 'OPTIONS'] as const - registrations.push({ id: routePath, route: routePath, - method: [...graphqlMethods], + method: [...GRAPHQL_METHODS], }) - for (const method of graphqlMethods) { + for (const method of GRAPHQL_METHODS) { addRoute(router, method, routePath, routeName) addRoute(router, method, `${routePath}/**`, routeName) } diff --git a/packages/vite/package.json b/packages/vite/package.json index c931228cb9..e370c2786b 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -73,7 +73,6 @@ "@cedarjs/testing": "workspace:*", "@cedarjs/web": "workspace:*", "@swc/core": "1.15.24", - "@universal-deploy/store": "^0.2.1", "@vitejs/plugin-react": "4.7.0", "@whatwg-node/fetch": "0.10.13", "@whatwg-node/server": "0.10.18", @@ -113,9 +112,6 @@ "typescript": "5.9.3", "vitest": "3.2.4" }, - "peerDependencies": { - "@cedarjs/api-server": "workspace:*" - }, "engines": { "node": ">=24" }, diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index a0f31463a3..243287b4c9 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -32,7 +32,6 @@ 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' type PluginOptions = { mode?: string | undefined diff --git a/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts b/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts deleted file mode 100644 index 352eb683d2..0000000000 --- a/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { addEntry } from '@universal-deploy/store' -import type { Plugin } from 'vite' - -// The virtual module ID for the Cedar API Universal Deploy entry. -const CEDAR_API_VIRTUAL_ENTRY_ID = 'virtual:cedar-api' - -// The UD catch-all virtual module ID. @universal-deploy/node/serve imports -// this at runtime to get the single Fetchable that handles all routes. -const UD_CATCH_ALL_ID = 'virtual:ud:catch-all' - -/** - * Cedar Vite plugin for Universal Deploy integration (Phase 3 / Phase 5). - * - * Registers Cedar's API endpoint as a Universal Deploy server entry so that - * UD-aware adapters can discover and bundle it. The virtual module uses - * `buildCedarDispatcher` from `@cedarjs/api-server/udDispatcher` to build a - * WinterTC-compatible Fetchable dispatcher at build time. - * - * Also resolves the UD catch-all virtual module (`virtual:ud:catch-all`) to - * the Cedar API entry (`virtual:cedar-api`). UD adapters such as - * `@universal-deploy/node` import `virtual:ud:catch-all` from their server - * entry to obtain the single Fetchable that handles all routes. Cedar uses one - * aggregate entry, so the catch-all is a simple re-export. In Phase 5 this - * will be replaced with a generated multi-route catch-all derived from Cedar's - * route manifest. - * - * Note: the `@universal-deploy/node` Vite plugin (`node()`) is intentionally - * NOT included here. That plugin targets Vite's server build environment and - * is meant to be added separately when Cedar adopts a Vite-based full-stack - * build pipeline (Phase 4). Cedar's API side is currently built with - * Babel/esbuild, not Vite. - * - * @see docs/implementation-plans/universal-deploy-integration-plan-refined.md - */ -export function cedarUniversalDeployPlugin(): Plugin { - let entriesRegistered = false - - return { - name: 'cedar:universal-deploy', - config: { - order: 'pre', - handler() { - if (entriesRegistered) { - return - } - - entriesRegistered = true - - // TODO(Phase 5): replace this single aggregate entry with per-route - // addEntry() calls derived from Cedar's route manifest (Phase 2). The - // hardcoded route list and single virtual:cedar-api entry are temporary - // scaffolding. See the Phase 3 "Temporary scaffolding" section in - // docs/implementation-plans/universal-deploy-integration-plan-refined.md - addEntry({ - id: CEDAR_API_VIRTUAL_ENTRY_ID, - route: ['/api/**', '/graphql', '/graphql/**'], - method: ['GET', 'POST', 'OPTIONS', 'PUT', 'DELETE', 'PATCH'], - }) - }, - }, - resolveId(id) { - if (id === CEDAR_API_VIRTUAL_ENTRY_ID || id === UD_CATCH_ALL_ID) { - return id - } - - return undefined - }, - load(id) { - if (id === UD_CATCH_ALL_ID) { - // TODO(Phase 5): replace this simple re-export with a generated - // multi-route dispatcher that imports each per-route entry and routes - // via rou3 — matching what @universal-deploy/vite's catchAll() plugin - // does for frameworks with multiple entries. This single re-export only - // works because Phase 3 uses one aggregate virtual:cedar-api entry. See - // the Phase 3 "Temporary scaffolding" section in - // docs/implementation-plans/universal-deploy-integration-plan-refined.md - return `export { default } from '${CEDAR_API_VIRTUAL_ENTRY_ID}'` - } - - if (id === CEDAR_API_VIRTUAL_ENTRY_ID) { - return ` - import { buildCedarDispatcher } from '@cedarjs/api-server/udDispatcher' - const { fetchable } = await buildCedarDispatcher() - export default fetchable - ` - } - - return undefined - }, - } -} From f27d77c363db198a8684a00425a572bfc64a8742 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 12:19:30 +0200 Subject: [PATCH 08/14] fix formatting --- .../universal-deploy-integration-plan-refined.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/implementation-plans/universal-deploy-integration-plan-refined.md b/docs/implementation-plans/universal-deploy-integration-plan-refined.md index 20232bafc5..213fd5aa06 100644 --- a/docs/implementation-plans/universal-deploy-integration-plan-refined.md +++ b/docs/implementation-plans/universal-deploy-integration-plan-refined.md @@ -847,8 +847,8 @@ development entrypoint. 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` + 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 From 058ec686ea6c49b1b2da59486a0ad368ea4dddce Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 12:30:55 +0200 Subject: [PATCH 09/14] fix yarn.lock and review comments --- .../2026-03-26-cedarjs-project-overview.md | 80 +++++++++---------- packages/api-server/src/udDispatcher.ts | 8 ++ yarn.lock | 3 - 3 files changed, 48 insertions(+), 43 deletions(-) diff --git a/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md b/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md index c9b7f38675..959b4fd34c 100644 --- a/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md +++ b/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md @@ -195,45 +195,45 @@ Routes.tsx ← 4 routes added inside `. Typed params, globs, redirects, `` layouts, `` auth guards. Named route helpers. Link/navigate/useLocation/useParams. | -| auth | Provider-agnostic. `createAuth(provider)` → {AuthProvider, useAuth}. State: loading/authenticated/user. \*SSR/RSC: ServerAuthProvider injects state for SSR. | -| web | App shell. RedwoodProvider. createCell (GraphQL state→UI). Apollo (useQuery/useMutation). Head/MetaTags. FatalErrorBoundary. Toast. FetchConfig. | -| api | Server runtime. Auth extraction. Validations (validate/validateWith). CORS. Logging (Pino). Cache (Redis/Memcached/InMemory). Webhooks. RedwoodError. | -| graphql-server | Yoga factory. Merge SDLs (schema) + services (resolvers) + directives + subscriptions. Armor. GraphiQL. useRequireAuth. Directive system (validator+transformer). | -| vite | cedar() → Vite plugins. Cell transform, entry injection, auto-imports. \*SSR/RSC: adds Express + 2 Vite servers, RSC transforms, Hot Module Replacement. | -| cli | Yargs. 25+ commands. Generators for all types. Plugin system. Telemetry. .env loading. | -| forms | react-hook-form wrapper. Typed fields. GraphQL coercion (valueAsBoolean/JSON). Error display. | -| prerender | Static Site Generation. renderToString at build, extract react-helmet meta tags, populate Apollo cache, write static HTML. | -| realtime | Live queries + subscriptions. @live directive. createPubSub. InMemory/Redis stores. | -| jobs | Background processing. JobManager/jobs/queues/workers. Delay/waitUntil/cron. Prisma adapter. | -| mailer | Email. Core + handlers (nodemailer/resend/in-memory) + renderers (react-email/mjml). | -| storage | File uploads. setupStorage→Prisma extension. FileSystem/Memory adapters. UrlSigner. | -| record | ActiveRecord on Prisma. Validations, reflections, relations. | -| context | Request-scoped context via AsyncLocalStorage. Proxy-based. Declaration merging. | -| server-store | Per-request store: auth state, headers, cookies, URL. \*SSR/RSC: used by middleware. | -| gqlorm | Prisma API → Proxy → GraphQL. useLiveQuery. Parser+generator. | -| structure | Project model (pages/routes/cells/services/SDLs). Diagnostics. ts-morph. | -| codemods | jscodeshift transforms. Version-organized (v2-v7). Cedar+migration from Redwood. | -| testing | Jest/Vitest config. MockProviders, MockRouter, mockGql, scenario helpers. | -| storybook | Vite Storybook. | -| project-config | Read cedar.toml. getPaths/getConfig/findUp. | -| internal | Re-exports project-config+babel-config. buildApi/dev/generate. Route extraction. | -| api-server | Fastify. Auto-discover Lambda functions. Mount GraphQL. Custom server.ts. | -| web-server | Fastify for web side. Uses fastify-web adapter. | -| fastify-web | Fastify plugin. Static files, SPA fallback, API proxy, prerender. | -| babel-config | Presets/plugins for api+web. registerApiSideBabelHook. | -| eslint-config | Flat config. TS+React+a11y+react-compiler+prettier. | -| eslint-plugin | Rules: process-env-computed, service-type-annotations, unsupported-route-components. | -| create-cedar-app | Standalone scaffolding CLI. Interactive. TS/JS. Copies templates. | -| create-cedar-rsc-app | Standalone RSC scaffolding. Downloads template zip. | -| telemetry | Anonymous CLI telemetry. Duration/errors. | -| tui | Terminal UI. spinners, boxes, reactive updates. | -| ogimage-gen | Vite plugin+middleware. OG images from React components. | -| cookie-jar | Typed cookie map. get/set/has/unset/serialize. | -| utils | Pluralization wrapper. | +| Package | Behavior | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| core | Umbrella. Re-exports CLI, servers, testing, config. Bin shims. | +| router | JSX routing. ``. Typed params, globs, redirects, `` layouts, `` auth guards. Named route helpers. Link/navigate/useLocation/useParams. | +| auth | Provider-agnostic. `createAuth(provider)` → {AuthProvider, useAuth}. State: loading/authenticated/user. \*SSR/RSC: ServerAuthProvider injects state for SSR. | +| web | App shell. RedwoodProvider. createCell (GraphQL state→UI). Apollo (useQuery/useMutation). Head/MetaTags. FatalErrorBoundary. Toast. FetchConfig. | +| api | Server runtime. Auth extraction. Validations (validate/validateWith). CORS. Logging (Pino). Cache (Redis/Memcached/InMemory). Webhooks. RedwoodError. | +| graphql-server | Yoga factory. Merge SDLs (schema) + services (resolvers) + directives + subscriptions. Armor. GraphiQL. useRequireAuth. Directive system (validator+transformer). | +| vite | cedar() → Vite plugins. Cell transform, entry injection, auto-imports. \*SSR/RSC: adds Express + 2 Vite servers, RSC transforms, Hot Module Replacement. | +| cli | Yargs. 25+ commands. Generators for all types. Plugin system. Telemetry. .env loading. | +| forms | react-hook-form wrapper. Typed fields. GraphQL coercion (valueAsBoolean/JSON). Error display. | +| prerender | Static Site Generation. renderToString at build, extract react-helmet meta tags, populate Apollo cache, write static HTML. | +| realtime | Live queries + subscriptions. @live directive. createPubSub. InMemory/Redis stores. | +| jobs | Background processing. JobManager/jobs/queues/workers. Delay/waitUntil/cron. Prisma adapter. | +| mailer | Email. Core + handlers (nodemailer/resend/in-memory) + renderers (react-email/mjml). | +| storage | File uploads. setupStorage→Prisma extension. FileSystem/Memory adapters. UrlSigner. | +| record | ActiveRecord on Prisma. Validations, reflections, relations. | +| context | Request-scoped context via AsyncLocalStorage. Proxy-based. Declaration merging. | +| server-store | Per-request store: auth state, headers, cookies, URL. \*SSR/RSC: used by middleware. | +| gqlorm | Prisma API → Proxy → GraphQL. useLiveQuery. Parser+generator. | +| structure | Project model (pages/routes/cells/services/SDLs). Diagnostics. ts-morph. | +| codemods | jscodeshift transforms. Version-organized (v2-v7). Cedar+migration from Redwood. | +| testing | Jest/Vitest config. MockProviders, MockRouter, mockGql, scenario helpers. | +| storybook | Vite Storybook. | +| project-config | Read cedar.toml. getPaths/getConfig/findUp. | +| internal | Re-exports project-config+babel-config. buildApi/dev/generate. Route extraction. | +| api-server | Fastify (default) + srvx/WinterTC (opt-in via `cedar serve api --ud` or `cedar-ud-server` binary). Auto-discover Lambda functions. Mount GraphQL. Custom server.ts. srvx path uses `buildCedarDispatcher` + `createUDServer` for Fastify-free serving. | +| web-server | Fastify for web side. Uses fastify-web adapter. | +| fastify-web | Fastify plugin. Static files, SPA fallback, API proxy, prerender. | +| babel-config | Presets/plugins for api+web. registerApiSideBabelHook. | +| eslint-config | Flat config. TS+React+a11y+react-compiler+prettier. | +| eslint-plugin | Rules: process-env-computed, service-type-annotations, unsupported-route-components. | +| create-cedar-app | Standalone scaffolding CLI. Interactive. TS/JS. Copies templates. | +| create-cedar-rsc-app | Standalone RSC scaffolding. Downloads template zip. | +| telemetry | Anonymous CLI telemetry. Duration/errors. | +| tui | Terminal UI. spinners, boxes, reactive updates. | +| ogimage-gen | Vite plugin+middleware. OG images from React components. | +| cookie-jar | Typed cookie map. get/set/has/unset/serialize. | +| utils | Pluralization wrapper. | ## CONVENTIONS @@ -250,6 +250,6 @@ Routes.tsx ← 4 routes added inside Date: Thu, 23 Apr 2026 13:09:23 +0200 Subject: [PATCH 10/14] detailed plan for phase 4 --- .../universal-deploy-phase-4-detailed-plan.md | 973 ++++++++++++++++++ 1 file changed, 973 insertions(+) create mode 100644 docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md 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..a0a735b259 --- /dev/null +++ b/docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md @@ -0,0 +1,973 @@ +# Detailed Plan: Universal Deploy Phase 4 — Vite-Centric Full-Stack Dev Runtime + +## Summary + +Phase 4 is the point where Cedar's Universal Deploy work becomes visible in +day-to-day development. The core shift is not just "use Vite more"; it is +"make Vite the single externally visible development runtime for the whole app." + +Today, Cedar development is still mentally and operationally split: + +- the web side is served through Vite +- the API side runs as a separate backend process +- requests move through a proxy boundary +- backend invalidation and frontend invalidation are related, but not truly part + of one runtime model + +Phase 4 replaces that with a single dev host and a single request entrypoint +that can serve: + +- web assets and HTML +- GraphQL requests +- auth requests +- server function requests +- future fetch-native backend handlers + +This phase is also where the temporary Phase 3 scaffolding starts turning into a +real runtime architecture. In particular: + +- Cedar dev should stop exposing a separate backend port as part of the normal + developer experience +- the API runtime should execute inside a Vite-centric development environment +- the API server Vite build should gain the first real + `cedarUniversalDeployPlugin()` +- `@universal-deploy/node` should be wired into the API server build and serve + path so `cedar serve` runs the Vite-built server entry rather than the + temporary direct server construction path + +Phase 4 is still not the phase where Cedar fully formalises per-route UD entry +registration. That belongs to Phase 5. Phase 4 should intentionally ship a +working aggregate-entry model that is operationally correct for local +development and for the Node serve path. + +## Why Phase 4 Exists + +Phases 1-3 establish the prerequisites: + +- Phase 1 makes Cedar handlers fetch-native +- Phase 2 gives Cedar a formal backend route manifest +- Phase 3 adopts UD deployment adapters and introduces temporary scaffolding + +But none of that yet changes the main development experience enough. Cedar still +feels like a split system unless development itself is unified. + +Phase 4 exists to solve five concrete problems: + +1. **Port split** + - Developers should not need to think in terms of "frontend port" and + "backend port" for normal app development. + +2. **Proxy split** + - Requests should not conceptually travel from "the Vite server" to "the API + server" as two separate application runtimes. + +3. **Module graph split** + - Backend code changes should participate in a Vite-owned invalidation and + restart model rather than a separate watcher/process model. + +4. **Serve path split** + - `cedar serve` should move onto the same UD-oriented build output that the + broader integration is targeting. + +5. **Architecture split** + - Cedar should stop treating the API runtime as a special non-Vite island in + development. + +## Relationship to the Refined Integration Plan + +This document expands the refined plan's Phase 4 section into an implementation +plan with concrete architecture, workstreams, sequencing, risks, and acceptance +criteria. + +It preserves the refined plan's key constraints: + +- one visible development port +- one dev request dispatcher +- backend execution integrated into the Vite dev runtime +- strong DX for browser traffic and direct HTTP tooling +- `cedarUniversalDeployPlugin()` introduced in the API server Vite build +- `node()` from `@universal-deploy/node/vite` added to the API server Vite build +- no confusion between Vite's "SSR environment" and Cedar's HTML SSR feature + +It also preserves the phase boundary: + +- Phase 4 delivers a working aggregate-entry plugin and runtime +- Phase 5 expands that into full per-route registration and provider-facing + correctness + +## Goals + +### Primary Goals + +- Make `cedar dev` expose one externally visible host/port for the app +- Route web and API traffic through one development dispatcher +- Execute Cedar 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 +- Introduce the first production-worthy version of + `cedarUniversalDeployPlugin()` for the API server build + +### Secondary Goals + +- Preserve GraphiQL and direct `curl` workflows +- Preserve existing auth and function behavior during the transition +- Minimise app-level migration burden +- Keep Phase 4 compatible with the later Phase 5 route-registration expansion + +## 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 + +## Current Baseline Before Phase 4 + +Based on the current Cedar architecture and the refined integration plan, the +baseline is: + +- web development is Vite-centric +- API development is still conceptually separate +- 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 + +This means Phase 4 is not starting from zero. It is integrating already-created +pieces into a coherent dev runtime. + +## Architectural Target for Phase 4 + +### High-Level Shape + +After Phase 4, the development architecture should look like this: + +- one Vite-hosted dev server is externally visible +- that dev server owns the request entrypoint +- browser-facing web requests are handled by Vite as usual +- API-like requests are dispatched into Cedar's fetch-native backend runtime +- backend modules are loaded through a Vite-aware mechanism rather than a + completely separate long-lived backend process +- the API server build has a Vite config that: + - installs `cedarUniversalDeployPlugin()` + - installs `node()` from `@universal-deploy/node/vite` + - emits a self-contained Node server entry for `cedar serve` + +### Conceptual Request Flow in Dev + +The intended request flow is: + +1. request arrives at the single Vite dev host +2. Cedar dev middleware classifies the request +3. request is dispatched to one of: + - Vite static/HMR/web handling + - GraphQL handler + - auth handler + - function handler + - aggregate Cedar API dispatcher +4. response is returned directly from the same host + +The important change is that the browser, GraphQL clients, auth callbacks, and +CLI HTTP tooling all target the same visible origin. + +### Conceptual Build/Serve Flow + +For `cedar serve`, the intended flow is: + +1. API server Vite config builds the server entry +2. `cedarUniversalDeployPlugin()` registers Cedar's aggregate API entry +3. `node()` from `@universal-deploy/node/vite` produces the Node-compatible + server output +4. `cedar serve` launches that built server entry + +This completes the move away from the temporary direct server construction path +for the Node serve case. + +## Design Principles for This Phase + +### 1. Vite Owns the Dev Host + +The visible development host should be Vite's host, not a wrapper process that +merely proxies to Vite. Cedar may compose middleware around Vite, but the +developer mental model should still be "the app runs on one Vite dev server." + +### 2. Cedar Owns Request Classification + +Vite should remain the host, but Cedar should own the logic that decides whether +a request is: + +- a frontend asset/HMR request +- a page/document request +- a GraphQL request +- an auth request +- a server function request +- a fallback request + +This keeps Cedar's routing and runtime contract authoritative. + +### 3. Fetch-Native Execution Is the Runtime Center + +Backend execution should happen through Cedar's fetch-native handler contract, +not through reintroduced Node/Express/Fastify-specific request objects. + +### 4. Aggregate Entry First, Per-Route Later + +Phase 4 should use one aggregate Cedar API entry for correctness and speed of +delivery. It should not prematurely implement the full Phase 5 route-splitting +model. + +### 5. No Cedar/SSR Terminology Drift + +Any Vite config or code comments must clearly distinguish: + +- Vite "SSR" meaning server-side module execution/build target +- Cedar "SSR" meaning HTML server rendering / streaming / RSC-related behavior + +This distinction matters because the API server build will use Vite's server +build machinery without implying Cedar HTML SSR. + +### 6. Preserve Existing App Contracts Where Possible + +App authors should not need to rewrite routes, functions, GraphQL handlers, or +auth setup just to adopt Phase 4. + +## Proposed Runtime Architecture + +## 1. Dev Runtime Composition + +The Phase 4 dev runtime should be composed from three layers: + +### Layer A: Vite Dev Server + +Responsibilities: + +- static asset serving +- HMR +- HTML transforms +- frontend module graph ownership +- browser-facing dev ergonomics + +### Layer B: Cedar Dev Request Dispatcher + +Responsibilities: + +- classify incoming requests +- decide whether Cedar backend handling should run +- invoke the aggregate Cedar API fetch dispatcher when appropriate +- fall through to Vite web handling when appropriate + +This is the key new Phase 4 layer. + +### Layer C: Cedar Aggregate API Runtime + +Responsibilities: + +- execute GraphQL +- execute auth endpoints +- execute filesystem-discovered functions +- execute any other 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. + +## 2. Request Classification Model + +The dispatcher should classify requests in a deterministic order. A practical +order is: + +1. Vite internal requests + - HMR endpoints + - Vite client assets + - transformed module requests +2. explicit API endpoints + - GraphQL + - auth + - function routes +3. web asset requests + - static files + - known web assets +4. page/document requests + - app routes that should return the web app shell in SPA mode +5. fallback/error handling + +The exact path patterns should come from Cedar configuration and route manifest +data where possible, not from scattered hardcoded checks. + +### Why Ordering Matters + +Ordering mistakes can create subtle bugs: + +- Vite HMR requests accidentally routed into Cedar API handling +- GraphQL requests falling through to SPA HTML +- auth callback routes being treated as frontend routes +- static assets being intercepted by API logic + +Phase 4 should therefore define request classification as a first-class runtime +concern, not an incidental middleware detail. + +## 3. Backend Execution Model in Dev + +There are two broad implementation styles Cedar could take: + +### Option A: In-Process Vite Middleware Execution + +Cedar installs middleware into the Vite dev server and directly invokes the +aggregate fetch dispatcher from there. + +**Pros** + +- simplest mental model +- one visible server +- minimal extra process orchestration +- easiest path to "one dispatcher" + +**Cons** + +- backend invalidation semantics must be handled carefully +- Node-only backend dependencies must coexist with Vite's server runtime model +- error isolation may be weaker than a separate worker model + +### Option B: Vite-Owned Host with Internal Backend Worker + +Cedar still exposes one visible Vite host, but backend execution happens in an +internal worker/sub-runtime managed by the dev system. + +**Pros** + +- stronger isolation +- potentially cleaner backend reload semantics + +**Cons** + +- more moving parts +- easier to accidentally recreate the old split model internally +- more complexity for Phase 4 than likely necessary + +### Recommendation + +Phase 4 should prefer **Option A** unless implementation evidence proves it +unworkable. The refined plan already points toward Vite middleware integration, +and that is the shortest path to the intended developer experience. + +If isolation issues appear, they should be documented as follow-up work rather +than causing Phase 4 to balloon into a multi-runtime orchestration project. + +## 4. Backend Invalidation and Reload Strategy + +This is one of the most important implementation details. + +The backend runtime must respond correctly to changes in: + +- `api/src/functions/**` +- `api/src/graphql/**` +- `api/src/services/**` +- auth-related backend modules +- route manifest inputs +- generated artifacts that affect backend execution + +### Required Outcomes + +- code changes should be reflected without requiring manual process restarts +- stale backend modules should not remain cached indefinitely +- errors should surface clearly in the terminal and browser/client responses +- invalidation should be targeted enough to avoid unnecessary full reloads when + possible + +### Practical Strategy + +Phase 4 should start with a conservative invalidation model: + +- treat the aggregate Cedar API runtime as a reloadable server module boundary +- when backend-relevant files change, invalidate the aggregate backend entry and + its dependent modules +- rebuild or re-import the backend dispatcher through Vite's server module + system +- prefer correctness over maximal granularity + +This is another place where Phase 5 can later improve precision once per-route +entries exist. + +### Important Constraint + +Do not try to make backend invalidation mirror frontend HMR exactly. Backend +execution correctness matters more than preserving stateful hot replacement +semantics. + +## 5. Aggregate API Entry Shape + +Phase 4 should introduce a single aggregate virtual entry, likely represented by +`virtual:cedar-api`. + +That virtual module should: + +- import the Cedar API dispatcher construction logic +- build the aggregate fetchable from Cedar's route/function/GraphQL/auth sources +- export the aggregate fetchable as the default export + +This entry is the bridge between Cedar's runtime model and UD's Vite/plugin +model. + +### Why Aggregate Entry Is Correct for Phase 4 + +An aggregate entry: + +- keeps plugin complexity manageable +- avoids premature provider-specific route splitting +- is sufficient for local dev and Node serve +- aligns with the refined plan's explicit Phase 4/Phase 5 boundary + +## 6. `cedarUniversalDeployPlugin()` Responsibilities in Phase 4 + +The plugin introduced in this phase should do exactly the minimum needed for a +working system. + +### 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. + +## 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 + +### 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. + +### 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 + +### 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. + +### 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 + +### 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. + +### Tasks + +- update CLI startup messaging to show one visible port +- remove or reduce references to separate web/API dev ports in normal output +- 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 + +### 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. + +### 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. + +### 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 + +## Suggested Milestones + +## Milestone A: Aggregate Backend Runtime Works + +Success means: + +- one aggregate fetch dispatcher exists +- GraphQL, auth, and functions all work through it +- it can be invoked independently of the final Vite integration + +## Milestone B: One-Port Dev Host Works + +Success means: + +- browser, GraphQL, auth, and functions all work from one visible host +- Vite HMR still works +- no separate backend port is required for normal use + +## Milestone C: Backend Reload Works Reliably + +Success means: + +- backend edits are reflected without manual restart +- stale module behavior is not observed in normal workflows + +## Milestone D: `cedar serve` Uses UD Node Output + +Success means: + +- API server Vite build emits the server entry +- `cedar serve` launches it successfully +- the temporary direct server path is no longer needed for the Node serve case + +## Risks and Mitigations + +## Risk 1: Vite Internal Requests Are Misclassified + +### Impact + +HMR or module loading breaks in confusing ways. + +### Mitigation + +- classify Vite internal requests first +- add explicit tests for Vite-specific paths +- add debug logging around request classification during development + +## Risk 2: Backend Module Invalidation Is Incomplete + +### Impact + +Developers see stale backend behavior and lose trust in the runtime. + +### Mitigation + +- start with coarse invalidation at the aggregate entry boundary +- prefer correctness over fine-grained optimisation +- log backend invalidation events during early rollout + +## Risk 3: Auth Flows Regress + +### Impact + +Login/logout/callback behavior breaks, often only in certain providers. + +### Mitigation + +- explicitly test callback-style providers +- test cookie-based and token-based auth paths +- preserve existing request context enrichment semantics + +## Risk 4: GraphiQL or Direct HTTP Tooling Regresses + +### Impact + +Developer workflows become worse even if browser flows work. + +### Mitigation + +- treat GraphiQL and `curl` as first-class acceptance cases +- test non-browser requests explicitly +- avoid assumptions that all requests originate from the SPA + +## Risk 5: Phase 4 Accidentally Expands into Phase 5 + +### Impact + +Delivery slows down because the team tries to solve per-route provider +registration too early. + +### Mitigation + +- keep the aggregate-entry boundary explicit +- defer per-route UD registration to Phase 5 +- document temporary limitations clearly + +## Risk 6: Terminology Confusion Around "SSR" + +### Impact + +Future maintainers wire `node()` into the wrong Vite config or conflate API +server builds with Cedar HTML SSR. + +### Mitigation + +- document the distinction repeatedly +- use precise naming in config and comments +- avoid ambiguous labels like "SSR build" without qualification + +## 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? + +## Exit Criteria for Phase 4 + +Phase 4 should be considered complete when all of the following are true: + +- `cedar dev` exposes one externally visible host/port for normal development +- GraphQL requests work directly against that host +- auth requests work directly against that host +- function requests work directly against that host +- browser app loading and HMR still work +- backend code changes are reflected without manual restart in normal workflows +- 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 +- Cedar no longer depends on a separately exposed backend port for the standard + dev experience +- the implementation does not require Cedar HTML SSR/RSC work to be complete + +## Deliverables + +Phase 4 should produce the following concrete outputs: + +- unified one-port dev runtime +- internal dev request dispatcher +- aggregate Cedar API fetch dispatcher for dev +- backend invalidation/reload behavior integrated with the Vite-centric runtime +- initial `cedarUniversalDeployPlugin()` in `@cedarjs/vite` +- `@cedarjs/api-server` peer dependency declared by `@cedarjs/vite` +- API server Vite build wired with `node()` from `@universal-deploy/node/vite` +- `cedar serve` updated to run the built Node server entry +- updated docs and CLI messaging reflecting the unified runtime + +## Recommended Scope Boundary for the PR Series + +This phase is large enough that it should likely land as a sequence of focused +PRs rather than one giant change. + +A sensible split is: + +1. internal dispatcher contract and aggregate backend runtime +2. Vite dev integration for one-port handling +3. backend invalidation/watch behavior +4. `cedarUniversalDeployPlugin()` introduction +5. `@universal-deploy/node` serve-path integration +6. docs, CLI messaging, and cleanup + +That keeps the architecture moving forward while preserving reviewability. + +## 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, while `cedar serve` moves onto the +UD Node build output. If that is achieved with an aggregate API entry and +conservative backend invalidation, Phase 4 is successful. + +Phase 5 can then build on a stable runtime foundation to formalise per-route UD +entry registration and provider-facing metadata correctness. From a3904da0ea26019bb543a6994e719273efca6136 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 13:13:26 +0200 Subject: [PATCH 11/14] update plans --- ...iversal-deploy-integration-plan-refined.md | 79 ++++-- .../universal-deploy-phase-4-detailed-plan.md | 242 ++++++++++++++---- 2 files changed, 248 insertions(+), 73 deletions(-) diff --git a/docs/implementation-plans/universal-deploy-integration-plan-refined.md b/docs/implementation-plans/universal-deploy-integration-plan-refined.md index 213fd5aa06..f7eb4fb721 100644 --- a/docs/implementation-plans/universal-deploy-integration-plan-refined.md +++ b/docs/implementation-plans/universal-deploy-integration-plan-refined.md @@ -51,8 +51,6 @@ https://github.com/universal-deploy/universal-deploy/blob/main/docs/framework-de contract - Preserving the current Express-based SSR runtime as foundational architecture -- Minimizing breaking changes for existing Cedar apps (though a - migration path is provided) - Implementing full UD support before Cedar has standardized its own runtime contracts @@ -820,19 +818,26 @@ Depends on Phases 2 and 3. #### Goal Replace the current web+API split dev model with a single Vite-hosted -development entrypoint. +development entrypoint for Cedar's default runtime path, while preserving +a compatibility path for existing apps that depend on custom Fastify +server setup. #### Work -- Eliminate the `8910 → proxy → 8911` mental model +- Eliminate the `8910 → proxy → 8911` mental model for the default + Cedar runtime path - Route page, GraphQL, auth, and function requests through one - externally visible dev host + externally visible dev host on that default path - Integrate backend handler execution into the Vite dev runtime (likely via Vite's `server.middlewareMode` or custom plugin) - Ensure server-side file watching and invalidation work for backend entries - Preserve strong DX for browser requests, direct `curl` requests, and GraphQL tooling (e.g., GraphiQL must still work) +- Preserve a compatibility path for apps that use `api/src/server.{ts,js}`, + `configureFastify`, `configureApiServer`, or direct Fastify plugin + registration, rather than silently routing them through the new + default runtime and dropping supported behavior - Introduce `cedarUniversalDeployPlugin()` in `@cedarjs/vite` and wire it into the **API server Vite build config**: register `virtual:cedar-api` with the UD store via `addEntry()`, resolve @@ -860,23 +865,33 @@ also builds the HTML SSR entry. #### Deliverables -- One visible development port -- One dev request dispatcher -- One shared module graph for frontend and backend development +- One visible development port on the default runtime path +- One dev request dispatcher on the default runtime path +- One shared module graph for frontend and backend development on the + default runtime path +- A documented compatibility path for apps with custom Fastify server + setup - `@universal-deploy/node` wired end-to-end: Vite builds a - self-contained server entry; `cedar serve` runs it + self-contained server entry; `cedar serve` runs it on the default + runtime path #### Exit Criteria -- Cedar dev no longer requires a separately exposed backend port +- Cedar dev no longer requires a separately exposed backend port on the + default runtime path - Requests to functions and GraphQL can be made directly against the - Vite dev host -- `cedar serve` runs an `@universal-deploy/node`-built server entry, - completing the Phase 3 goal of removing Fastify from the production - path entirely - -**User-facing impact**: High (positive). Developers see one port, one -process, simpler mental model. Config files may need minor updates. + Vite dev host on the default runtime path +- `cedar serve` runs an `@universal-deploy/node`-built server entry on + the default runtime path, completing the Phase 3 goal of removing + Fastify from that production path +- Existing apps with custom Fastify server setup still have a supported + compatibility path and are not silently forced onto the new default + runtime + +**User-facing impact**: High (positive). Most developers see one port, +one process, and a simpler mental model. Existing apps with custom +Fastify setup remain on a compatibility path until a later migration +story exists. Config files may need minor updates. --- @@ -1060,8 +1075,11 @@ nothing.** developer's perspective. UD's node adapter is wired up but used only for production self-hosting. Dev still uses two ports. -**After Phase 4**: Single-port dev. This is the first major visible -change. Developers update their config and enjoy a simpler mental model. +**After Phase 4**: Single-port dev on the default runtime path. This is +the first major visible change. Developers on the standard Cedar path +update their config and enjoy a simpler mental model. Apps with custom +Fastify server setup remain on a compatibility path rather than being +silently forced onto the new runtime. **After Phase 5**: No visible change for developers. UD integration is framework-internal. @@ -1131,18 +1149,20 @@ impact (Phases 4, 6, 7). The guide should cover: - Step-by-step migration instructions - Before/after code examples - Common pitfalls +- How to identify whether an app is on the default runtime path or the + custom Fastify compatibility path ### Which Phases Require App Developer Action -| Phase | App Developer Action Required | -| ----- | ------------------------------------------- | -| 1 | None (shim handles it) | -| 2 | None | -| 3 | None | -| 4 | Config updates, possible dev script changes | -| 5 | None | -| 6 | SSR config migration | -| 7 | Deploy config updates | +| Phase | App Developer Action Required | +| ----- | ----------------------------------------------------------------------------------- | +| 1 | None (shim handles it) | +| 2 | None | +| 3 | None | +| 4 | Config updates for standard apps; compatibility-path review for custom Fastify apps | +| 5 | None | +| 6 | SSR config migration | +| 7 | Deploy config updates | ## Risks @@ -1160,6 +1180,9 @@ impact (Phases 4, 6, 7). The guide should cover: to edge cases in existing auth middleware - Phase 4 (Vite-centric dev) being significantly harder than estimated due to HMR, module graph, and backend file watching interactions +- Silently dropping supported Fastify-specific behavior for existing + apps that use `api/src/server.{ts,js}`, `configureFastify`, + `configureApiServer`, or direct Fastify plugin registration ## Open Questions diff --git a/docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md b/docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md index a0a735b259..2344fdafee 100644 --- a/docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md +++ b/docs/implementation-plans/universal-deploy-phase-4-detailed-plan.md @@ -99,14 +99,19 @@ It also preserves the phase boundary: ### Primary Goals -- Make `cedar dev` expose one externally visible host/port for the app -- Route web and API traffic through one development dispatcher -- Execute Cedar backend handlers in a Vite-centric runtime +- Make `cedar dev` expose one externally visible host/port for the default app + runtime +- Route web and API traffic through one development dispatcher for the default + Cedar runtime path +- Execute Cedar-owned backend handlers in a Vite-centric runtime - Ensure backend source changes are reflected through a coherent dev invalidation model -- Make `cedar serve` run the Vite-built UD Node server entry +- 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 @@ -114,6 +119,8 @@ It also preserves the phase boundary: - Preserve existing auth and function behavior during the transition - Minimise app-level migration burden - Keep Phase 4 compatible with the later Phase 5 route-registration expansion +- Make the compatibility story for `api/src/server.{ts,js}`, + `configureFastify`, and custom Fastify plugins explicit ## Non-Goals @@ -127,14 +134,20 @@ Phase 4 should explicitly not try to do all of the following: - 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 and the refined integration plan, the -baseline is: +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 @@ -142,15 +155,48 @@ baseline is: - 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. +pieces into a coherent dev runtime while preserving a compatibility path for +apps that depend on Fastify-specific server customisation. + +## Codebase Alignment Notes + +The current codebase already supports the main direction of this phase: + +- temporary UD scaffolding exists specifically to be removed in Phase 4 +- a shared aggregate Cedar dispatcher already exists and is intended to be used + by both the temporary server path and the future Vite virtual module path +- the CLI already marks the current UD serve path as transitional +- the current dev model is still clearly split between web and API processes + +At the same time, the codebase also makes two important constraints visible: + +1. The current aggregate UD dispatcher is still narrower than the final Phase 4 + target. It already handles Cedar-owned API surfaces such as GraphQL and + filesystem-discovered functions, but it should not be treated as proof that + all Fastify-based customisation has already been subsumed by the fetch-native + runtime. +2. Cedar currently exposes a real Fastify customisation surface. That means + Phase 4 cannot be treated as a blanket removal of Fastify from every app + runtime path without breaking supported user setups. + +These constraints shape the recommended implementation approach for this phase: +the unified Vite-centric runtime becomes the default path for standard Cedar +apps, while custom-server apps remain on an explicit compatibility lane until a +later migration path exists. ## Architectural Target for Phase 4 ### High-Level Shape -After Phase 4, the development architecture should look like this: +After Phase 4, the default development architecture should look like this: - one Vite-hosted dev server is externally visible - that dev server owns the request entrypoint @@ -163,6 +209,12 @@ After Phase 4, the development architecture should look like this: - installs `node()` from `@universal-deploy/node/vite` - emits a self-contained Node server entry for `cedar serve` +For apps with custom Fastify setup, Phase 4 should preserve a compatibility +lane rather than forcing them onto the default unified runtime immediately. +Those apps may continue to use a custom-server path until Cedar provides a +framework-agnostic replacement for the Fastify-specific extension points they +depend on. + ### Conceptual Request Flow in Dev The intended request flow is: @@ -241,6 +293,17 @@ build machinery without implying Cedar HTML SSR. App authors should not need to rewrite routes, functions, GraphQL handlers, or auth setup just to adopt Phase 4. +### 7. Preserve a Compatibility Lane for Custom Fastify Apps + +Apps that use `api/src/server.{ts,js}`, `configureApiServer`, +`configureFastify`, or direct `server.register(...)` Fastify plugin setup are +using a supported Cedar extension path today. Phase 4 should not silently +bypass or ignore those customisations. + +Instead, the default unified runtime should apply to standard Cedar apps, while +custom-server apps remain on an explicit compatibility lane until Cedar offers a +clear migration path to framework-agnostic extension points. + ## Proposed Runtime Architecture ## 1. Dev Runtime Composition @@ -275,12 +338,20 @@ Responsibilities: - execute GraphQL - execute auth endpoints - execute filesystem-discovered functions -- execute any other fetch-native backend entries included in the aggregate - dispatcher +- 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 @@ -430,7 +501,7 @@ An aggregate entry: ## 6. `cedarUniversalDeployPlugin()` Responsibilities in Phase 4 The plugin introduced in this phase should do exactly the minimum needed for a -working system. +working system on the default Cedar runtime path. ### Required Responsibilities @@ -470,6 +541,45 @@ This is a Vite server build concern, not a Cedar HTML SSR concern. Any implementation notes, config names, comments, and docs should repeatedly make this clear to avoid future confusion. +## Runtime Lanes in Phase 4 + +Phase 4 should explicitly support two runtime lanes. + +### Lane A: Default Unified Runtime + +This is the primary Phase 4 target for standard Cedar apps: + +- one visible Vite-hosted dev port +- one Cedar dev request dispatcher +- Cedar-owned backend execution through the aggregate fetch-native runtime +- API server Vite build integrated with `cedarUniversalDeployPlugin()` +- `cedar serve` running the Vite-built UD Node server entry + +### Lane B: Custom Fastify Compatibility Runtime + +This lane exists for apps that depend on Cedar's current Fastify-specific server +extension points, including: + +- `api/src/server.{ts,js}` +- `configureApiServer` +- `configureFastify` +- direct `server.register(...)` plugin setup +- custom Fastify routes, hooks, decorators, parsers, or reply/request behavior + +For these apps, Phase 4 should preserve a supported compatibility path rather +than forcing immediate migration. + +### Runtime Selection Rule + +The implementation should treat the presence of a custom server path as a +meaningful runtime distinction. If an app is using a custom server entry or +Fastify-specific setup, Cedar should either: + +- keep that app on the compatibility lane automatically, or +- fail clearly with guidance rather than silently dropping custom behaviour + +Silent partial compatibility is the worst outcome here. + ## Workstreams ## Workstream 1: Inventory and Stabilise Existing Dev Entry Logic @@ -488,6 +598,11 @@ can replace the split runtime without regressing unrelated behavior. - 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 @@ -536,7 +651,8 @@ startup path. ### Objective -Create the aggregate fetch-native backend runtime that the dispatcher will call. +Create the aggregate fetch-native backend runtime that the dispatcher will call +for the default Cedar runtime path. ### Tasks @@ -548,6 +664,9 @@ Create the aggregate fetch-native backend runtime that the dispatcher will call. - 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 @@ -565,7 +684,7 @@ A single backend fetch dispatcher that can answer all Cedar API requests in dev. ### Objective Mount Cedar backend handling into the Vite dev server so one visible host serves -the whole app. +the whole app on the default runtime lane. ### Tasks @@ -575,6 +694,8 @@ the whole app. - 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 @@ -670,16 +791,20 @@ Make `cedar serve` run the Vite-built Node server entry. ### Objective -Make the new runtime feel intentional rather than transitional. +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 -- remove or reduce references to separate web/API dev ports in normal output +- update CLI startup messaging to show one visible port for the default runtime +- remove or reduce references to separate web/API dev ports in normal output for + standard apps - update any generated URLs, docs, or help text that assume proxying - ensure error messages mention the unified host where appropriate - ensure debugging output still makes it clear whether a request was handled by Vite web logic or Cedar backend logic +- add explicit messaging for custom-server apps so users understand when Cedar + is using the compatibility lane instead of the default unified runtime ### Deliverable @@ -743,7 +868,7 @@ Test the request dispatcher in isolation. ## 2. Integration Testing for Dev Runtime -Test the unified dev host end-to-end. +Test the unified dev host end-to-end for the default runtime lane. ### Cases @@ -758,7 +883,7 @@ Test the unified dev host end-to-end. ## 3. Serve-Path Testing -Test the Vite-built Node server output. +Test the Vite-built Node server output for the default runtime lane. ### Cases @@ -779,6 +904,9 @@ Focus on areas most likely to break: - 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 @@ -889,6 +1017,24 @@ server builds with Cedar HTML SSR. - use precise naming in config and comments - avoid ambiguous labels like "SSR build" without qualification +## Risk 7: Custom Fastify Behaviour Is Silently Lost + +### Impact + +Apps that rely on `api/src/server.{ts,js}`, `configureFastify`, +`configureApiServer`, or direct Fastify plugin registration appear to start, but +some custom routes, hooks, parsers, decorators, or request/reply behaviour stop +working. + +### Mitigation + +- preserve an explicit compatibility lane for custom-server apps +- detect custom-server usage and branch intentionally +- never silently route custom-server apps through the default unified runtime if + that would drop supported behaviour +- document the boundary between Cedar-owned fetch-native runtime support and + Fastify-specific compatibility support + ## Open Design Questions to Resolve During Implementation These do not block writing the plan, but they should be resolved early in @@ -910,54 +1056,51 @@ implementation: behind the unified host? 7. What is the cleanest migration path for any existing CLI flags or docs that expose separate dev ports? +8. What is the exact runtime-selection rule for deciding when an app stays on + the custom Fastify compatibility lane? +9. Should custom-server apps keep the current split dev model in Phase 4, or is + there a safe compatibility wrapper that still preserves Fastify behaviour? +10. Which current Fastify extension points need a future framework-agnostic + replacement, and which should remain explicitly serverful-only? ## Exit Criteria for Phase 4 Phase 4 should be considered complete when all of the following are true: -- `cedar dev` exposes one externally visible host/port for normal development -- GraphQL requests work directly against that host -- auth requests work directly against that host -- function requests work directly against that host -- browser app loading and HMR still work +- `cedar dev` exposes one externally visible host/port for the default runtime + path +- GraphQL requests work directly against that host on the default runtime path +- auth requests work directly against that host on the default runtime path +- function requests work directly against that host on the default runtime path +- browser app loading and HMR still work on the default runtime path - backend code changes are reflected without manual restart in normal workflows + on the default runtime path - the API server Vite build includes `cedarUniversalDeployPlugin()` - the API server Vite build includes `node()` from `@universal-deploy/node/vite` -- `cedar serve` runs the Vite-built Node server entry +- `cedar serve` runs the Vite-built Node server entry for the default runtime + path - Cedar no longer depends on a separately exposed backend port for the standard dev experience +- custom-server apps still have a documented and supported compatibility path - the implementation does not require Cedar HTML SSR/RSC work to be complete ## Deliverables Phase 4 should produce the following concrete outputs: -- unified one-port dev runtime +- unified one-port dev runtime for the default Cedar runtime lane - internal dev request dispatcher - aggregate Cedar API fetch dispatcher for dev - backend invalidation/reload behavior integrated with the Vite-centric runtime - initial `cedarUniversalDeployPlugin()` in `@cedarjs/vite` - `@cedarjs/api-server` peer dependency declared by `@cedarjs/vite` - API server Vite build wired with `node()` from `@universal-deploy/node/vite` -- `cedar serve` updated to run the built Node server entry -- updated docs and CLI messaging reflecting the unified runtime - -## Recommended Scope Boundary for the PR Series - -This phase is large enough that it should likely land as a sequence of focused -PRs rather than one giant change. - -A sensible split is: - -1. internal dispatcher contract and aggregate backend runtime -2. Vite dev integration for one-port handling -3. backend invalidation/watch behavior -4. `cedarUniversalDeployPlugin()` introduction -5. `@universal-deploy/node` serve-path integration -6. docs, CLI messaging, and cleanup - -That keeps the architecture moving forward while preserving reviewability. +- `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 @@ -965,8 +1108,17 @@ 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, while `cedar serve` moves onto the -UD Node build output. If that is achieved with an aggregate API entry and +single-host and architecturally Vite-centric on the default runtime lane, while +`cedar serve` moves onto the UD Node build output for that same lane. If that is +achieved with an aggregate API entry and conservative backend invalidation, +Phase 4 is successful. + +That success condition should not require Cedar to immediately eliminate the +custom Fastify server path. Existing apps that depend on `api/src/server.{ts,js}` +or Fastify-specific plugin setup should remain supported through an explicit +compatibility lane. Phase 5 and later work can then build on a stable default +runtime foundation while separately addressing longer-term migration away from +Fastify-specific extension points where appropriate. conservative backend invalidation, Phase 4 is successful. Phase 5 can then build on a stable runtime foundation to formalise per-route UD From ef0c9a0c5b764ef1833693bcb75c453701f52788 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 14:13:17 +0200 Subject: [PATCH 12/14] Also implement phase 4 --- packages/api-server/dist.test.ts | 3 +- packages/api-server/package.json | 14 +- packages/api-server/src/createUDServer.ts | 53 ----- packages/api-server/src/udBin.ts | 36 ---- packages/api-server/src/udCLIConfig.ts | 50 ----- packages/api-server/src/udDispatcher.ts | 25 ++- .../cli/src/commands/build/buildHandler.ts | 12 ++ packages/cli/src/commands/dev/devHandler.ts | 83 +++----- packages/cli/src/commands/serve.ts | 67 +++++-- packages/vite/package.json | 12 ++ packages/vite/src/buildUDApiServer.ts | 94 +++++++++ packages/vite/src/index.ts | 8 +- .../vite-plugin-cedar-dev-dispatcher.ts | 181 ++++++++++++++++++ .../vite-plugin-cedar-universal-deploy.ts | 56 ++++++ yarn.lock | 23 +++ 15 files changed, 484 insertions(+), 233 deletions(-) delete mode 100644 packages/api-server/src/createUDServer.ts delete mode 100644 packages/api-server/src/udBin.ts delete mode 100644 packages/api-server/src/udCLIConfig.ts create mode 100644 packages/vite/src/buildUDApiServer.ts create mode 100644 packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts create mode 100644 packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts diff --git a/packages/api-server/dist.test.ts b/packages/api-server/dist.test.ts index 5cfab6e16d..55d2102f0a 100644 --- a/packages/api-server/dist.test.ts +++ b/packages/api-server/dist.test.ts @@ -11,10 +11,9 @@ describe('dist', () => { expect(fs.existsSync(path.join(distPath, '__tests__'))).toEqual(false) }) - it('ships seven bins', () => { + it('ships six bins', () => { expect(packageConfig.bin).toMatchInlineSnapshot(` { - "cedar-ud-server": "./dist/udBin.js", "cedarjs-api-server-watch": "./dist/watch.js", "cedarjs-log-formatter": "./dist/logFormatter/bin.js", "cedarjs-server": "./dist/bin.js", diff --git a/packages/api-server/package.json b/packages/api-server/package.json index 3fe419b64f..7966a26e23 100644 --- a/packages/api-server/package.json +++ b/packages/api-server/package.json @@ -79,18 +79,6 @@ "default": "./dist/udDispatcher.js" } }, - "./udServer": { - "import": { - "types": "./dist/createUDServer.d.ts", - "default": "./dist/createUDServer.js" - } - }, - "./udCLIConfig": { - "import": { - "types": "./dist/udCLIConfig.d.ts", - "default": "./dist/udCLIConfig.js" - } - }, "./udFetchable": { "import": { "types": "./dist/udFetchable.d.ts", @@ -111,7 +99,6 @@ "main": "./dist/createServer.js", "types": "./dist/createServer.d.ts", "bin": { - "cedar-ud-server": "./dist/udBin.js", "cedarjs-api-server-watch": "./dist/watch.js", "cedarjs-log-formatter": "./dist/logFormatter/bin.js", "cedarjs-server": "./dist/bin.js", @@ -143,6 +130,7 @@ "@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", diff --git a/packages/api-server/src/createUDServer.ts b/packages/api-server/src/createUDServer.ts deleted file mode 100644 index 8366fc1f34..0000000000 --- a/packages/api-server/src/createUDServer.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { addEntry } from '@universal-deploy/store' -import { serve } from 'srvx' -import type { Server } from 'srvx' - -import type { CedarDispatcherOptions } from './udDispatcher.js' -import { buildCedarDispatcher } from './udDispatcher.js' - -export interface CreateUDServerOptions extends CedarDispatcherOptions { - port?: number - host?: string -} - -// TODO Phase 4 — remove this function. It is temporary scaffolding that -// stands in for `@universal-deploy/node` while Cedar's API is built with -// Babel/esbuild rather than Vite. Once Phase 4 moves the API to a Vite build -// and wires in `node()` from `@universal-deploy/node/vite`, `cedar serve` -// will run the Vite-built server entry directly and this function has no -// remaining purpose. See the Phase 3 "Temporary scaffolding" section in -// docs/implementation-plans/universal-deploy-integration-plan-refined.md -/** - * Creates a WinterTC-compatible HTTP server using srvx that serves Cedar API - * functions discovered in `api/dist/functions/`. Function discovery and - * routing are delegated to buildCedarDispatcher. Each discovered function is - * also registered with the @universal-deploy/store via addEntry() for UD - * tooling introspection. - */ -export async function createUDServer( - options?: CreateUDServerOptions, -): Promise { - const port = options?.port ?? 8911 - const host = options?.host - - const { fetchable, registrations } = await buildCedarDispatcher({ - apiRootPath: options?.apiRootPath, - discoverFunctionsGlob: options?.discoverFunctionsGlob, - }) - - for (const registration of registrations) { - addEntry(registration) - } - - const server = serve({ - port, - hostname: host, - fetch(request: Request): Promise { - return Promise.resolve(fetchable.fetch(request)) - }, - }) - - await server.ready() - - return server -} diff --git a/packages/api-server/src/udBin.ts b/packages/api-server/src/udBin.ts deleted file mode 100644 index f19089e715..0000000000 --- a/packages/api-server/src/udBin.ts +++ /dev/null @@ -1,36 +0,0 @@ -import path from 'node:path' - -import { config } from 'dotenv-defaults' -import { hideBin } from 'yargs/helpers' -import yargs from 'yargs/yargs' - -import { getPaths } from '@cedarjs/project-config' - -import { description, builder, handler } from './udCLIConfig.js' - -if (!process.env.CEDAR_ENV_FILES_LOADED) { - config({ - path: path.join(getPaths().base, '.env'), - defaults: path.join(getPaths().base, '.env.defaults'), - multiline: true, - }) - - process.env.CEDAR_ENV_FILES_LOADED = 'true' -} - -process.env.NODE_ENV ??= 'production' - -yargs(hideBin(process.argv)) - .scriptName('cedar-ud-server') - .strict() - .alias('h', 'help') - .alias('v', 'version') - .command( - '$0', - description, - // @ts-expect-error The yargs types aren't very good; it's ok for builder to - // be a function - builder, - handler, - ) - .parse() diff --git a/packages/api-server/src/udCLIConfig.ts b/packages/api-server/src/udCLIConfig.ts deleted file mode 100644 index 8361704c65..0000000000 --- a/packages/api-server/src/udCLIConfig.ts +++ /dev/null @@ -1,50 +0,0 @@ -import ansis from 'ansis' -import type { Argv } from 'yargs' - -type UDParsedOptions = { - port?: number - host?: string - apiRootPath?: string -} - -export const description = - 'Start a Universal Deploy server for serving the Cedar API' - -export function builder(yargs: Argv) { - yargs.options({ - port: { - description: 'The port to listen at', - type: 'number', - alias: 'p', - default: 8911, - }, - host: { - description: - 'The host to listen at. Note that you most likely want this to be ' + - "'0.0.0.0' in production", - type: 'string', - }, - apiRootPath: { - description: 'Root path where your api functions are served', - type: 'string', - alias: ['api-root-path', 'rootPath', 'root-path'], - default: '/', - }, - }) -} - -export async function handler(options: UDParsedOptions) { - const timeStart = Date.now() - - console.log(ansis.dim.italic('Starting Universal Deploy Server...')) - - const { createUDServer } = await import('./createUDServer.js') - - await createUDServer({ - port: options.port, - host: options.host, - apiRootPath: options.apiRootPath, - }) - - console.log(ansis.dim.italic('Took ' + (Date.now() - timeStart) + ' ms')) -} diff --git a/packages/api-server/src/udDispatcher.ts b/packages/api-server/src/udDispatcher.ts index 23631b0333..dae60fbafd 100644 --- a/packages/api-server/src/udDispatcher.ts +++ b/packages/api-server/src/udDispatcher.ts @@ -55,17 +55,22 @@ function normalizeApiRootPath(rootPath: string): string { return normalized } -// TODO Phase 4 — the runtime function-discovery approach used here (scanning -// `api/dist/functions/` with fast-glob at startup) is temporary scaffolding -// for the period when Cedar's API is built with Babel/esbuild rather than -// Vite. Once Phase 4 moves the API to a Vite build, functions are bundled -// statically at build time and runtime discovery is no longer needed. At that -// point this function can be deleted (or retained only for a deliberate -// non-Vite standalone-serve mode). See the Phase 3 "Temporary scaffolding" -// section in docs/implementation-plans/universal-deploy-integration-plan-refined.md +// NOTE: The runtime function-discovery approach used here (scanning +// `api/dist/functions/` with fast-glob at startup) is now used by two +// callers: +// 1. `cedarUniversalDeployPlugin` — the Phase 4 Vite plugin that registers +// `virtual:cedar-api` in the API server Vite build +// 2. `cedarDevDispatcherPlugin` — the Phase 4 Vite dev middleware that +// serves API requests from the unified Vite dev host +// +// TODO: Once the API server is fully Vite-built and Phase 5 introduces +// per-route static entry registration, the fast-glob discovery path can be +// removed in favour of statically-known routes. Until then, this function +// remains the shared aggregate dispatcher for both callers. /** - * Shared inner routing logic used by both `createUDServer` (which wraps it in - * srvx) and the Vite plugin's `virtual:cedar-api` module. + * Shared aggregate Cedar API dispatcher used by + * `cedarUniversalDeployPlugin` (via `virtual:cedar-api`) and + * `cedarDevDispatcherPlugin` (the unified Vite dev host middleware). * * Discovers Cedar API functions in `api/dist/functions/`, builds a rou3 router * and a map of route names to Fetchables, then returns a single Fetchable that diff --git a/packages/cli/src/commands/build/buildHandler.ts b/packages/cli/src/commands/build/buildHandler.ts index 35f5445150..709fbe89e8 100644 --- a/packages/cli/src/commands/build/buildHandler.ts +++ b/packages/cli/src/commands/build/buildHandler.ts @@ -20,6 +20,7 @@ import { loadAndValidateSdls } from '@cedarjs/internal/dist/validateSchema' import { detectPrerenderRoutes } from '@cedarjs/prerender/detection' import { type Paths } from '@cedarjs/project-config' import { timedTelemetry } from '@cedarjs/telemetry' +import { buildUDApiServer } from '@cedarjs/vite/buildUDApiServer' import { generatePrismaCommand } from '../../lib/generatePrismaClient.js' // @ts-expect-error - Types not available for JS files @@ -233,6 +234,11 @@ export const handler = async ({ title: 'Verifying graphql schema...', task: loadAndValidateSdls, }, + // The API build has two sequential steps: + // 1. esbuild compiles api/src/** → api/dist/ (functions, services, etc.) + // 2. Vite wraps api/dist/functions/ into a self-contained UD Node server + // entry at api/dist/ud/index.js for `cedar serve api` + // Step 2 depends on step 1 having completed. workspace.includes('api') && { title: 'Building API...', task: async () => { @@ -247,6 +253,12 @@ export const handler = async ({ } }, }, + workspace.includes('api') && { + title: 'Bundling API server entry (Universal Deploy)...', + task: async () => { + await buildUDApiServer({ verbose }) + }, + }, workspace.includes('web') && { title: 'Building Web...', task: async () => { diff --git a/packages/cli/src/commands/dev/devHandler.ts b/packages/cli/src/commands/dev/devHandler.ts index 975681b6ab..ced14f35db 100644 --- a/packages/cli/src/commands/dev/devHandler.ts +++ b/packages/cli/src/commands/dev/devHandler.ts @@ -46,23 +46,12 @@ export const handler = async ({ const serverFile = serverFileExists() - // Starting values of ports from config (cedar.toml or redwood.toml) - const apiPreferredPort = parseInt(String(getConfig().api.port)) - - let webPreferredPort: number | undefined = parseInt( - String(getConfig().web.port), - ) - - // Assume we can have the ports we want - let apiAvailablePort = apiPreferredPort - let apiPortChangeNeeded = false - let webAvailablePort = webPreferredPort - let webPortChangeNeeded = false - - // Check api port, unless there's a serverFile. If there is a serverFile, we - // don't know what port will end up being used in the end. It's up to the - // author of the server file to decide and handle that - if (workspace.includes('api') && !serverFile) { + // For the custom-server lane (apps with api/src/server.{ts,js}), we still + // need to find a free API port since the server file controls its own + // listening and we don't know what port it will use. + let apiAvailablePort: number | undefined + if (workspace.includes('api') && serverFile) { + const apiPreferredPort = parseInt(String(getConfig().api.port)) apiAvailablePort = await getFreePort(apiPreferredPort) if (apiAvailablePort === -1) { @@ -70,10 +59,14 @@ export const handler = async ({ message: `Could not determine a free port for the api server`, }) } - - apiPortChangeNeeded = apiAvailablePort !== apiPreferredPort } + let webPreferredPort: number | undefined = parseInt( + String(getConfig().web.port), + ) + let webAvailablePort = webPreferredPort + let webPortChangeNeeded = false + // Check web port if (workspace.includes('web')) { // Extract any ports the user forwarded to the dev server and prefer that @@ -87,10 +80,7 @@ export const handler = async ({ webPreferredPort = port ? parseInt(port, 10) : undefined } - webAvailablePort = await getFreePort(webPreferredPort, [ - apiPreferredPort, - apiAvailablePort, - ]) + webAvailablePort = await getFreePort(webPreferredPort) if (webAvailablePort === -1) { exitWithError(undefined, { @@ -102,19 +92,16 @@ export const handler = async ({ } // Check for port conflict and exit with message if found - if (apiPortChangeNeeded || webPortChangeNeeded) { + if (webPortChangeNeeded) { const message = [ - 'The currently configured ports for the development server are', - 'unavailable. Suggested changes to your ports, which can be changed in', - 'cedar.toml (or redwood.toml), are:\n', - apiPortChangeNeeded && ` - API to use port ${apiAvailablePort} instead`, - apiPortChangeNeeded && 'of your currently configured', - apiPortChangeNeeded && `${apiPreferredPort}\n`, - webPortChangeNeeded && ` - Web to use port ${webAvailablePort} instead`, - webPortChangeNeeded && 'of your currently configured', - webPortChangeNeeded && `${webPreferredPort}\n`, - '\nCannot run the development server until your configured ports are', - 'changed or become available.', + 'The currently configured port for the development server is', + 'unavailable. Suggested change to your port, which can be changed in', + 'cedar.toml (or redwood.toml):\n', + ` - Web to use port ${webAvailablePort} instead`, + 'of your currently configured', + `${webPreferredPort}\n`, + '\nCannot run the development server until your configured port is', + 'changed or becomes available.', ] .filter(Boolean) .join(' ') @@ -130,23 +117,9 @@ export const handler = async ({ errorTelemetry(process.argv, `Error generating prisma client: ${message}`) console.error(c.error(message)) } - - // Again, if a server file is configured, we don't know what port it'll end - // up using - if (!serverFile) { - try { - await shutdownPort(apiAvailablePort) - } catch (e) { - const message = getErrorMessage(e) - errorTelemetry(process.argv, `Error shutting down "api": ${message}`) - console.error( - `Error whilst shutting down "api" port: ${c.error(message)}`, - ) - } - } } - if (workspace.includes('web')) { + if (workspace.includes('web') && webAvailablePort !== undefined) { try { await shutdownPort(webAvailablePort) } catch (e) { @@ -209,7 +182,13 @@ export const handler = async ({ runWhen?: () => boolean })[] = [] - if (workspace.includes('api')) { + // For the custom-server compatibility lane (apps with api/src/server.{ts,js}), + // we still start a separate API process because those apps use Fastify-specific + // extension points (configureApiServer, direct plugin registration, etc.) that + // are not yet covered by the unified Vite dev runtime. For standard Cedar apps + // the cedarDevDispatcherPlugin handles API requests directly inside the Vite + // dev server — no separate API process is needed. + if (workspace.includes('api') && serverFile) { jobs.push({ name: 'api', command: [ @@ -218,7 +197,7 @@ export const handler = async ({ ` --watch "${cedarConfigPath}"`, ` --exec "yarn ${serverWatchCommand}`, ` --port ${apiAvailablePort}`, - ` ${getApiDebugFlag(apiDebugPort, apiAvailablePort)}`, + ` ${getApiDebugFlag(apiDebugPort, apiAvailablePort!)}`, ' | rw-log-formatter"', ] .join(' ') diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 79e13dfc34..bf4585003b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1,5 +1,6 @@ +import { fork } from 'node:child_process' import fs from 'node:fs' -import path from 'path' +import path from 'node:path' import { terminalLink } from 'termi-link' import type { Argv } from 'yargs' @@ -75,16 +76,11 @@ export const builder = async (yargs: Argv) => { apiServerCLIConfig.builder(yargs) } return yargs.option('ud', { - // TODO(Phase 4): remove this flag. It is temporary scaffolding that - // bridges to createUDServer while Cedar's API is not yet Vite-built. - // Phase 4 wires in @universal-deploy/node/vite and makes UD serving - // the default, at which point this flag has no remaining purpose. - // See the Phase 3 "Temporary scaffolding" section in - // docs/implementation-plans/universal-deploy-integration-plan-refined.md + // UD serving is the default. Pass --no-ud to use the legacy Fastify server instead. description: - 'Use the Universal Deploy server (srvx) instead of Fastify', + 'Use the Universal Deploy server (srvx). This is the default; pass --no-ud to use Fastify instead.', type: 'boolean', - default: false, + default: true, }) }, handler: async (argv: ServeArgv) => { @@ -97,13 +93,54 @@ export const builder = async (yargs: Argv) => { }) if (argv.ud) { - const { handler: udHandler } = - await import('@cedarjs/api-server/udCLIConfig') - await udHandler({ - port: argv.port, - host: argv.host, - apiRootPath: argv.apiRootPath, + // 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 } diff --git a/packages/vite/package.json b/packages/vite/package.json index e370c2786b..7b9bfe3508 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -33,6 +33,9 @@ "require": "./dist/cjs/buildFeServer.js", "import": "./dist/buildFeServer.js" }, + "./buildUDApiServer": { + "import": "./dist/buildUDApiServer.js" + }, "./build": { "import": "./dist/build/build.js", "default": "./dist/cjs/build/build.js" @@ -73,6 +76,7 @@ "@cedarjs/testing": "workspace:*", "@cedarjs/web": "workspace:*", "@swc/core": "1.15.24", + "@universal-deploy/node": "^0.1.6", "@vitejs/plugin-react": "4.7.0", "@whatwg-node/fetch": "0.10.13", "@whatwg-node/server": "0.10.18", @@ -112,6 +116,14 @@ "typescript": "5.9.3", "vitest": "3.2.4" }, + "peerDependencies": { + "@cedarjs/api-server": "workspace:*" + }, + "peerDependenciesMeta": { + "@cedarjs/api-server": { + "optional": false + } + }, "engines": { "node": ">=24" }, diff --git a/packages/vite/src/buildUDApiServer.ts b/packages/vite/src/buildUDApiServer.ts new file mode 100644 index 0000000000..187becddf3 --- /dev/null +++ b/packages/vite/src/buildUDApiServer.ts @@ -0,0 +1,94 @@ +import path from 'node:path' + +import { getPaths } from '@cedarjs/project-config' + +export interface BuildUDApiServerOptions { + verbose?: boolean + apiRootPath?: string +} + +/** + * Builds the API server Universal Deploy Node entry using Vite. + * + * Runs a Vite server build that: + * 1. Installs `cedarUniversalDeployPlugin()` to register `virtual:cedar-api` + * and resolve `virtual:ud:catch-all` → Cedar's aggregate fetch dispatcher + * 2. Installs `node()` from `@universal-deploy/node/vite` to emit a + * self-contained Node server entry at `api/dist/ud/index.js` + * + * The emitted entry can be launched directly: node api/dist/ud/index.js + * That is what `cedar serve api` does. + * + * NOTE: The Vite "ssr" build target used here is a server-side module build + * concern — it is NOT related to Cedar HTML SSR or RSC. "ssr" simply means + * Vite produces a Node-compatible bundle rather than a browser bundle. + */ +export const buildUDApiServer = async ({ + verbose = false, + apiRootPath, +}: BuildUDApiServerOptions = {}) => { + // Dynamic imports so that vite and the UD plugins are only loaded when + // this function is actually called (keeps cold-start cost of importing + // @cedarjs/vite low for callers that only need the web build path). + const { build } = await import('vite') + const { cedarUniversalDeployPlugin } = + await import('./plugins/vite-plugin-cedar-universal-deploy.js') + const { node } = await import('@universal-deploy/node/vite') + + const rwPaths = getPaths() + + // The UD Node server entry is placed under api/dist/ud/ so it does not + // collide with the existing esbuild output under api/dist/. + const outDir = path.join(rwPaths.api.dist, 'ud') + + await build({ + // No configFile — we configure everything inline so this build is + // self-contained and does not require a vite.config.ts in api/. + configFile: false, + envFile: false, + logLevel: verbose ? 'info' : 'warn', + + plugins: [ + // Registers virtual:cedar-api with @universal-deploy/store and resolves + // virtual:ud:catch-all → virtual:cedar-api → Cedar's aggregate fetchable. + cedarUniversalDeployPlugin({ apiRootPath }), + + // Emits a self-contained Node server entry (api/dist/ud/index.js) that + // imports virtual:ud:catch-all and starts an srvx HTTP server. + // This is a Vite server-build concern, not Cedar HTML SSR. + ...node(), + ], + + // The ssr environment is the Vite mechanism for server-side builds. + // Reminder: "ssr" here means "server-side module execution", NOT + // Cedar HTML SSR / streaming / RSC. + environments: { + ssr: { + build: { + outDir, + // Ensure @universal-deploy/node is bundled into the output so the + // emitted entry is self-contained. + rollupOptions: { + output: { + // Produce a single-file entry where possible; srvx chunks are + // split by the node() plugin automatically. + entryFileNames: '[name].js', + }, + }, + }, + resolve: { + // Do not externalise @universal-deploy/node — the node() plugin + // requires it to be bundled into the server entry. + noExternal: ['@universal-deploy/node'], + }, + }, + }, + + build: { + // Write the server entry to api/dist/ud/ + outDir, + // This is a server (Node) build, not a browser build. + ssr: true, + }, + }) +} diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index 243287b4c9..8c20969d57 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -10,11 +10,11 @@ import { } from '@cedarjs/testing/web/vitest' import { cedarCellTransform } from './plugins/vite-plugin-cedar-cell.js' +import { cedarDevDispatcherPlugin } from './plugins/vite-plugin-cedar-dev-dispatcher.js' import { cedarEntryInjectionPlugin } from './plugins/vite-plugin-cedar-entry-injection.js' import { cedarHtmlEnvPlugin } from './plugins/vite-plugin-cedar-html-env.js' import { cedarNodePolyfills } from './plugins/vite-plugin-cedar-node-polyfills.js' import { cedarRemoveFromBundle } from './plugins/vite-plugin-cedar-remove-from-bundle.js' -import { cedarWaitForApiServer } from './plugins/vite-plugin-cedar-wait-for-api-server.js' import { cedarjsResolveCedarStyleImportsPlugin } from './plugins/vite-plugin-cedarjs-resolve-cedar-style-imports.js' import { cedarTransformJsAsJsx } from './plugins/vite-plugin-jsx-loader.js' import { cedarMergedConfig } from './plugins/vite-plugin-merged-config.js' @@ -32,6 +32,10 @@ export { cedarjsJobPathInjectorPlugin } from './plugins/vite-plugin-cedarjs-job- export { cedarTransformJsAsJsx } from './plugins/vite-plugin-jsx-loader.js' export { cedarMergedConfig } from './plugins/vite-plugin-merged-config.js' export { cedarSwapApolloProvider } from './plugins/vite-plugin-swap-apollo-provider.js' +export { cedarUniversalDeployPlugin } from './plugins/vite-plugin-cedar-universal-deploy.js' +export { cedarDevDispatcherPlugin } from './plugins/vite-plugin-cedar-dev-dispatcher.js' +/** @deprecated The default cedar() plugin array now uses cedarDevDispatcherPlugin. This export is kept for apps that reference cedarWaitForApiServer directly. */ +export { cedarWaitForApiServer } from './plugins/vite-plugin-cedar-wait-for-api-server.js' type PluginOptions = { mode?: string | undefined @@ -69,7 +73,7 @@ export function cedar({ mode }: PluginOptions = {}): PluginOption[] { mode === 'test' && cedarJsRouterImportTransformPlugin(), mode === 'test' && createAuthImportTransformPlugin(), mode === 'test' && autoImportsPlugin(), - cedarWaitForApiServer(), + cedarDevDispatcherPlugin(), cedarNodePolyfills(), cedarHtmlEnvPlugin(), cedarEntryInjectionPlugin(), diff --git a/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts b/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts new file mode 100644 index 0000000000..a1aae7766a --- /dev/null +++ b/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts @@ -0,0 +1,181 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' + +import type { Plugin, ViteDevServer } from 'vite' + +import { getConfig } from '@cedarjs/project-config' + +type Fetchable = { fetch(request: Request): Response | Promise } + +let cachedDispatcher: Fetchable | null = null +let buildPromise: Promise | null = null + +async function getDispatcher(): Promise { + if (cachedDispatcher !== null) { + return cachedDispatcher + } + + if (buildPromise !== null) { + return buildPromise + } + + buildPromise = (async () => { + const { buildCedarDispatcher } = + await import('@cedarjs/api-server/udDispatcher') + const { fetchable } = await buildCedarDispatcher() + cachedDispatcher = fetchable + buildPromise = null + return fetchable + })() + + return buildPromise +} + +function invalidateDispatcher() { + cachedDispatcher = null + buildPromise = null +} + +function isViteInternalRequest(url: string): boolean { + return ( + url.startsWith('/@') || + url.startsWith('/__vite') || + url.startsWith('/__hmr') || + url.includes('?import') || + url.includes('?t=') || + url.includes('?v=') + ) +} + +function isApiRequest(url: string): boolean { + const cedarConfig = getConfig() + const apiUrl = cedarConfig.web.apiUrl.replace(/\/$/, '') + const apiGqlUrl = cedarConfig.web.apiGraphQLUrl ?? apiUrl + '/graphql' + + return ( + url.startsWith(apiUrl) || + url === apiGqlUrl || + url.startsWith(apiGqlUrl + '/') || + url.startsWith(apiGqlUrl + '?') + ) +} + +async function nodeRequestToFetch(req: IncomingMessage): Promise { + const host = req.headers.host ?? 'localhost' + const url = `http://${host}${req.url ?? '/'}` + + const headers = new Headers() + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) { + continue + } + + if (Array.isArray(value)) { + for (const v of value) { + headers.append(key, v) + } + } else { + headers.set(key, value) + } + } + + const method = (req.method ?? 'GET').toUpperCase() + const hasBody = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) + + let body: Buffer | undefined + + if (hasBody) { + body = await new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => resolve(Buffer.concat(chunks))) + req.on('error', reject) + }) + } + + return new Request(url, { + method, + headers, + body: hasBody && body && body.length > 0 ? new Uint8Array(body) : undefined, + }) +} + +async function fetchResponseToNode( + fetchRes: Response, + res: ServerResponse, +): Promise { + res.statusCode = fetchRes.status + + fetchRes.headers.forEach((value, key) => { + res.setHeader(key, value) + }) + + const bodyBuffer = await fetchRes.arrayBuffer() + + if (bodyBuffer.byteLength > 0) { + res.end(Buffer.from(bodyBuffer)) + } else { + res.end() + } +} + +export function cedarDevDispatcherPlugin(): Plugin { + return { + name: 'cedar-dev-dispatcher', + apply: 'serve', + + configureServer(server: ViteDevServer) { + server.watcher.on('change', (filePath: string) => { + if (filePath.includes('/api/src/')) { + invalidateDispatcher() + } + }) + + server.middlewares.use( + async (req: IncomingMessage, res: ServerResponse, next: () => void) => { + const url = req.url ?? '/' + + if (isViteInternalRequest(url)) { + return next() + } + + if (!isApiRequest(url)) { + return next() + } + + try { + const dispatcher = await getDispatcher() + const fetchRequest = await nodeRequestToFetch(req) + const fetchResponse = await dispatcher.fetch(fetchRequest) + await fetchResponseToNode(fetchResponse, res) + } catch (err) { + console.error( + '[cedar-dev-dispatcher] Error handling API request:', + err, + ) + + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + } + + res.end( + JSON.stringify( + { + errors: [ + { + message: + err instanceof Error + ? err.message + : 'Internal Server Error', + }, + ], + }, + null, + 2, + ), + ) + } + }, + ) + }, + } +} diff --git a/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts b/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts new file mode 100644 index 0000000000..36df9bad1e --- /dev/null +++ b/packages/vite/src/plugins/vite-plugin-cedar-universal-deploy.ts @@ -0,0 +1,56 @@ +import { addEntry, catchAllEntry } from '@universal-deploy/store' +import type { Plugin } from 'vite' + +export interface CedarUniversalDeployPluginOptions { + apiRootPath?: string +} + +const VIRTUAL_CEDAR_API = 'virtual:cedar-api' +const RESOLVED_VIRTUAL_CEDAR_API = '\0virtual:cedar-api' + +export function cedarUniversalDeployPlugin( + options: CedarUniversalDeployPluginOptions = {}, +): Plugin { + const { apiRootPath } = options + + return { + name: 'cedar-universal-deploy', + apply: 'build', + + buildStart() { + addEntry({ + id: VIRTUAL_CEDAR_API, + route: '/**', + }) + }, + + resolveId(id) { + if (id === catchAllEntry) { + return RESOLVED_VIRTUAL_CEDAR_API + } + + if (id === VIRTUAL_CEDAR_API) { + return RESOLVED_VIRTUAL_CEDAR_API + } + + return undefined + }, + + load(id) { + if (id !== RESOLVED_VIRTUAL_CEDAR_API) { + return undefined + } + + const apiRootPathArg = + apiRootPath !== undefined + ? `{ apiRootPath: ${JSON.stringify(apiRootPath)} }` + : 'undefined' + + return ` +import { buildCedarDispatcher } from '@cedarjs/api-server/udDispatcher'; +const { fetchable } = await buildCedarDispatcher(${apiRootPathArg}); +export default fetchable; +` + }, + } +} diff --git a/yarn.lock b/yarn.lock index 3d38340d1e..457b046db8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2171,6 +2171,7 @@ __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" @@ -3766,6 +3767,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" @@ -3795,6 +3797,11 @@ __metadata: vitest: "npm:3.2.4" ws: "npm:8.20.0" yargs-parser: "npm:21.1.1" + peerDependencies: + "@cedarjs/api-server": "workspace:*" + peerDependenciesMeta: + "@cedarjs/api-server": + optional: false bin: rw-dev-fe: ./dist/devFeServer.js rw-serve-fe: ./dist/runFeServer.js @@ -11524,6 +11531,22 @@ __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" From 96532a923851f8486501a267a31ae6b5c0e88d6d Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 14:19:42 +0200 Subject: [PATCH 13/14] yarn.lock --- yarn.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 457b046db8..04be5d684b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2199,7 +2199,6 @@ __metadata: "@cedarjs/graphql-server": optional: true bin: - cedar-ud-server: ./dist/udBin.js cedarjs-api-server-watch: ./dist/watch.js cedarjs-log-formatter: ./dist/logFormatter/bin.js cedarjs-server: ./dist/bin.js From a731912229dfce156693531593bc8b67b0bee8c2 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 23 Apr 2026 14:24:37 +0200 Subject: [PATCH 14/14] review fixes --- .../vite-plugin-cedar-dev-dispatcher.ts | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts b/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts index a1aae7766a..40dac9de50 100644 --- a/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts +++ b/packages/vite/src/plugins/vite-plugin-cedar-dev-dispatcher.ts @@ -7,6 +7,10 @@ import { getConfig } from '@cedarjs/project-config' type Fetchable = { fetch(request: Request): Response | Promise } let cachedDispatcher: Fetchable | null = null +// Each invalidation increments this counter. The in-flight build closure +// captures the generation at start and checks it before writing +// cachedDispatcher, so a superseded build never overwrites a newer one. +let dispatcherGeneration = 0 let buildPromise: Promise | null = null async function getDispatcher(): Promise { @@ -18,12 +22,45 @@ async function getDispatcher(): Promise { return buildPromise } + // Capture the current generation so we can detect if we've been + // invalidated by the time the build finishes. + const generationAtStart = dispatcherGeneration + buildPromise = (async () => { + // Recompile api/src/ -> api/dist/ before loading the dispatcher, so the + // dispatcher always reads fresh build artifacts. We use rebuildApi when a + // build context already exists (incremental rebuild is faster), and fall + // back to a full buildApi on the very first run or after a clean. + try { + const { rebuildApi, buildApi } = + await import('@cedarjs/internal/dist/build/api') + try { + await rebuildApi() + } catch { + // rebuildApi can throw if there is no existing build context yet + // (e.g. first run). Fall back to a full build. + await buildApi() + } + } catch (err) { + console.warn( + '[cedar-dev-dispatcher] API compilation failed; serving with last-known-good dist:', + err, + ) + } + const { buildCedarDispatcher } = await import('@cedarjs/api-server/udDispatcher') const { fetchable } = await buildCedarDispatcher() - cachedDispatcher = fetchable - buildPromise = null + + // Only commit if we are still the current generation. If invalidate() was + // called while we were building, a newer build will be (or already is) + // in-flight and we must not overwrite cachedDispatcher with our stale + // result. + if (generationAtStart === dispatcherGeneration) { + cachedDispatcher = fetchable + buildPromise = null + } + return fetchable })() @@ -33,6 +70,8 @@ async function getDispatcher(): Promise { function invalidateDispatcher() { cachedDispatcher = null buildPromise = null + // Increment so any in-flight build can detect it has been superseded. + dispatcherGeneration++ } function isViteInternalRequest(url: string): boolean {