Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/plugin-rsc/e2e/action-reachability.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, string[]> = 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', () => {
Expand Down
28 changes: 26 additions & 2 deletions packages/plugin-rsc/examples/action-reachability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@

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<string, string>()
const serverReferenceChunks = new Map<string, string>()
const routeClientReferenceImportIds = new Map<string, Set<string>>()
const routeServerReferenceIds = new Map<string, Set<string>>()
let routeActionManifest: Record<string, string[]> = {}
Expand All @@ -24,8 +30,33 @@
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
Expand All @@ -35,11 +66,25 @@
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 }`

Check warning

Code scanning / CodeQL

Improper code sanitization Medium

Code construction depends on an
improperly sanitized value
.
}
},
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 } =
Expand Down Expand Up @@ -77,18 +122,24 @@
// 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',
Expand All @@ -99,11 +150,108 @@
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`)}),`,

Check warning

Code scanning / CodeQL

Improper code sanitization Medium

Code construction depends on an
improperly sanitized value
.
)
.join('\n')}\n}\n`,
)
},
},
}
}

function routeDeploymentName(route: string) {
return route === '/' ? '%2F' : encodeURIComponent(route.slice(1))
}

function renderServerReferences(
referenceKeys: Iterable<string>,
referenceImports: Map<string, string>,
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<string> {
const files = new Set<string>()
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Response> {
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,16 @@ declare module 'virtual:route-action-manifest' {
const manifest: Record<string, string[]> | null
export default manifest
}

declare module 'virtual:route-server-references' {
const references: Record<string, () => Promise<Record<string, unknown>>>
export default references
}

declare module 'virtual:route-deployments' {
const deployments: Record<
string,
() => Promise<{ default: { fetch(request: Request): Promise<Response> } }>
>
export default deployments
}
Loading
Loading