Skip to content

Commit 16933c1

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

5 files changed

Lines changed: 590 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/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)