diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 48f639513..5bea01b95 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs' +import path from 'node:path' import { expect, test } from '@playwright/test' import { useFixture } from './fixture' import { waitForHydration } from './helper' @@ -30,6 +32,55 @@ test.describe('build', () => { page.getByRole('heading', { name: 'This is page "b"' }), ).toBeVisible() }) + + test('emits filtered route deployments', () => { + const outDir = path.join(f.root, 'dist/rsc') + const manifestSource = fs.readFileSync( + path.join(outDir, '__route_action_manifest.js'), + 'utf-8', + ) + const manifest: Record = JSON.parse( + manifestSource.slice('export default '.length), + ) + + for (const [route, includedResult, excludedResult] of [ + ['/a', 'ACTION_A_OK', 'ACTION_B_OK'], + ['/b', 'ACTION_B_OK', 'ACTION_A_OK'], + ] as const) { + const deploymentDir = path.join( + outDir, + 'deployments', + route.slice(1), + 'rsc', + ) + const registry = fs.readFileSync( + path.join(deploymentDir, '__server_references.js'), + 'utf-8', + ) + const referenceKeys = [...registry.matchAll(/^\s*"([^"]+)":/gm)].map( + (match) => match[1], + ) + expect(referenceKeys).toEqual( + manifest[route]!.map((actionId) => actionId.split('#')[0]), + ) + + const code = [ + fs.readFileSync(path.join(deploymentDir, 'handler.js'), 'utf-8'), + ...fs + .readdirSync(path.join(deploymentDir, 'assets')) + .filter((fileName) => fileName.endsWith('.js')) + .map((fileName) => + fs.readFileSync( + path.join(deploymentDir, 'assets', fileName), + 'utf-8', + ), + ), + ].join('\n') + expect(code).toContain(includedResult) + expect(code).not.toContain(excludedResult) + expect(code).not.toContain('virtual:vite-rsc/server-references') + } + }) }) test.describe('dev', () => { diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index 4d9dbec61..3b3671548 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -15,7 +15,7 @@ The example follows this sequence: | Production | `/a` middleware | `ACTION_A_OK:MIDDLEWARE_A` | `/b` | | Development | `/b` middleware | `ACTION_A_OK:MIDDLEWARE_B` | `/b` | -In production, a generated route-action manifest lets the RSC handler redispatch the action request through middleware for a page whose graph reaches the action. This example enables manifest routing only in production, so development stays on the current route. +In production, a generated route-action manifest lets the RSC dispatcher select a deployment whose filtered registry can load the action. The dispatcher also redispatches the request through middleware for a page whose graph reaches the action. This example enables manifest routing only in production, so development stays on the current route. ## Application graphs @@ -34,12 +34,36 @@ During the RSC build, the manifest plugin traverses each route graph and records After all environment builds finish, the plugin installs the mapping in the RSC output for runtime routing. +## Route deployments + +The final RSC build emits each discovered server-reference module as an explicit chunk entry. Once the client graph completes the route-action relation, a post-build step writes two deployment directories from that canonical output: + +```text +dist/rsc/deployments/a + -> rsc request handler and shared dependencies + -> rsc filtered server-reference registry + -> rsc action A entry and dependencies + -> shared SSR output + +dist/rsc/deployments/b + -> rsc request handler and shared dependencies + -> rsc filtered server-reference registry + -> rsc action B entry and dependencies + -> shared SSR output +``` + +The deployment handlers use the low-level `@vitejs/plugin-rsc/react/rsc` runtime with `setRequireModule()`. Each registry imports only the emitted server-reference entries selected for that route. This packaging step copies existing chunks and does not run a second bundle. + +The canonical `index.js` is a small dispatcher. It routes normal requests by pathname and explicit-ID action requests by the generated manifest, then loads the selected deployment handler. The copied handlers have independent module-local loaders, so explicit `loadServerAction()` calls use the selected filtered registry even though the example runs both deployments in one process. + ## Request redispatch -For the production scenario above, the RSC handler finds action A under `/a` in the manifest and creates a new action request for `/a`. That request re-enters `/a` middleware, so the action observes `MIDDLEWARE_A`. It also preserves `/b` as the render URL, so the response continues rendering page B. +For the production scenario above, the RSC dispatcher finds action A under `/a` in the manifest, creates a new action request for `/a`, and loads deployment A. That request enters `/a` middleware, so the action observes `MIDDLEWARE_A`. It also preserves `/b` as the render URL, so the response continues rendering page B. Development skips route-aware redispatch. The handler executes action A on `/b`, so the action observes `MIDDLEWARE_B`. ## Protocol scope For simplicity, route-aware redispatch covers only hydrated action calls that carry an explicit action ID. Progressive multipart form actions still use the baseline `decodeAction()` path without manifest routing. + +React's decoder module hook is process-global. Nested server references in action arguments and progressive actions therefore require process isolation or a request-aware decoder integration when multiple deployment handlers run in one process. They are outside this example's explicit-ID deployment proof. diff --git a/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts index 0cdad1aff..9cb754a5a 100644 --- a/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts +++ b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts @@ -12,9 +12,15 @@ const routes = { const ROUTE_ACTION_MANIFEST_ID = 'virtual:route-action-manifest' const ROUTE_ACTION_MANIFEST_FILE = '__route_action_manifest.js' +const SERVER_REFERENCES_ID = 'virtual:route-server-references' +const SERVER_REFERENCES_FILE = '__server_references.js' +const ROUTE_DEPLOYMENTS_ID = 'virtual:route-deployments' +const ROUTE_DEPLOYMENTS_FILE = '__route_deployments.js' export function routeActionManifestPlugin(): Plugin { let manager: RscPluginManager + const emittedServerReferences = new Map() + const serverReferenceChunks = new Map() const routeClientReferenceImportIds = new Map>() const routeServerReferenceIds = new Map>() let routeActionManifest: Record = {} @@ -24,8 +30,33 @@ export function routeActionManifestPlugin(): Plugin { configResolved(config) { manager = getPluginApi(config)!.manager }, + buildStart() { + if ( + manager.isScanBuild || + this.environment.mode !== 'build' || + this.environment.name !== 'rsc' + ) { + return + } + emittedServerReferences.clear() + for (const meta of manager.serverReferences.metaMap.values()) { + emittedServerReferences.set( + meta.referenceKey, + this.emitFile({ + type: 'chunk', + id: meta.importId, + name: `server-reference-${meta.referenceKey}`, + preserveSignature: 'strict', + }), + ) + } + }, resolveId(source) { - if (source === ROUTE_ACTION_MANIFEST_ID) { + if ( + source === ROUTE_ACTION_MANIFEST_ID || + source === SERVER_REFERENCES_ID || + source === ROUTE_DEPLOYMENTS_ID + ) { return this.environment.mode === 'build' ? { id: source, external: true } : '\0' + source @@ -35,11 +66,25 @@ export function routeActionManifestPlugin(): Plugin { if (id === '\0' + ROUTE_ACTION_MANIFEST_ID) { return 'export default null' } + if (id === '\0' + SERVER_REFERENCES_ID) { + return 'export default {}' + } + if (id === '\0' + ROUTE_DEPLOYMENTS_ID) { + const entry = JSON.stringify( + normalizePath(path.resolve('./src/framework/entry.rsc.tsx')), + ) + return `const load = () => import(${entry}); export default { '/a': load, '/b': load }` + } }, generateBundle() { if (manager.isScanBuild) return if (this.environment.name === 'rsc') { + serverReferenceChunks.clear() + for (const [referenceKey, emittedId] of emittedServerReferences) { + serverReferenceChunks.set(referenceKey, this.getFileName(emittedId)) + } + // Collect references reachable in each route's RSC graph. for (const [route, roots] of Object.entries(routes)) { const { clientReferenceImportIds, serverReferenceIds } = @@ -77,18 +122,24 @@ export function routeActionManifestPlugin(): Plugin { // Leave the virtual import external, then point it at an ESM sidecar // generated after the later client build. renderChunk(code, chunk) { - if (code.includes(ROUTE_ACTION_MANIFEST_ID)) { - let relativePath = path.posix.relative( - path.posix.dirname(chunk.fileName), - ROUTE_ACTION_MANIFEST_FILE, - ) - if (!relativePath.startsWith('.')) { - relativePath = './' + relativePath - } - return { - code: code.replaceAll(ROUTE_ACTION_MANIFEST_ID, relativePath), + const replacements = [ + [ROUTE_ACTION_MANIFEST_ID, ROUTE_ACTION_MANIFEST_FILE], + [SERVER_REFERENCES_ID, SERVER_REFERENCES_FILE], + [ROUTE_DEPLOYMENTS_ID, ROUTE_DEPLOYMENTS_FILE], + ] as const + for (const [id, fileName] of replacements) { + if (code.includes(id)) { + let relativePath = path.posix.relative( + path.posix.dirname(chunk.fileName), + fileName, + ) + if (!relativePath.startsWith('.')) { + relativePath = './' + relativePath + } + code = code.replaceAll(id, relativePath) } } + return { code } }, buildApp: { order: 'post', @@ -99,11 +150,108 @@ export function routeActionManifestPlugin(): Plugin { path.join(outDir, ROUTE_ACTION_MANIFEST_FILE), `export default ${JSON.stringify(routeActionManifest, null, 2)}\n`, ) + await fs.promises.writeFile( + path.join(outDir, SERVER_REFERENCES_FILE), + renderServerReferences( + serverReferenceChunks.keys(), + serverReferenceChunks, + ), + ) + + const deploymentsDir = path.join(outDir, 'deployments') + await fs.promises.rm(deploymentsDir, { recursive: true, force: true }) + for (const [route, actionIds] of Object.entries(routeActionManifest)) { + const deploymentDir = path.join( + deploymentsDir, + routeDeploymentName(route), + ) + const rscDir = path.join(deploymentDir, 'rsc') + await fs.promises.mkdir(rscDir, { recursive: true }) + const referenceKeys = new Set( + actionIds.map((actionId) => actionId.split('#')[0]!), + ) + const files = collectChunkClosure(manager.bundles.rsc, [ + 'handler.js', + ...[...referenceKeys].map((key) => serverReferenceChunks.get(key)!), + ]) + for (const fileName of files) { + const destination = path.join(rscDir, fileName) + await fs.promises.mkdir(path.dirname(destination), { + recursive: true, + }) + await fs.promises.copyFile(path.join(outDir, fileName), destination) + } + await fs.promises.copyFile( + path.join(outDir, ROUTE_ACTION_MANIFEST_FILE), + path.join(rscDir, ROUTE_ACTION_MANIFEST_FILE), + ) + await fs.promises.writeFile( + path.join(rscDir, SERVER_REFERENCES_FILE), + renderServerReferences(referenceKeys, serverReferenceChunks), + ) + await fs.promises.cp( + builder.config.environments.ssr.build.outDir, + path.join(deploymentDir, 'ssr'), + { recursive: true }, + ) + } + await fs.promises.writeFile( + path.join(outDir, ROUTE_DEPLOYMENTS_FILE), + `export default {\n${Object.keys(routes) + .map( + (route) => + ` ${JSON.stringify(route)}: () => import(${JSON.stringify(`./deployments/${routeDeploymentName(route)}/rsc/handler.js`)}),`, + ) + .join('\n')}\n}\n`, + ) }, }, } } +function routeDeploymentName(route: string) { + return route === '/' ? '%2F' : encodeURIComponent(route.slice(1)) +} + +function renderServerReferences( + referenceKeys: Iterable, + referenceImports: Map, + relative = true, +) { + let code = '' + for (const referenceKey of referenceKeys) { + let importId = referenceImports.get(referenceKey)! + if (relative) importId = './' + importId + code += ` ${JSON.stringify(referenceKey)}: () => import(${JSON.stringify(importId)}),\n` + } + return `export default {\n${code}}\n` +} + +function collectChunkClosure( + bundle: Rollup.OutputBundle, + roots: string[], +): Set { + const files = new Set() + const queue = [...roots] + for (let index = 0; index < queue.length; index++) { + const fileName = queue[index]! + if (files.has(fileName)) continue + const output = bundle[fileName] + if (!output) continue + files.add(fileName) + if (output?.type === 'chunk') { + queue.push(...output.imports, ...output.dynamicImports) + if (output.viteMetadata) { + queue.push( + ...output.viteMetadata.importedAssets, + ...output.viteMetadata.importedCss, + ) + } + } + } + return files +} + function collectReachableReferences( context: Rollup.PluginContext, manager: RscPluginManager, diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.router.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.router.ts new file mode 100644 index 000000000..ce77e2128 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.router.ts @@ -0,0 +1,22 @@ +import deployments from 'virtual:route-deployments' +import { routeActionRequest } from './action-routing.ts' +import { parseRenderRequest } from './request.tsx' + +export default { fetch: handler } + +async function handler(request: Request): Promise { + let renderRequest = parseRenderRequest(request) + if (renderRequest.actionId) { + const routing = routeActionRequest(renderRequest) + if (routing.type === 'reject') return routing.response + if (routing.type === 'redispatch') { + request = routing.request + renderRequest = parseRenderRequest(request) + } + } + + const load = deployments[renderRequest.url.pathname] + if (!load) return new Response('Not Found', { status: 404 }) + const deployment = await load() + return deployment.default.fetch(request) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx index a17478fe6..021a72b5f 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx @@ -5,12 +5,27 @@ import { loadServerAction, decodeAction, decodeFormState, -} from '@vitejs/plugin-rsc/rsc' + setRequireModule, +} from '@vitejs/plugin-rsc/react/rsc' import type { ReactFormState } from 'react-dom/client' +import serverReferences from 'virtual:route-server-references' import { getRoute, RouteRoot } from '../app/routes.tsx' import { routeActionRequest } from './action-routing.ts' import { parseRenderRequest } from './request.tsx' +setRequireModule({ + async load(id) { + if (!import.meta.env.__vite_rsc_build__) { + return import(/* @vite-ignore */ id) + } + const load = serverReferences[id] + if (!load) { + throw new Error(`Server reference unavailable in this deployment: ${id}`) + } + return load() + }, +}) + export type RscPayload = { root: React.ReactNode returnValue?: { ok: boolean; data: unknown } diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts index e4a8ce208..7f7df9ba0 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts @@ -2,3 +2,16 @@ declare module 'virtual:route-action-manifest' { const manifest: Record | null export default manifest } + +declare module 'virtual:route-server-references' { + const references: Record Promise>> + export default references +} + +declare module 'virtual:route-deployments' { + const deployments: Record< + string, + () => Promise<{ default: { fetch(request: Request): Promise } }> + > + export default deployments +} diff --git a/packages/plugin-rsc/examples/action-reachability/vite.config.ts b/packages/plugin-rsc/examples/action-reachability/vite.config.ts index 3c59ffd16..079268270 100644 --- a/packages/plugin-rsc/examples/action-reachability/vite.config.ts +++ b/packages/plugin-rsc/examples/action-reachability/vite.config.ts @@ -10,7 +10,8 @@ export default defineConfig({ build: { rollupOptions: { input: { - index: './src/framework/entry.rsc.tsx', + index: './src/framework/entry.router.ts', + handler: './src/framework/entry.rsc.tsx', }, }, },