Skip to content

Commit 31cdbb8

Browse files
hi-ogawaOpenCode
andauthored
feat(rsc): ability to register server function via plugins (#1310)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode <noreply@opencode.ai>
1 parent bc5ca14 commit 31cdbb8

22 files changed

Lines changed: 1082 additions & 47 deletions

File tree

packages/plugin-rsc/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ npm create vite@latest -- --template rsc
2727

2828
- [`./examples/basic`](./examples/basic) - Comprehensive showcase of standard RSC features and the primary E2E test fixture.
2929
- [`./examples/use-cache`](./examples/use-cache) - Minimal cache feature inspired by Next.js's `"use cache"`, built with generic transform and RSC runtime APIs.
30+
- [`./examples/custom-server-function`](./examples/custom-server-function) - Third-party Server Function directive integration using server reference claims.
3031
- [`./examples/ssg`](./examples/ssg) - Static site generation with MDX and client components for interactivity.
3132
- [`./examples/ppr`](./examples/ppr) - Partial prerendering with a reusable static HTML shell and request-time RSC content.
3233
- [`./examples/no-ssr`](./examples/no-ssr) - RSC application without an SSR environment.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { expect, test, type Page } from '@playwright/test'
2+
import { useFixture } from './fixture'
3+
import {
4+
expectNoPageError,
5+
expectNoReload,
6+
testNoJs,
7+
waitForHydration,
8+
} from './helper'
9+
10+
test.describe('dev-custom-server-function', () => {
11+
const f = useFixture({
12+
root: 'examples/custom-server-function',
13+
mode: 'dev',
14+
})
15+
defineTest(f)
16+
17+
test('moves an action between custom and built-in ownership', async ({
18+
page,
19+
}) => {
20+
using _ = expectNoPageError(page)
21+
await page.goto(f.url())
22+
await waitForHydration(page)
23+
await using _noReload = await expectNoReload(page)
24+
25+
const editor = f.createEditor('src/features/mixed-directives/actions.ts')
26+
// Switch one export from the custom plugin to the built-in plugin. HMR
27+
// must remove the custom claim while preserving the module's other claims.
28+
editor.edit((source) =>
29+
source
30+
.replace(`'use custom-server'`, `'use server'`)
31+
.replace(
32+
`customLabel = 'Custom'`,
33+
`customLabel = 'Custom changed to built-in'`,
34+
)
35+
.replace('customCount++', 'customCount += 10'),
36+
)
37+
38+
await expect(
39+
page.getByRole('button', { name: 'Custom changed to built-in: 0' }),
40+
).toBeVisible()
41+
await page.getByRole('button', { name: 'Built-in: 0', exact: true }).click()
42+
await expect(
43+
page.getByRole('button', { name: 'Built-in: 1', exact: true }),
44+
).toBeVisible()
45+
await page
46+
.getByRole('button', { name: 'Custom changed to built-in: 0' })
47+
.click()
48+
await expect(
49+
page.getByRole('button', { name: 'Custom changed to built-in: 10' }),
50+
).toBeVisible()
51+
52+
// Switch the export back and verify neither owner retained stale state.
53+
editor.reset()
54+
await expect(page.getByRole('button', { name: 'Custom: 0' })).toBeVisible()
55+
await page.getByRole('button', { name: 'Built-in: 0', exact: true }).click()
56+
await expect(
57+
page.getByRole('button', { name: 'Built-in: 1', exact: true }),
58+
).toBeVisible()
59+
await page.getByRole('button', { name: 'Custom: 0' }).click()
60+
await expect(page.getByRole('button', { name: 'Custom: 1' })).toBeVisible()
61+
})
62+
})
63+
64+
test.describe('build-custom-server-function', () => {
65+
const f = useFixture({
66+
root: 'examples/custom-server-function',
67+
mode: 'build',
68+
})
69+
defineTest(f)
70+
})
71+
72+
function defineTest(f: ReturnType<typeof useFixture>) {
73+
test('built-in and custom server functions', async ({ page }) => {
74+
using _ = expectNoPageError(page)
75+
await page.goto(f.url())
76+
await waitForHydration(page)
77+
await testActions(page)
78+
})
79+
80+
testNoJs('progressive forms', async ({ page }) => {
81+
await page.goto(f.url())
82+
await testActions(page)
83+
})
84+
85+
async function testActions(page: Page) {
86+
// The first two actions are inline functions in one RSC-reachable module.
87+
await page.getByRole('button', { name: 'Built-in: 0' }).click()
88+
await expect(
89+
page.getByRole('button', { name: 'Built-in: 1' }),
90+
).toBeVisible()
91+
92+
await page.getByRole('button', { name: 'Custom: 0' }).click()
93+
await expect(page.getByRole('button', { name: 'Custom: 1' })).toBeVisible()
94+
95+
// This module is only imported by a Client Component, so its implementation
96+
// reaches the RSC build through the aggregated server reference manifest.
97+
await page.getByRole('button', { name: 'From client: 0' }).click()
98+
await expect(
99+
page.getByRole('button', { name: 'From client: 1' }),
100+
).toBeVisible()
101+
102+
await page.getByRole('button', { name: 'Reset' }).click()
103+
await expect(
104+
page.getByRole('button', { name: 'Built-in: 0' }),
105+
).toBeVisible()
106+
await expect(page.getByRole('button', { name: 'Custom: 0' })).toBeVisible()
107+
}
108+
}

packages/plugin-rsc/examples/browser-mode/vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ function rscBrowserModePlugin(): Plugin[] {
216216
return `export default {}` // no-op during dev
217217
}
218218
let code = ''
219-
for (const meta of Object.values(manager.serverReferenceMetaMap)) {
219+
for (const meta of manager.serverReferences.metaMap.values()) {
220220
code += `${JSON.stringify(meta.referenceKey)}: () => import(${JSON.stringify(meta.importId)}),`
221221
}
222222
return `export default {${code}}`
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Custom Server Function
2+
3+
This example demonstrates a third-party Vite plugin implementing a custom `"use custom-server"` directive alongside the built-in `"use server"` directive.
4+
5+
## Background
6+
7+
Server Function extensibility has two separate concerns:
8+
9+
- The directive owner transforms its syntax and registers the function with the React runtime.
10+
- `@vitejs/plugin-rsc` owns bundler-level module identity, graph visibility, manifests, and reference resolution.
11+
12+
The custom plugin in [`custom-server-function-plugin.ts`](./custom-server-function-plugin.ts) transforms `"use custom-server"` and reports its exports as server reference claims. The RSC plugin aggregates those claims with its built-in `"use server"` claim instead of requiring one transform to own the entire module. This keeps custom syntax and metadata policy outside the RSC plugin while preserving a single canonical reference identity for the bundler.
13+
14+
The example separates two import graph shapes under [`src/features`](./src/features):
15+
16+
- `mixed-directives` exports inline built-in and custom Server Functions from one RSC-reachable module.
17+
- `action-from-client` imports a module-level custom Server Function only from a Client Component. The custom plugin creates its client and SSR proxies, while the server reference manifest brings its implementation into the RSC build.
18+
19+
This is a low-level integration example rather than a proposed high-level Server Function API.
20+
21+
## Current E2E Coverage
22+
23+
[`../../e2e/custom-server-function.test.ts`](../../e2e/custom-server-function.test.ts) verifies in both development and production build modes that:
24+
25+
- a built-in Server Function and a custom Server Function can coexist in one module
26+
- each function reaches the server and updates the rendered result
27+
- a custom Server Function that is not statically imported by the RSC entry works through both the SSR and client proxy paths
28+
- built-in, inline custom, and client-imported custom functions work as progressively enhanced forms without JavaScript
29+
- in development, an export can move from the custom owner to the built-in owner and back without reloading, losing the built-in claim, or retaining a conflicting stale claim
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc'
2+
import {
3+
hasDirective,
4+
transformDirectiveProxyExport,
5+
transformHoistInlineDirective,
6+
transformWrapExport,
7+
} from '@vitejs/plugin-rsc/transforms'
8+
import { parseAstAsync, type Plugin } from 'vite'
9+
10+
const directive = 'use custom-server'
11+
const pluginName = 'example:custom-server-function'
12+
13+
// This intentionally mirrors the built-in "use server" pipeline through the
14+
// public integration APIs, but uses a separate directive and plugin name and
15+
// omits export-all expansion and action encryption.
16+
// TODO: Give the custom directive observable runtime semantics so ownership
17+
// swaps can assert the active registration path in addition to claim cleanup.
18+
export function customServerFunctionPlugin(): Plugin {
19+
let manager: RscPluginManager
20+
21+
return {
22+
name: pluginName,
23+
configResolved(config) {
24+
manager = getPluginApi(config)!.manager
25+
},
26+
async transform(code, id) {
27+
const environmentName = this.environment.name
28+
if (!code.includes(directive)) {
29+
manager.serverReferences.deleteClaim(pluginName, id)
30+
return
31+
}
32+
33+
const reference = manager.serverReferences.resolve(id, 'rsc')
34+
const ast = (await parseAstAsync(code)) as unknown as Parameters<
35+
typeof transformHoistInlineDirective
36+
>[1]
37+
38+
if (environmentName === 'rsc') {
39+
const runtime = (value: string, name: string) =>
40+
`$$CustomReactServer.registerServerReference(${value}, ${JSON.stringify(reference.referenceKey)}, ${JSON.stringify(name)})`
41+
const result = hasDirective(ast.body, directive)
42+
? transformWrapExport(code, ast, {
43+
runtime,
44+
rejectNonAsyncFunction: true,
45+
})
46+
: transformHoistInlineDirective(code, ast, {
47+
directive,
48+
runtime,
49+
rejectNonAsyncFunction: true,
50+
})
51+
if (!result.output.hasChanged()) {
52+
manager.serverReferences.deleteClaim(pluginName, id)
53+
return
54+
}
55+
56+
manager.serverReferences.replaceClaim(pluginName, id, {
57+
...reference,
58+
exportNames: 'names' in result ? result.names : result.exportNames,
59+
})
60+
result.output.prepend(
61+
`import * as $$CustomReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`,
62+
)
63+
return {
64+
code: result.output.toString(),
65+
map: result.output.generateMap({ hires: 'boundary' }),
66+
}
67+
}
68+
69+
const result = transformDirectiveProxyExport(ast, {
70+
code,
71+
directive,
72+
rejectNonAsyncFunction: true,
73+
runtime: (name) =>
74+
`$$CustomReactClient.createServerReference(` +
75+
`${JSON.stringify(reference.referenceKey + '#' + name)},` +
76+
`$$CustomReactClient.callServer,` +
77+
`undefined,` +
78+
(this.environment.mode === 'dev'
79+
? `$$CustomReactClient.findSourceMapURL,`
80+
: `undefined,`) +
81+
`${JSON.stringify(name)})`,
82+
})
83+
if (!result?.output.hasChanged()) {
84+
manager.serverReferences.deleteClaim(pluginName, id)
85+
return
86+
}
87+
88+
manager.serverReferences.replaceClaim(pluginName, id, {
89+
...reference,
90+
exportNames: result.exportNames,
91+
})
92+
const runtimeEnvironment =
93+
environmentName === 'client' ? 'browser' : 'ssr'
94+
result.output.prepend(
95+
`import * as $$CustomReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`,
96+
)
97+
return {
98+
code: result.output.toString(),
99+
map: result.output.generateMap({ hires: 'boundary' }),
100+
}
101+
},
102+
}
103+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@vitejs/plugin-rsc-examples-custom-server-function",
3+
"version": "0.0.0",
4+
"private": true,
5+
"license": "MIT",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"react": "^19.2.8",
14+
"react-dom": "^19.2.8"
15+
},
16+
"devDependencies": {
17+
"@types/react": "^19.2.17",
18+
"@types/react-dom": "^19.2.3",
19+
"@vitejs/plugin-react": "latest",
20+
"@vitejs/plugin-rsc": "latest",
21+
"rsc-html-stream": "^0.0.7",
22+
"vite": "^8.1.4"
23+
}
24+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use custom-server'
2+
3+
// @ts-ignore -- virtualized by @vitejs/plugin-rsc
4+
import 'server-only'
5+
6+
export async function incrementFromClient(previous: number) {
7+
return previous + 1
8+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client'
2+
3+
import { useActionState } from 'react'
4+
import { incrementFromClient } from './action.ts'
5+
6+
export function ActionFromClient() {
7+
const [count, action] = useActionState(incrementFromClient, 0)
8+
return (
9+
<form action={action}>
10+
<button>From client: {count}</button>
11+
</form>
12+
)
13+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
let builtinCount = 0
2+
let customCount = 0
3+
4+
export const customLabel = 'Custom'
5+
6+
export function getCounts() {
7+
return { builtinCount, customCount }
8+
}
9+
10+
export async function incrementBuiltin() {
11+
'use server'
12+
builtinCount++
13+
}
14+
15+
export async function incrementCustom() {
16+
'use custom-server'
17+
customCount++
18+
}
19+
20+
export async function resetCounts() {
21+
'use server'
22+
builtinCount = 0
23+
customCount = 0
24+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {
2+
customLabel,
3+
getCounts,
4+
incrementBuiltin,
5+
incrementCustom,
6+
resetCounts,
7+
} from './actions.ts'
8+
9+
export function MixedDirectives() {
10+
const { builtinCount, customCount } = getCounts()
11+
return (
12+
<>
13+
<form action={incrementBuiltin}>
14+
<button>Built-in: {builtinCount}</button>
15+
</form>
16+
<form action={incrementCustom}>
17+
<button>
18+
{customLabel}: {customCount}
19+
</button>
20+
</form>
21+
<form action={resetCounts}>
22+
<button>Reset</button>
23+
</form>
24+
</>
25+
)
26+
}

0 commit comments

Comments
 (0)