Skip to content

Commit efc7995

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

4 files changed

Lines changed: 297 additions & 1 deletion

File tree

packages/plugin-rsc/README.md

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

445+
### Framework compatibility version
446+
447+
Frameworks can use `getPluginApi(config)` to access compiler-owned RSC
448+
compatibility metadata. This is intended for deployment skew protection: a
449+
framework can include the compatibility version in an RSC response and trigger a
450+
document reload when a later RSC request comes from an incompatible client.
451+
452+
```js
453+
import rsc, { getPluginApi } from '@vitejs/plugin-rsc'
454+
455+
let manager
456+
457+
export default defineConfig({
458+
plugins: [
459+
{
460+
name: 'my-rsc-framework',
461+
configResolved(config) {
462+
manager = getPluginApi(config).manager
463+
},
464+
generateBundle() {
465+
const version = manager.getCompatibilityVersion()
466+
// Include `version` in framework RSC payloads or manifests.
467+
},
468+
},
469+
rsc(),
470+
],
471+
})
472+
```
473+
474+
`manager.getCompatibilityManifest()` returns the structured data used for the
475+
hash. It includes the Vite base path, RSC runtime package versions, client
476+
reference keys with rendered exports, server reference keys with exported
477+
functions, and a hash of the server-action encryption key when action closure
478+
encryption is actually emitted. Call this from `generateBundle` or later during
479+
the real RSC build; earlier scan builds may not have complete `renderedExports`
480+
metadata.
481+
445482
## RSC runtime (react-server-dom) API
446483

447484
### `@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: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { vitePluginRscMinimal, type PluginApi } from './plugin'
3+
4+
describe('RscPluginManager compatibility version', () => {
5+
test('serializes normalized client and server reference ABI metadata', () => {
6+
const manager = createManager({ root: '/workspace/app', base: '/base/' })
7+
manager.clientReferenceMetaMap = {
8+
'/workspace/app/src/button.tsx': {
9+
importId: '/workspace/app/src/button.tsx',
10+
referenceKey: 'button',
11+
exportNames: ['Button', 'Unused'],
12+
renderedExports: ['Button'],
13+
},
14+
}
15+
manager.serverReferenceMetaMap = {
16+
'/workspace/app/src/actions.ts': {
17+
importId: '/workspace/app/src/actions.ts',
18+
referenceKey: 'actions',
19+
exportNames: ['save'],
20+
},
21+
}
22+
23+
expect(manager.getCompatibilityManifest()).toMatchObject({
24+
version: 1,
25+
base: '/base/',
26+
clientReferences: [
27+
{
28+
id: 'src/button.tsx',
29+
referenceKey: 'button',
30+
renderedExports: ['Button'],
31+
},
32+
],
33+
serverReferences: [
34+
{
35+
id: 'src/actions.ts',
36+
referenceKey: 'actions',
37+
exportNames: ['save'],
38+
},
39+
],
40+
})
41+
})
42+
43+
test('ignores client exports that are not rendered', () => {
44+
const manager = createManager()
45+
manager.clientReferenceMetaMap = {
46+
'/workspace/app/src/button.tsx': {
47+
importId: '/workspace/app/src/button.tsx',
48+
referenceKey: 'button',
49+
exportNames: ['Button'],
50+
renderedExports: ['Button'],
51+
},
52+
}
53+
const before = manager.getCompatibilityVersion()
54+
55+
manager.clientReferenceMetaMap[
56+
'/workspace/app/src/button.tsx'
57+
]!.exportNames = ['Button', 'Unused']
58+
59+
expect(manager.getCompatibilityVersion()).toBe(before)
60+
})
61+
62+
test('changes when the rendered client export ABI changes', () => {
63+
const manager = createManager()
64+
manager.clientReferenceMetaMap = {
65+
'/workspace/app/src/card.tsx': {
66+
importId: '/workspace/app/src/card.tsx',
67+
referenceKey: 'card',
68+
exportNames: ['Card', 'CardHeader'],
69+
renderedExports: ['Card'],
70+
},
71+
}
72+
const before = manager.getCompatibilityVersion()
73+
74+
manager.clientReferenceMetaMap[
75+
'/workspace/app/src/card.tsx'
76+
]!.renderedExports = ['Card', 'CardHeader']
77+
78+
expect(manager.getCompatibilityVersion()).not.toBe(before)
79+
})
80+
81+
test('changes when the server reference ABI changes', () => {
82+
const manager = createManager()
83+
manager.serverReferenceMetaMap = {
84+
'/workspace/app/src/actions.ts': {
85+
importId: '/workspace/app/src/actions.ts',
86+
referenceKey: 'actions',
87+
exportNames: ['save'],
88+
},
89+
}
90+
const before = manager.getCompatibilityVersion()
91+
92+
manager.serverReferenceMetaMap[
93+
'/workspace/app/src/actions.ts'
94+
]!.exportNames = ['delete', 'save']
95+
96+
expect(manager.getCompatibilityVersion()).not.toBe(before)
97+
})
98+
99+
test('changes when server action encryption key identity changes', () => {
100+
const manager = createManager()
101+
manager.serverActionEncryptionKeyHash = 'key-a'
102+
const before = manager.getCompatibilityVersion()
103+
104+
manager.serverActionEncryptionKeyHash = 'key-b'
105+
106+
expect(manager.getCompatibilityVersion()).not.toBe(before)
107+
})
108+
109+
test('is stable across different absolute roots', () => {
110+
const first = createManager({ root: '/first/root' })
111+
first.clientReferenceMetaMap = {
112+
'/first/root/src/button.tsx': {
113+
importId: '/first/root/src/button.tsx',
114+
referenceKey: 'button',
115+
exportNames: ['Button'],
116+
renderedExports: ['Button'],
117+
},
118+
}
119+
120+
const second = createManager({ root: '/second/root' })
121+
second.clientReferenceMetaMap = {
122+
'/second/root/src/button.tsx': {
123+
importId: '/second/root/src/button.tsx',
124+
referenceKey: 'button',
125+
exportNames: ['Button'],
126+
renderedExports: ['Button'],
127+
},
128+
}
129+
130+
expect(second.getCompatibilityVersion()).toBe(
131+
first.getCompatibilityVersion(),
132+
)
133+
})
134+
})
135+
136+
function createManager({
137+
base = '/',
138+
root = '/workspace/app',
139+
}: {
140+
base?: string
141+
root?: string
142+
} = {}) {
143+
const [plugin] = vitePluginRscMinimal()
144+
const manager = (plugin as { api: PluginApi }).api.manager
145+
manager.config = { base, root } as any
146+
return manager
147+
}

