Skip to content
Open
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
40 changes: 40 additions & 0 deletions packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,46 @@ export default defineConfig({
})
```

### Framework compatibility manifest

Frameworks can import `virtual:vite-rsc/compatibility-manifest` from the RSC or
SSR environment to access compiler-owned deployment compatibility metadata. This
is intended for deployment skew protection: a framework can include
`compatibilityManifest.compatibilityVersion` in RSC responses and trigger a
document reload when a later RSC request comes from an incompatible client.

```js
import compatibilityManifest from 'virtual:vite-rsc/compatibility-manifest'

export function getRscResponseMetadata() {
return {
compatibilityVersion: compatibilityManifest.compatibilityVersion,
}
}
```

The manifest includes the Vite base path, RSC runtime package versions, a hash
of the final assets manifest, final output bundle hashes, client reference keys
with rendered exports, server reference keys with exported functions, and a hash
of the server-action encryption key when action closure encryption is actually
emitted.

Frameworks with a custom build pipeline can use `getPluginApi(config).manager`
after the real RSC and client builds have completed:

```js
import { getPluginApi } from '@vitejs/plugin-rsc'

const manager = getPluginApi(config).manager
const manifest = manager.finalizeCompatibilityManifest()
const version = manifest.compatibilityVersion
```

`manager.getCompatibilityManifest()` and `manager.getCompatibilityVersion()`
throw during production builds until the manifest has been finalized. This
prevents scan builds or incomplete custom pipelines from accidentally emitting a
partial compatibility version.

## RSC runtime (react-server-dom) API

### `@vitejs/plugin-rsc/rsc`
Expand Down
66 changes: 66 additions & 0 deletions packages/plugin-rsc/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ import {
waitForHydration,
} from './helper'

function readCompatibilityManifest(f: Fixture, environmentName: string) {
return JSON.parse(
readFileSync(
path.join(
f.root,
'dist',
environmentName,
'__vite_rsc_compatibility_manifest.js',
),
'utf-8',
).slice('export default '.length),
)
}

test.describe('dev-default', () => {
const f = useFixture({ root: 'examples/basic', mode: 'dev' })
defineTest(f)
Expand Down Expand Up @@ -428,6 +442,58 @@ function defineTest(f: Fixture) {
manifest.clientReferenceDeps[hashString('src/routes/client.tsx')]
expect(srcs).toEqual(expect.arrayContaining(deps.js))
})

test('compatibility manifest', async ({ page }) => {
const response = await page.request.get(
f.url('__test_compatibility_manifest'),
)
expect(response.ok()).toBe(true)
const runtimeManifest = await response.json()
const rscManifest = readCompatibilityManifest(f, 'rsc')
const ssrManifest = readCompatibilityManifest(f, 'ssr')

expect(runtimeManifest).toEqual(rscManifest)
expect(ssrManifest).toEqual(rscManifest)
expect(rscManifest).toMatchObject({
version: 1,
compatibilityVersion: expect.stringMatching(/^[a-f0-9]{64}$/),
base: '/',
runtime: expect.objectContaining({
'@vitejs/plugin-rsc': expect.any(String),
react: expect.any(String),
'react-dom': expect.any(String),
vite: expect.any(String),
}),
assetsManifestHash: expect.stringMatching(/^[a-f0-9]{64}$/),
bundles: {
client: expect.stringMatching(/^[a-f0-9]{64}$/),
rsc: expect.stringMatching(/^[a-f0-9]{64}$/),
ssr: expect.stringMatching(/^[a-f0-9]{64}$/),
},
clientReferences: expect.arrayContaining([
expect.objectContaining({
id: 'src/routes/client.tsx',
renderedExports: expect.arrayContaining([
'ClientCounter',
'Hydrated',
]),
}),
]),
serverReferences: expect.arrayContaining([
expect.objectContaining({
id: 'src/routes/action/action.tsx',
exportNames: expect.arrayContaining([
'changeServerCounter',
'getServerCounter',
'resetServerCounter',
]),
}),
]),
})
expect(rscManifest.compatibilityVersion).not.toBe(
rscManifest.assetsManifestHash,
)
})
})

test.describe(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ async function handleRequest({
async function handler(request: Request): Promise<Response> {
const url = new URL(request.url)

if (url.pathname === '/__test_compatibility_manifest') {
const { default: compatibilityManifest } =
await import('virtual:vite-rsc/compatibility-manifest')
return Response.json(compatibilityManifest)
}

const { Root } = await import('../routes/root.tsx')
const nonce = !process.env.NO_CSP ? crypto.randomUUID() : undefined
// https://vite.dev/guide/features.html#content-security-policy-csp
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-rsc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
default,
type RscPluginOptions,
type RscCompatibilityManifest,
getPluginApi,
type PluginApi,
} from './plugin'
234 changes: 234 additions & 0 deletions packages/plugin-rsc/src/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { describe, expect, test } from 'vitest'
import { vitePluginRscMinimal, type PluginApi } from './plugin'

describe('RscPluginManager compatibility version', () => {
test('throws during build before finalization', () => {
const manager = createManager()

expect(() => manager.getCompatibilityManifest()).toThrow(
/compatibility manifest is not ready/,
)
expect(() => manager.finalizeCompatibilityManifest()).toThrow(
/requires the final assets manifest/,
)
})

test('serializes normalized references and final build fingerprints', () => {
const manager = createFinalizedManager({
root: '/workspace/app',
base: '/base/',
})

expect(manager.getCompatibilityManifest()).toMatchObject({
version: 1,
compatibilityVersion: expect.stringMatching(/^[a-f0-9]{64}$/),
base: '/base/',
assetsManifestHash: expect.stringMatching(/^[a-f0-9]{64}$/),
bundles: {
client: expect.stringMatching(/^[a-f0-9]{64}$/),
rsc: expect.stringMatching(/^[a-f0-9]{64}$/),
},
clientReferences: [
{
id: 'src/button.tsx',
referenceKey: 'button',
renderedExports: ['Button'],
},
],
serverReferences: [
{
id: 'src/actions.ts',
referenceKey: 'actions',
exportNames: ['save'],
},
],
})
expect(manager.getCompatibilityVersion()).toBe(
manager.getCompatibilityManifest().compatibilityVersion,
)
})

test('ignores client exports that are not rendered', () => {
const manager = createFinalizedManager()
const before = manager.getCompatibilityVersion()

manager.clientReferenceMetaMap[
'/workspace/app/src/button.tsx'
]!.exportNames = ['Button', 'Unused']
manager.finalizeCompatibilityManifest()

expect(manager.getCompatibilityVersion()).toBe(before)
})

test('changes when the rendered client export ABI changes', () => {
const manager = createFinalizedManager()
const before = manager.getCompatibilityVersion()

manager.clientReferenceMetaMap[
'/workspace/app/src/button.tsx'
]!.renderedExports = ['Button', 'ButtonIcon']
manager.finalizeCompatibilityManifest()

expect(manager.getCompatibilityVersion()).not.toBe(before)
})

test('changes when the server reference ABI changes', () => {
const manager = createFinalizedManager()
const before = manager.getCompatibilityVersion()

manager.serverReferenceMetaMap[
'/workspace/app/src/actions.ts'
]!.exportNames = ['delete', 'save']
manager.finalizeCompatibilityManifest()

expect(manager.getCompatibilityVersion()).not.toBe(before)
})

test('changes when the client assets manifest changes', () => {
const manager = createFinalizedManager()
const before = manager.getCompatibilityVersion()

manager.buildAssetsManifest = {
...manager.buildAssetsManifest!,
clientReferenceDeps: {
button: {
js: ['/assets/button.new.js'],
css: [],
},
},
}
manager.finalizeCompatibilityManifest()

expect(manager.getCompatibilityVersion()).not.toBe(before)
})

test('changes when final client bundle content changes', () => {
const manager = createFinalizedManager()
const before = manager.getCompatibilityVersion()

manager.bundles.client = createBundle({
'assets/button.js': 'export const Button = "new"',
})
manager.finalizeCompatibilityManifest()

expect(manager.getCompatibilityVersion()).not.toBe(before)
})

test('changes when final rsc bundle content changes', () => {
const manager = createFinalizedManager()
const before = manager.getCompatibilityVersion()

manager.bundles.rsc = createBundle({
'index.js': 'export const root = "new-rsc"',
})
manager.finalizeCompatibilityManifest()

expect(manager.getCompatibilityVersion()).not.toBe(before)
})

test('changes when server action encryption key identity changes', () => {
const manager = createFinalizedManager()
manager.serverActionEncryptionKeyHash = 'key-a'
manager.finalizeCompatibilityManifest()
const before = manager.getCompatibilityVersion()

manager.serverActionEncryptionKeyHash = 'key-b'
manager.finalizeCompatibilityManifest()

expect(manager.getCompatibilityVersion()).not.toBe(before)
})

test('is stable across different absolute roots', () => {
const first = createFinalizedManager({ root: '/first/root' })
first.clientReferenceMetaMap = {
'/first/root/src/button.tsx': {
importId: '/first/root/src/button.tsx',
referenceKey: 'button',
exportNames: ['Button'],
renderedExports: ['Button'],
},
}
first.finalizeCompatibilityManifest()

const second = createFinalizedManager({ root: '/second/root' })
second.clientReferenceMetaMap = {
'/second/root/src/button.tsx': {
importId: '/second/root/src/button.tsx',
referenceKey: 'button',
exportNames: ['Button'],
renderedExports: ['Button'],
},
}
second.finalizeCompatibilityManifest()

expect(second.getCompatibilityVersion()).toBe(
first.getCompatibilityVersion(),
)
})
})

type ManagerOptions = {
base?: string
root?: string
}

function createFinalizedManager(options: ManagerOptions = {}) {
const manager = createManager(options)
manager.clientReferenceMetaMap = {
[`${options.root ?? '/workspace/app'}/src/button.tsx`]: {
importId: `${options.root ?? '/workspace/app'}/src/button.tsx`,
referenceKey: 'button',
exportNames: ['Button'],
renderedExports: ['Button'],
},
}
manager.serverReferenceMetaMap = {
[`${options.root ?? '/workspace/app'}/src/actions.ts`]: {
importId: `${options.root ?? '/workspace/app'}/src/actions.ts`,
referenceKey: 'actions',
exportNames: ['save'],
},
}
manager.buildAssetsManifest = {
bootstrapScriptContent: 'import("/assets/index.js")',
clientReferenceDeps: {
button: {
js: ['/assets/button.js'],
css: [],
},
},
}
manager.bundles = {
client: createBundle({
'assets/button.js': 'export const Button = "old"',
}),
rsc: createBundle({
'index.js': 'export const root = "rsc"',
}),
}
manager.finalizeCompatibilityManifest()
return manager
}

function createManager({
base = '/',
root = '/workspace/app',
}: ManagerOptions = {}) {
const [plugin] = vitePluginRscMinimal()
const manager = (plugin as { api: PluginApi }).api.manager
manager.config = { base, command: 'build', root } as any
return manager
}

function createBundle(chunks: Record<string, string>) {
return Object.fromEntries(
Object.entries(chunks).map(([fileName, code]) => [
fileName,
{
type: 'chunk',
fileName,
code,
},
]),
) as any
}
Loading