Skip to content

Commit f6437f0

Browse files
committed
feat(plugin-rsc): expose RSC compatibility version
1 parent 2b8df67 commit f6437f0

8 files changed

Lines changed: 663 additions & 1 deletion

File tree

packages/plugin-rsc/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,46 @@ export default defineConfig({
442442
})
443443
```
444444

445+
### Framework compatibility manifest
446+
447+
Frameworks can import `virtual:vite-rsc/compatibility-manifest` from the RSC or
448+
SSR environment to access compiler-owned deployment compatibility metadata. This
449+
is intended for deployment skew protection: a framework can include
450+
`compatibilityManifest.compatibilityVersion` in RSC responses and trigger a
451+
document reload when a later RSC request comes from an incompatible client.
452+
453+
```js
454+
import compatibilityManifest from 'virtual:vite-rsc/compatibility-manifest'
455+
456+
export function getRscResponseMetadata() {
457+
return {
458+
compatibilityVersion: compatibilityManifest.compatibilityVersion,
459+
}
460+
}
461+
```
462+
463+
The manifest includes the Vite base path, RSC runtime package versions, a hash
464+
of the final assets manifest, final output bundle hashes, client reference keys
465+
with rendered exports, server reference keys with exported functions, and a hash
466+
of the server-action encryption key when action closure encryption is actually
467+
emitted.
468+
469+
Frameworks with a custom build pipeline can use `getPluginApi(config).manager`
470+
after the real RSC and client builds have completed:
471+
472+
```js
473+
import { getPluginApi } from '@vitejs/plugin-rsc'
474+
475+
const manager = getPluginApi(config).manager
476+
const manifest = manager.finalizeCompatibilityManifest()
477+
const version = manifest.compatibilityVersion
478+
```
479+
480+
`manager.getCompatibilityManifest()` and `manager.getCompatibilityVersion()`
481+
throw during production builds until the manifest has been finalized. This
482+
prevents scan builds or incomplete custom pipelines from accidentally emitting a
483+
partial compatibility version.
484+
445485
## RSC runtime (react-server-dom) API
446486

447487
### `@vitejs/plugin-rsc/rsc`

packages/plugin-rsc/e2e/basic.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ import {
1818
waitForHydration,
1919
} from './helper'
2020

21+
function readCompatibilityManifest(f: Fixture, environmentName: string) {
22+
return JSON.parse(
23+
readFileSync(
24+
path.join(
25+
f.root,
26+
'dist',
27+
environmentName,
28+
'__vite_rsc_compatibility_manifest.js',
29+
),
30+
'utf-8',
31+
).slice('export default '.length),
32+
)
33+
}
34+
2135
test.describe('dev-default', () => {
2236
const f = useFixture({ root: 'examples/basic', mode: 'dev' })
2337
defineTest(f)
@@ -428,6 +442,58 @@ function defineTest(f: Fixture) {
428442
manifest.clientReferenceDeps[hashString('src/routes/client.tsx')]
429443
expect(srcs).toEqual(expect.arrayContaining(deps.js))
430444
})
445+
446+
test('compatibility manifest', async ({ page }) => {
447+
const response = await page.request.get(
448+
f.url('__test_compatibility_manifest'),
449+
)
450+
expect(response.ok()).toBe(true)
451+
const runtimeManifest = await response.json()
452+
const rscManifest = readCompatibilityManifest(f, 'rsc')
453+
const ssrManifest = readCompatibilityManifest(f, 'ssr')
454+
455+
expect(runtimeManifest).toEqual(rscManifest)
456+
expect(ssrManifest).toEqual(rscManifest)
457+
expect(rscManifest).toMatchObject({
458+
version: 1,
459+
compatibilityVersion: expect.stringMatching(/^[a-f0-9]{64}$/),
460+
base: '/',
461+
runtime: expect.objectContaining({
462+
'@vitejs/plugin-rsc': expect.any(String),
463+
react: expect.any(String),
464+
'react-dom': expect.any(String),
465+
vite: expect.any(String),
466+
}),
467+
assetsManifestHash: expect.stringMatching(/^[a-f0-9]{64}$/),
468+
bundles: {
469+
client: expect.stringMatching(/^[a-f0-9]{64}$/),
470+
rsc: expect.stringMatching(/^[a-f0-9]{64}$/),
471+
ssr: expect.stringMatching(/^[a-f0-9]{64}$/),
472+
},
473+
clientReferences: expect.arrayContaining([
474+
expect.objectContaining({
475+
id: 'src/routes/client.tsx',
476+
renderedExports: expect.arrayContaining([
477+
'ClientCounter',
478+
'Hydrated',
479+
]),
480+
}),
481+
]),
482+
serverReferences: expect.arrayContaining([
483+
expect.objectContaining({
484+
id: 'src/routes/action/action.tsx',
485+
exportNames: expect.arrayContaining([
486+
'changeServerCounter',
487+
'getServerCounter',
488+
'resetServerCounter',
489+
]),
490+
}),
491+
]),
492+
})
493+
expect(rscManifest.compatibilityVersion).not.toBe(
494+
rscManifest.assetsManifestHash,
495+
)
496+
})
431497
})
432498

433499
test.describe(() => {

packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ async function handleRequest({
128128
async function handler(request: Request): Promise<Response> {
129129
const url = new URL(request.url)
130130

131+
if (url.pathname === '/__test_compatibility_manifest') {
132+
const { default: compatibilityManifest } =
133+
await import('virtual:vite-rsc/compatibility-manifest')
134+
return Response.json(compatibilityManifest)
135+
}
136+
131137
const { Root } = await import('../routes/root.tsx')
132138
const nonce = !process.env.NO_CSP ? crypto.randomUUID() : undefined
133139
// https://vite.dev/guide/features.html#content-security-policy-csp

packages/plugin-rsc/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export {
22
default,
33
type RscPluginOptions,
4+
type RscCompatibilityManifest,
45
getPluginApi,
56
type PluginApi,
67
} from './plugin'
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { vitePluginRscMinimal, type PluginApi } from './plugin'
3+
4+
describe('RscPluginManager compatibility version', () => {
5+
test('throws during build before finalization', () => {
6+
const manager = createManager()
7+
8+
expect(() => manager.getCompatibilityManifest()).toThrow(
9+
/compatibility manifest is not ready/,
10+
)
11+
expect(() => manager.finalizeCompatibilityManifest()).toThrow(
12+
/requires the final assets manifest/,
13+
)
14+
})
15+
16+
test('serializes normalized references and final build fingerprints', () => {
17+
const manager = createFinalizedManager({
18+
root: '/workspace/app',
19+
base: '/base/',
20+
})
21+
22+
expect(manager.getCompatibilityManifest()).toMatchObject({
23+
version: 1,
24+
compatibilityVersion: expect.stringMatching(/^[a-f0-9]{64}$/),
25+
base: '/base/',
26+
assetsManifestHash: expect.stringMatching(/^[a-f0-9]{64}$/),
27+
bundles: {
28+
client: expect.stringMatching(/^[a-f0-9]{64}$/),
29+
rsc: expect.stringMatching(/^[a-f0-9]{64}$/),
30+
},
31+
clientReferences: [
32+
{
33+
id: 'src/button.tsx',
34+
referenceKey: 'button',
35+
renderedExports: ['Button'],
36+
},
37+
],
38+
serverReferences: [
39+
{
40+
id: 'src/actions.ts',
41+
referenceKey: 'actions',
42+
exportNames: ['save'],
43+
},
44+
],
45+
})
46+
expect(manager.getCompatibilityVersion()).toBe(
47+
manager.getCompatibilityManifest().compatibilityVersion,
48+
)
49+
})
50+
51+
test('ignores client exports that are not rendered', () => {
52+
const manager = createFinalizedManager()
53+
const before = manager.getCompatibilityVersion()
54+
55+
manager.clientReferenceMetaMap[
56+
'/workspace/app/src/button.tsx'
57+
]!.exportNames = ['Button', 'Unused']
58+
manager.finalizeCompatibilityManifest()
59+
60+
expect(manager.getCompatibilityVersion()).toBe(before)
61+
})
62+
63+
test('changes when the rendered client export ABI changes', () => {
64+
const manager = createFinalizedManager()
65+
const before = manager.getCompatibilityVersion()
66+
67+
manager.clientReferenceMetaMap[
68+
'/workspace/app/src/button.tsx'
69+
]!.renderedExports = ['Button', 'ButtonIcon']
70+
manager.finalizeCompatibilityManifest()
71+
72+
expect(manager.getCompatibilityVersion()).not.toBe(before)
73+
})
74+
75+
test('changes when the server reference ABI changes', () => {
76+
const manager = createFinalizedManager()
77+
const before = manager.getCompatibilityVersion()
78+
79+
manager.serverReferenceMetaMap[
80+
'/workspace/app/src/actions.ts'
81+
]!.exportNames = ['delete', 'save']
82+
manager.finalizeCompatibilityManifest()
83+
84+
expect(manager.getCompatibilityVersion()).not.toBe(before)
85+
})
86+
87+
test('changes when the client assets manifest changes', () => {
88+
const manager = createFinalizedManager()
89+
const before = manager.getCompatibilityVersion()
90+
91+
manager.buildAssetsManifest = {
92+
...manager.buildAssetsManifest!,
93+
clientReferenceDeps: {
94+
button: {
95+
js: ['/assets/button.new.js'],
96+
css: [],
97+
},
98+
},
99+
}
100+
manager.finalizeCompatibilityManifest()
101+
102+
expect(manager.getCompatibilityVersion()).not.toBe(before)
103+
})
104+
105+
test('changes when final client bundle content changes', () => {
106+
const manager = createFinalizedManager()
107+
const before = manager.getCompatibilityVersion()
108+
109+
manager.bundles.client = createBundle({
110+
'assets/button.js': 'export const Button = "new"',
111+
})
112+
manager.finalizeCompatibilityManifest()
113+
114+
expect(manager.getCompatibilityVersion()).not.toBe(before)
115+
})
116+
117+
test('changes when final rsc bundle content changes', () => {
118+
const manager = createFinalizedManager()
119+
const before = manager.getCompatibilityVersion()
120+
121+
manager.bundles.rsc = createBundle({
122+
'index.js': 'export const root = "new-rsc"',
123+
})
124+
manager.finalizeCompatibilityManifest()
125+
126+
expect(manager.getCompatibilityVersion()).not.toBe(before)
127+
})
128+
129+
test('changes when server action encryption key identity changes', () => {
130+
const manager = createFinalizedManager()
131+
manager.serverActionEncryptionKeyHash = 'key-a'
132+
manager.finalizeCompatibilityManifest()
133+
const before = manager.getCompatibilityVersion()
134+
135+
manager.serverActionEncryptionKeyHash = 'key-b'
136+
manager.finalizeCompatibilityManifest()
137+
138+
expect(manager.getCompatibilityVersion()).not.toBe(before)
139+
})
140+
141+
test('is stable across different absolute roots', () => {
142+
const first = createFinalizedManager({ root: '/first/root' })
143+
first.clientReferenceMetaMap = {
144+
'/first/root/src/button.tsx': {
145+
importId: '/first/root/src/button.tsx',
146+
referenceKey: 'button',
147+
exportNames: ['Button'],
148+
renderedExports: ['Button'],
149+
},
150+
}
151+
first.finalizeCompatibilityManifest()
152+
153+
const second = createFinalizedManager({ root: '/second/root' })
154+
second.clientReferenceMetaMap = {
155+
'/second/root/src/button.tsx': {
156+
importId: '/second/root/src/button.tsx',
157+
referenceKey: 'button',
158+
exportNames: ['Button'],
159+
renderedExports: ['Button'],
160+
},
161+
}
162+
second.finalizeCompatibilityManifest()
163+
164+
expect(second.getCompatibilityVersion()).toBe(
165+
first.getCompatibilityVersion(),
166+
)
167+
})
168+
})
169+
170+
type ManagerOptions = {
171+
base?: string
172+
root?: string
173+
}
174+
175+
function createFinalizedManager(options: ManagerOptions = {}) {
176+
const manager = createManager(options)
177+
manager.clientReferenceMetaMap = {
178+
[`${options.root ?? '/workspace/app'}/src/button.tsx`]: {
179+
importId: `${options.root ?? '/workspace/app'}/src/button.tsx`,
180+
referenceKey: 'button',
181+
exportNames: ['Button'],
182+
renderedExports: ['Button'],
183+
},
184+
}
185+
manager.serverReferenceMetaMap = {
186+
[`${options.root ?? '/workspace/app'}/src/actions.ts`]: {
187+
importId: `${options.root ?? '/workspace/app'}/src/actions.ts`,
188+
referenceKey: 'actions',
189+
exportNames: ['save'],
190+
},
191+
}
192+
manager.buildAssetsManifest = {
193+
bootstrapScriptContent: 'import("/assets/index.js")',
194+
clientReferenceDeps: {
195+
button: {
196+
js: ['/assets/button.js'],
197+
css: [],
198+
},
199+
},
200+
}
201+
manager.bundles = {
202+
client: createBundle({
203+
'assets/button.js': 'export const Button = "old"',
204+
}),
205+
rsc: createBundle({
206+
'index.js': 'export const root = "rsc"',
207+
}),
208+
}
209+
manager.finalizeCompatibilityManifest()
210+
return manager
211+
}
212+
213+
function createManager({
214+
base = '/',
215+
root = '/workspace/app',
216+
}: ManagerOptions = {}) {
217+
const [plugin] = vitePluginRscMinimal()
218+
const manager = (plugin as { api: PluginApi }).api.manager
219+
manager.config = { base, command: 'build', root } as any
220+
return manager
221+
}
222+
223+
function createBundle(chunks: Record<string, string>) {
224+
return Object.fromEntries(
225+
Object.entries(chunks).map(([fileName, code]) => [
226+
fileName,
227+
{
228+
type: 'chunk',
229+
fileName,
230+
code,
231+
},
232+
]),
233+
) as any
234+
}

0 commit comments

Comments
 (0)