packages/plugin-rsc/src/plugin.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from 'node:assert'
2+
import { createHash } from 'node:crypto'
23
import fs from 'node:fs'
34
import { createRequire } from 'node:module'
45
import path from 'node:path'
@@ -78,6 +79,14 @@ const isRolldownVite = 'rolldownVersion' in vite
7879

7980
const BUILD_ASSETS_MANIFEST_NAME = '__vite_rsc_assets_manifest.js'
8081

82+
const COMPATIBILITY_RUNTIME_PACKAGES = [
83+
'@vitejs/plugin-rsc',
84+
'react',
85+
'react-dom',
86+
'react-server-dom-webpack',
87+
'vite',
88+
]
89+
8190
type ClientReferenceMeta = {
8291
importId: string
8392
// same as `importId` during dev. hashed id during build.
@@ -97,6 +106,24 @@ type ServerRerferenceMeta = {
97106
exportNames: string[]
98107
}
99108

109+
export type RscCompatibilityManifest = {
110+
version: 1
111+
base: string
112+
runtime: Record<string, string>
113+
clientReferences: Array<{
114+
id: string
115+
referenceKey: string
116+
packageSource?: string
117+
renderedExports: string[]
118+
}>
119+
serverReferences: Array<{
120+
id: string
121+
referenceKey: string
122+
exportNames: string[]
123+
}>
124+
serverActionEncryptionKeyHash?: string
125+
}
126+
100127
const PKG_NAME = '@vitejs/plugin-rsc'
101128
const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom`
102129

@@ -126,6 +153,7 @@ class RscPluginManager {
126153
clientReferenceGroups: Record</* group name*/ string, ClientReferenceMeta[]> =
127154
{}
128155
serverReferenceMetaMap: Record<string, ServerRerferenceMeta> = {}
156+
serverActionEncryptionKeyHash: string | undefined
129157
serverResourcesMetaMap: Record<string, { key: string }> = {}
130158
environmentImportMetaMap: Record<
131159
string, // sourceEnv
@@ -166,6 +194,85 @@ class RscPluginManager {
166194
writeEnvironmentImportsManifest(): void {
167195
writeEnvironmentImportsManifest(this)
168196
}
197+
198+
getCompatibilityManifest(): RscCompatibilityManifest {
199+
const manifest: RscCompatibilityManifest = {
200+
version: 1,
201+
base: this.config.base,
202+
runtime: Object.fromEntries(
203+
COMPATIBILITY_RUNTIME_PACKAGES.map((packageName) => [
204+
packageName,
205+
getPackageVersion(packageName),
206+
]),
207+
),
208+
clientReferences: Object.values(this.clientReferenceMetaMap)
209+
.map((meta) => ({
210+
id: this.toCompatibilityId(meta.importId),
211+
referenceKey: meta.referenceKey,
212+
packageSource: meta.packageSource,
213+
renderedExports: [...meta.renderedExports].sort(),
214+
}))
215+
.sort(compareClientCompatibilityReferences),
216+
serverReferences: Object.values(this.serverReferenceMetaMap)
217+
.map((meta) => ({
218+
id: this.toCompatibilityId(meta.importId),
219+
referenceKey: meta.referenceKey,
220+
exportNames: [...meta.exportNames].sort(),
221+
}))
222+
.sort(compareServerCompatibilityReferences),
223+
}
224+
if (this.serverActionEncryptionKeyHash) {
225+
manifest.serverActionEncryptionKeyHash =
226+
this.serverActionEncryptionKeyHash
227+
}
228+
return manifest
229+
}
230+
231+
getCompatibilityVersion(): string {
232+
return hashCompatibilityValue(
233+
JSON.stringify(this.getCompatibilityManifest()),
234+
)
235+
}
236+
237+
private toCompatibilityId(id: string): string {
238+
return normalizePath(path.isAbsolute(id) ? this.toRelativeId(id) : id)
239+
}
240+
}
241+
242+
function compareClientCompatibilityReferences(
243+
a: RscCompatibilityManifest['clientReferences'][number],
244+
b: RscCompatibilityManifest['clientReferences'][number],
245+
): number {
246+
return (
247+
a.referenceKey.localeCompare(b.referenceKey) ||
248+
a.id.localeCompare(b.id) ||
249+
(a.packageSource ?? '').localeCompare(b.packageSource ?? '')
250+
)
251+
}
252+
253+
function compareServerCompatibilityReferences(
254+
a: RscCompatibilityManifest['serverReferences'][number],
255+
b: RscCompatibilityManifest['serverReferences'][number],
256+
): number {
257+
return (
258+
a.referenceKey.localeCompare(b.referenceKey) || a.id.localeCompare(b.id)
259+
)
260+
}
261+
262+
function hashCompatibilityValue(value: string): string {
263+
return createHash('sha256').update(value).digest('hex').slice(0, 16)
264+
}
265+
266+
function getPackageVersion(packageName: string): string {
267+
try {
268+
const packageJsonPath = require.resolve(`${packageName}/package.json`)
269+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
270+
return typeof packageJson.version === 'string'
271+
? packageJson.version
272+
: 'unknown'
273+
} catch {
274+
return 'unknown'
275+
}
169276
}
170277

171278
export type RscPluginOptions = {
@@ -338,7 +445,7 @@ export function vitePluginRscMinimal(
338445
...vitePluginRscCore(),
339446
...vitePluginUseClient(rscPluginOptions, manager),
340447
...vitePluginUseServer(rscPluginOptions, manager),
341-
...vitePluginDefineEncryptionKey(rscPluginOptions),
448+
...vitePluginDefineEncryptionKey(rscPluginOptions, manager),
342449
{
343450
name: 'rsc:reference-validation',
344451
apply: 'serve',
@@ -1820,6 +1927,7 @@ function vitePluginDefineEncryptionKey(
18201927
RscPluginOptions,
18211928
'defineEncryptionKey' | 'environment'
18221929
>,
1930+
manager: RscPluginManager,
18231931
): Plugin[] {
18241932
let defineEncryptionKey: string
18251933
let emitEncryptionKey = false
@@ -1833,6 +1941,7 @@ function vitePluginDefineEncryptionKey(
18331941
name: 'rsc:encryption-key',
18341942
async configEnvironment(name, _config, env) {
18351943
if (name === serverEnvironmentName && !env.isPreview) {
1944+
manager.serverActionEncryptionKeyHash = undefined
18361945
defineEncryptionKey =
18371946
useServerPluginOptions.defineEncryptionKey ??
18381947
JSON.stringify(toBase64(await generateEncryptionKey()))
@@ -1863,6 +1972,8 @@ function vitePluginDefineEncryptionKey(
18631972
if (code.includes(KEY_PLACEHOLDER)) {
18641973
assert.equal(this.environment.name, serverEnvironmentName)
18651974
emitEncryptionKey = true
1975+
manager.serverActionEncryptionKeyHash =
1976+
hashCompatibilityValue(defineEncryptionKey)
18661977
const normalizedPath = normalizeRelativePath(
18671978
path.relative(path.join(chunk.fileName, '..'), KEY_FILE),
18681979
)

0 commit comments

Comments
 (0)