diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index aa84f68f9..e2d11b867 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -27,6 +27,7 @@ npm create vite@latest -- --template rsc - [`./examples/basic`](./examples/basic) - Comprehensive showcase of standard RSC features and the primary E2E test fixture. - [`./examples/use-cache`](./examples/use-cache) - Minimal cache feature inspired by Next.js's `"use cache"`, built with generic transform and RSC runtime APIs. +- [`./examples/custom-server-function`](./examples/custom-server-function) - Third-party Server Function directive integration using server reference claims. - [`./examples/ssg`](./examples/ssg) - Static site generation with MDX and client components for interactivity. - [`./examples/ppr`](./examples/ppr) - Partial prerendering with a reusable static HTML shell and request-time RSC content. - [`./examples/no-ssr`](./examples/no-ssr) - RSC application without an SSR environment. diff --git a/packages/plugin-rsc/e2e/custom-server-function.test.ts b/packages/plugin-rsc/e2e/custom-server-function.test.ts new file mode 100644 index 000000000..f490f3f67 --- /dev/null +++ b/packages/plugin-rsc/e2e/custom-server-function.test.ts @@ -0,0 +1,108 @@ +import { expect, test, type Page } from '@playwright/test' +import { useFixture } from './fixture' +import { + expectNoPageError, + expectNoReload, + testNoJs, + waitForHydration, +} from './helper' + +test.describe('dev-custom-server-function', () => { + const f = useFixture({ + root: 'examples/custom-server-function', + mode: 'dev', + }) + defineTest(f) + + test('moves an action between custom and built-in ownership', async ({ + page, + }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + await using _noReload = await expectNoReload(page) + + const editor = f.createEditor('src/features/mixed-directives/actions.ts') + // Switch one export from the custom plugin to the built-in plugin. HMR + // must remove the custom claim while preserving the module's other claims. + editor.edit((source) => + source + .replace(`'use custom-server'`, `'use server'`) + .replace( + `customLabel = 'Custom'`, + `customLabel = 'Custom changed to built-in'`, + ) + .replace('customCount++', 'customCount += 10'), + ) + + await expect( + page.getByRole('button', { name: 'Custom changed to built-in: 0' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Built-in: 0', exact: true }).click() + await expect( + page.getByRole('button', { name: 'Built-in: 1', exact: true }), + ).toBeVisible() + await page + .getByRole('button', { name: 'Custom changed to built-in: 0' }) + .click() + await expect( + page.getByRole('button', { name: 'Custom changed to built-in: 10' }), + ).toBeVisible() + + // Switch the export back and verify neither owner retained stale state. + editor.reset() + await expect(page.getByRole('button', { name: 'Custom: 0' })).toBeVisible() + await page.getByRole('button', { name: 'Built-in: 0', exact: true }).click() + await expect( + page.getByRole('button', { name: 'Built-in: 1', exact: true }), + ).toBeVisible() + await page.getByRole('button', { name: 'Custom: 0' }).click() + await expect(page.getByRole('button', { name: 'Custom: 1' })).toBeVisible() + }) +}) + +test.describe('build-custom-server-function', () => { + const f = useFixture({ + root: 'examples/custom-server-function', + mode: 'build', + }) + defineTest(f) +}) + +function defineTest(f: ReturnType) { + test('built-in and custom server functions', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + await testActions(page) + }) + + testNoJs('progressive forms', async ({ page }) => { + await page.goto(f.url()) + await testActions(page) + }) + + async function testActions(page: Page) { + // The first two actions are inline functions in one RSC-reachable module. + await page.getByRole('button', { name: 'Built-in: 0' }).click() + await expect( + page.getByRole('button', { name: 'Built-in: 1' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Custom: 0' }).click() + await expect(page.getByRole('button', { name: 'Custom: 1' })).toBeVisible() + + // This module is only imported by a Client Component, so its implementation + // reaches the RSC build through the aggregated server reference manifest. + await page.getByRole('button', { name: 'From client: 0' }).click() + await expect( + page.getByRole('button', { name: 'From client: 1' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Reset' }).click() + await expect( + page.getByRole('button', { name: 'Built-in: 0' }), + ).toBeVisible() + await expect(page.getByRole('button', { name: 'Custom: 0' })).toBeVisible() + } +} diff --git a/packages/plugin-rsc/examples/browser-mode/vite.config.ts b/packages/plugin-rsc/examples/browser-mode/vite.config.ts index 6bd40a6a8..989ed172b 100644 --- a/packages/plugin-rsc/examples/browser-mode/vite.config.ts +++ b/packages/plugin-rsc/examples/browser-mode/vite.config.ts @@ -216,7 +216,7 @@ function rscBrowserModePlugin(): Plugin[] { return `export default {}` // no-op during dev } let code = '' - for (const meta of Object.values(manager.serverReferenceMetaMap)) { + for (const meta of manager.serverReferences.metaMap.values()) { code += `${JSON.stringify(meta.referenceKey)}: () => import(${JSON.stringify(meta.importId)}),` } return `export default {${code}}` diff --git a/packages/plugin-rsc/examples/custom-server-function/README.md b/packages/plugin-rsc/examples/custom-server-function/README.md new file mode 100644 index 000000000..0718c35d4 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/README.md @@ -0,0 +1,29 @@ +# Custom Server Function + +This example demonstrates a third-party Vite plugin implementing a custom `"use custom-server"` directive alongside the built-in `"use server"` directive. + +## Background + +Server Function extensibility has two separate concerns: + +- The directive owner transforms its syntax and registers the function with the React runtime. +- `@vitejs/plugin-rsc` owns bundler-level module identity, graph visibility, manifests, and reference resolution. + +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. + +The example separates two import graph shapes under [`src/features`](./src/features): + +- `mixed-directives` exports inline built-in and custom Server Functions from one RSC-reachable module. +- `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. + +This is a low-level integration example rather than a proposed high-level Server Function API. + +## Current E2E Coverage + +[`../../e2e/custom-server-function.test.ts`](../../e2e/custom-server-function.test.ts) verifies in both development and production build modes that: + +- a built-in Server Function and a custom Server Function can coexist in one module +- each function reaches the server and updates the rendered result +- a custom Server Function that is not statically imported by the RSC entry works through both the SSR and client proxy paths +- built-in, inline custom, and client-imported custom functions work as progressively enhanced forms without JavaScript +- 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 diff --git a/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts new file mode 100644 index 000000000..6b21c4cc4 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/custom-server-function-plugin.ts @@ -0,0 +1,103 @@ +import { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc' +import { + hasDirective, + transformDirectiveProxyExport, + transformHoistInlineDirective, + transformWrapExport, +} from '@vitejs/plugin-rsc/transforms' +import { parseAstAsync, type Plugin } from 'vite' + +const directive = 'use custom-server' +const pluginName = 'example:custom-server-function' + +// This intentionally mirrors the built-in "use server" pipeline through the +// public integration APIs, but uses a separate directive and plugin name and +// omits export-all expansion and action encryption. +// TODO: Give the custom directive observable runtime semantics so ownership +// swaps can assert the active registration path in addition to claim cleanup. +export function customServerFunctionPlugin(): Plugin { + let manager: RscPluginManager + + return { + name: pluginName, + configResolved(config) { + manager = getPluginApi(config)!.manager + }, + async transform(code, id) { + const environmentName = this.environment.name + if (!code.includes(directive)) { + manager.serverReferences.deleteClaim(pluginName, id) + return + } + + const reference = manager.serverReferences.resolve(id, 'rsc') + const ast = (await parseAstAsync(code)) as unknown as Parameters< + typeof transformHoistInlineDirective + >[1] + + if (environmentName === 'rsc') { + const runtime = (value: string, name: string) => + `$$CustomReactServer.registerServerReference(${value}, ${JSON.stringify(reference.referenceKey)}, ${JSON.stringify(name)})` + const result = hasDirective(ast.body, directive) + ? transformWrapExport(code, ast, { + runtime, + rejectNonAsyncFunction: true, + }) + : transformHoistInlineDirective(code, ast, { + directive, + runtime, + rejectNonAsyncFunction: true, + }) + if (!result.output.hasChanged()) { + manager.serverReferences.deleteClaim(pluginName, id) + return + } + + manager.serverReferences.replaceClaim(pluginName, id, { + ...reference, + exportNames: 'names' in result ? result.names : result.exportNames, + }) + result.output.prepend( + `import * as $$CustomReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, + ) + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: 'boundary' }), + } + } + + const result = transformDirectiveProxyExport(ast, { + code, + directive, + rejectNonAsyncFunction: true, + runtime: (name) => + `$$CustomReactClient.createServerReference(` + + `${JSON.stringify(reference.referenceKey + '#' + name)},` + + `$$CustomReactClient.callServer,` + + `undefined,` + + (this.environment.mode === 'dev' + ? `$$CustomReactClient.findSourceMapURL,` + : `undefined,`) + + `${JSON.stringify(name)})`, + }) + if (!result?.output.hasChanged()) { + manager.serverReferences.deleteClaim(pluginName, id) + return + } + + manager.serverReferences.replaceClaim(pluginName, id, { + ...reference, + exportNames: result.exportNames, + }) + const runtimeEnvironment = + environmentName === 'client' ? 'browser' : 'ssr' + result.output.prepend( + `import * as $$CustomReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`, + ) + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: 'boundary' }), + } + }, + } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/package.json b/packages/plugin-rsc/examples/custom-server-function/package.json new file mode 100644 index 000000000..8811c3d65 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/package.json @@ -0,0 +1,24 @@ +{ + "name": "@vitejs/plugin-rsc-examples-custom-server-function", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^8.1.4" + } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts new file mode 100644 index 000000000..5562fffd5 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/action.ts @@ -0,0 +1,8 @@ +'use custom-server' + +// @ts-ignore -- virtualized by @vitejs/plugin-rsc +import 'server-only' + +export async function incrementFromClient(previous: number) { + return previous + 1 +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/client.tsx b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/client.tsx new file mode 100644 index 000000000..567d0d50c --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/action-from-client/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import { useActionState } from 'react' +import { incrementFromClient } from './action.ts' + +export function ActionFromClient() { + const [count, action] = useActionState(incrementFromClient, 0) + return ( +
+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts new file mode 100644 index 000000000..8d559b043 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/actions.ts @@ -0,0 +1,24 @@ +let builtinCount = 0 +let customCount = 0 + +export const customLabel = 'Custom' + +export function getCounts() { + return { builtinCount, customCount } +} + +export async function incrementBuiltin() { + 'use server' + builtinCount++ +} + +export async function incrementCustom() { + 'use custom-server' + customCount++ +} + +export async function resetCounts() { + 'use server' + builtinCount = 0 + customCount = 0 +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx new file mode 100644 index 000000000..67257b6a8 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/features/mixed-directives/server.tsx @@ -0,0 +1,26 @@ +import { + customLabel, + getCounts, + incrementBuiltin, + incrementCustom, + resetCounts, +} from './actions.ts' + +export function MixedDirectives() { + const { builtinCount, customCount } = getCounts() + return ( + <> +
+ +
+
+ +
+
+ +
+ + ) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx new file mode 100644 index 000000000..5b48ebdfa --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.browser.tsx @@ -0,0 +1,138 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc' +import { GlobalErrorBoundary } from './error-boundary' +import { createRscRenderRequest } from './request' + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }) + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }) + setPayload(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data + }) + + // hydration + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + // implement server HMR by triggering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..c9cf5c4b3 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.rsc.tsx @@ -0,0 +1,122 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.tsx' + +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. +export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. + root: React.ReactNode + // server action return value of non-progressive enhancement case + returnValue?: { ok: boolean; data: unknown } + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState +} + +// the plugin by default assumes `rsc` entry having default export of request handler. +// however, how server entries are executed can be customized by registering own server handler. +export default { fetch: handler } + +async function handler(request: Request): Promise { + // differentiate RSC, SSR, action, etc. + const renderRequest = parseRenderRequest(request) + request = renderRequest.request + + // handle server function request + let returnValue: RscPayload['returnValue'] | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + let actionStatus: number | undefined + if (renderRequest.isAction === true) { + if (renderRequest.actionId) { + // action is called via `ReactClient.setServerCallback`. + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(renderRequest.actionId) + try { + const data = await action.apply(null, args) + returnValue = { ok: true, data } + } catch (e) { + returnValue = { ok: false, data: e } + actionStatus = 500 + } + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + // there's no single general obvious way to surface this error, + // so explicitly return classic 500 response. + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } + } + } + + // serialization from React VDOM tree to RSC stream. + // we render RSC stream after handling server function request + // so that new render reflects updated state from server function call + // to achieve single round trip to mutate and fetch from server. + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // Respond RSC stream without HTML rendering as decided by `RenderRequest` + if (renderRequest.isRsc) { + return new Response(rscStream, { + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + // Delegate to SSR environment for html rendering. + // The plugin provides `loadModule` helper to allow loading SSR environment entry module + // in RSC environment. however this can be customized by implementing own runtime communication + // e.g. `@cloudflare/vite-plugin`'s service binding. + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + // allow quick simulation of javascript disabled browser + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..7fc5a9564 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/entry.ssr.tsx @@ -0,0 +1,74 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + + // deserialize RSC stream back to React VDOM + let payload: Promise | undefined + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + // render html (traditional SSR) + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + // fallback to render an empty shell and run pure CSR on browser, + // which can replay server component error and trigger error boundary. + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/error-boundary.tsx new file mode 100644 index 000000000..39d916510 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/error-boundary.tsx @@ -0,0 +1,81 @@ +'use client' + +import React from 'react' + +// Minimal ErrorBoundary example to handle errors globally on browser +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode + errorComponent: React.FC<{ + error: Error + reset: () => void + }> +}> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const error = this.state.error + if (error) { + return + } + return this.props.children + } +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{' '}
+          {import.meta.env.DEV && 'message' in props.error
+            ? props.error.message
+            : '(Unknown)'}
+        
+ + + + ) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/framework/request.tsx b/packages/plugin-rsc/examples/custom-server-function/src/framework/request.tsx new file mode 100644 index 000000000..4c7c666e8 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/framework/request.tsx @@ -0,0 +1,58 @@ +// Framework conventions (arbitrary choices for this demo): +// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests +// - Use `x-rsc-action` header to pass server action ID +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +// Parsed request information used to route between RSC/SSR rendering and action handling. +// Created by parseRenderRequest() from incoming HTTP requests. +type RenderRequest = { + isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean // true if this is a server action call (POST request) + actionId?: string // server action ID from x-rsc-action header + request: Request // normalized Request with _.rsc suffix removed from URL + url: URL // normalized URL with _.rsc suffix removed +} + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString) + url.pathname += URL_POSTFIX + const headers = new Headers() + if (action) { + headers.set(HEADER_ACTION_ID, action.id) + } + return new Request(url.toString(), { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined + if (request.method === 'POST' && !actionId) { + throw new Error('Missing action id header for RSC action request') + } + return { + isRsc: true, + isAction, + actionId, + request: new Request(url, request), + url, + } + } else { + return { + isRsc: false, + isAction, + request, + url, + } + } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/src/root.tsx b/packages/plugin-rsc/examples/custom-server-function/src/root.tsx new file mode 100644 index 000000000..fbc00dfee --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/src/root.tsx @@ -0,0 +1,18 @@ +import { ActionFromClient } from './features/action-from-client/client.tsx' +import { MixedDirectives } from './features/mixed-directives/server.tsx' + +export function Root(_props: { url: URL }) { + return ( + + + + + Custom Server Function + + + + + + + ) +} diff --git a/packages/plugin-rsc/examples/custom-server-function/tsconfig.json b/packages/plugin-rsc/examples/custom-server-function/tsconfig.json new file mode 100644 index 000000000..b212cd7a7 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/custom-server-function/vite.config.ts b/packages/plugin-rsc/examples/custom-server-function/vite.config.ts new file mode 100644 index 000000000..bc3e9e658 --- /dev/null +++ b/packages/plugin-rsc/examples/custom-server-function/vite.config.ts @@ -0,0 +1,27 @@ +import react from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' +import { customServerFunctionPlugin } from './custom-server-function-plugin.ts' + +export default defineConfig({ + plugins: [customServerFunctionPlugin(), rsc(), react()], + environments: { + rsc: { + build: { + rollupOptions: { input: { index: './src/framework/entry.rsc.tsx' } }, + }, + }, + ssr: { + build: { + rollupOptions: { input: { index: './src/framework/entry.ssr.tsx' } }, + }, + }, + client: { + build: { + rollupOptions: { + input: { index: './src/framework/entry.browser.tsx' }, + }, + }, + }, + }, +}) diff --git a/packages/plugin-rsc/src/index.ts b/packages/plugin-rsc/src/index.ts index b3c2a7f0b..f1f6f65d8 100644 --- a/packages/plugin-rsc/src/index.ts +++ b/packages/plugin-rsc/src/index.ts @@ -3,4 +3,5 @@ export { type RscPluginOptions, getPluginApi, type PluginApi, + type RscPluginManager, } from './plugin' diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index f68db7a1e..a87a471ee 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -40,6 +40,10 @@ import { withResolvedIdProxy, } from './plugins/resolved-id-proxy' import { scanBuildStripPlugin } from './plugins/scan' +import { + ServerReferencesManager, + type ServerReferenceMeta, +} from './plugins/server-reference' import { parseCssVirtual, toCssVirtual, @@ -94,13 +98,6 @@ type ClientReferenceMeta = { groupChunkId?: string } -type ServerReferenceMeta = { - importId: string - referenceKey: string - // TODO: tree shake unused server functions - exportNames: string[] -} - const PKG_NAME = '@vitejs/plugin-rsc' const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` @@ -129,7 +126,7 @@ class RscPluginManager { clientReferenceMetaMap: Record = {} clientReferenceGroups: Record = {} - serverReferenceMetaMap: Record = {} + serverReferences: ServerReferencesManager = new ServerReferencesManager(this) serverResourcesMetaMap: Record = {} environmentImportMetaMap: Record< string, // sourceEnv @@ -362,9 +359,7 @@ export function vitePluginRscMinimal( } } if (parsed.type === 'server') { - let meta = Object.values(manager.serverReferenceMetaMap).find( - (meta) => meta.referenceKey === parsed.id, - ) + let meta = manager.serverReferences.findByReferenceKey(parsed.id) if (!meta) { // Server references decoded by `createFromReadableStream` with // `preserveServerReferences` can reach action loading without their @@ -383,9 +378,7 @@ export function vitePluginRscMinimal( await this.environment.transformRequest(parsed.id) } } catch {} - meta = Object.values(manager.serverReferenceMetaMap).find( - (meta) => meta.referenceKey === parsed.id, - ) + meta = manager.serverReferences.findByReferenceKey(parsed.id) } if (meta) { return `export {}` @@ -1996,18 +1989,18 @@ function vitePluginUseServer( useServerPluginOptions.environment?.browser ?? 'client' const debug = createDebug('vite-rsc:use-server') + const pluginName = 'rsc:use-server' return [ { - name: 'rsc:use-server', + name: pluginName, transform: { // TODO: cannot use filter because handler has cleanup side effect - // (`delete manager.serverReferenceMetaMap[id]`) that must run - // even when directive is removed (HMR case) + // that must run even when directive is removed (HMR case) // filter: { code: 'use server' }, async handler(code, id) { if (!code.includes('use server')) { - delete manager.serverReferenceMetaMap[id] + manager.serverReferences.deleteClaim(pluginName, id) return } let ast = await parseAstAsync(code) @@ -2024,9 +2017,9 @@ function vitePluginUseServer( } } - let normalizedId_: string | undefined - const getNormalizedId = () => { - if (!normalizedId_) { + let serverReference_: ServerReferenceMeta | undefined + const getServerReference = () => { + if (!serverReference_) { if ( this.environment.mode === 'dev' && id.includes('/node_modules/') @@ -2037,18 +2030,13 @@ function vitePluginUseServer( debug( `internal server reference created through a package imported in ${this.environment.name} environment: ${id}`, ) - id = cleanUrl(id) - } - if (manager.config.command === 'build') { - normalizedId_ = hashString(manager.toRelativeId(id)) - } else { - normalizedId_ = normalizeViteImportAnalysisUrl( - manager.server.environments[serverEnvironmentName]!, - id, - ) } + serverReference_ = manager.serverReferences.resolve( + id, + serverEnvironmentName, + ) } - return normalizedId_ + return serverReference_ } if (this.environment.name === serverEnvironmentName) { @@ -2061,7 +2049,7 @@ function vitePluginUseServer( const result = transformServerActionServer_(code, ast, { runtime: (value, name) => `$$ReactServer.registerServerReference(${value}, ${JSON.stringify( - getNormalizedId(), + getServerReference().referenceKey, )}, ${JSON.stringify(name)})`, rejectNonAsyncFunction: true, encode: enableEncryption @@ -2075,14 +2063,13 @@ function vitePluginUseServer( }) const output = result.output if (!result || !output.hasChanged()) { - delete manager.serverReferenceMetaMap[id] + manager.serverReferences.deleteClaim(pluginName, id) return } - manager.serverReferenceMetaMap[id] = { - importId: id, - referenceKey: getNormalizedId(), + manager.serverReferences.replaceClaim(pluginName, id, { + ...getServerReference(), exportNames: result.referenceNames, - } + }) const importSource = resolvePackage(`${PKG_NAME}/react/rsc/server`) output.prepend( `import * as $$ReactServer from "${importSource}";\n`, @@ -2101,7 +2088,10 @@ function vitePluginUseServer( } } else { if (!hasDirective(ast.body, 'use server')) { - delete manager.serverReferenceMetaMap[id] + // TODO: Inline server functions entering a non-RSC graph are + // unsupported and should throw an explicit validation error. + // https://github.com/vitejs/vite-plugin-react/issues/883#issuecomment-5029243311 + manager.serverReferences.deleteClaim(pluginName, id) return } const transformDirectiveProxyExport_ = withRollupError( @@ -2112,7 +2102,7 @@ function vitePluginUseServer( code, runtime: (name) => `$$ReactClient.createServerReference(` + - `${JSON.stringify(getNormalizedId() + '#' + name)},` + + `${JSON.stringify(getServerReference().referenceKey + '#' + name)},` + `$$ReactClient.callServer, ` + `undefined, ` + (this.environment.mode === 'dev' @@ -2122,14 +2112,19 @@ function vitePluginUseServer( directive: 'use server', rejectNonAsyncFunction: true, }) - if (!result) return + if (!result) { + manager.serverReferences.deleteClaim(pluginName, id) + return + } const output = result?.output - if (!output?.hasChanged()) return - manager.serverReferenceMetaMap[id] = { - importId: id, - referenceKey: getNormalizedId(), - exportNames: result.exportNames, + if (!output?.hasChanged()) { + manager.serverReferences.deleteClaim(pluginName, id) + return } + manager.serverReferences.replaceClaim(pluginName, id, { + ...getServerReference(), + exportNames: result.exportNames, + }) const name = this.environment.name === browserEnvironmentName ? 'browser' @@ -2151,7 +2146,7 @@ function vitePluginUseServer( return { code: `export {}`, map: null } } let code = '' - for (const meta of Object.values(manager.serverReferenceMetaMap)) { + for (const meta of manager.serverReferences.metaMap.values()) { const key = JSON.stringify(meta.referenceKey) const id = JSON.stringify(meta.importId) const exports = meta.exportNames diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts new file mode 100644 index 000000000..e428246b4 --- /dev/null +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert' +import type { RscPluginManager } from '../plugin' +import { hashString } from './utils' +import { cleanUrl, normalizeViteImportAnalysisUrl } from './vite-utils' + +export type ServerReferenceMeta = { + importId: string + referenceKey: string + // TODO: tree shake unused server functions + exportNames: string[] +} + +type ServerReferenceClaimMap = DefaultMap< + // exact transform module ID + string, + Map< + // claim owner + string, + ServerReferenceMeta + > +> + +export class ServerReferencesManager { + claimMap: ServerReferenceClaimMap = new DefaultMap(() => new Map()) + metaMap: Map = new Map() + + constructor(private manager: RscPluginManager) {} + + resolve(id: string, serverEnvironmentName: string): ServerReferenceMeta { + const importId = this.normalizeImportId(id) + const referenceKey = + this.manager.config.command === 'build' + ? hashString(this.manager.toRelativeId(importId)) + : normalizeViteImportAnalysisUrl( + this.manager.server.environments[serverEnvironmentName]!, + importId, + ) + return { importId, referenceKey, exportNames: [] } + } + + replaceClaim(owner: string, id: string, meta: ServerReferenceMeta): void { + this.claimMap.get(id).set(owner, meta) + this.metaMap = deriveMetaMap(this.claimMap) + } + + deleteClaim(owner: string, id: string): void { + const ownerMap = this.claimMap.get(id) + ownerMap.delete(owner) + if (ownerMap.size === 0) { + this.claimMap.delete(id) + } + this.metaMap = deriveMetaMap(this.claimMap) + } + + findByReferenceKey(referenceKey: string): ServerReferenceMeta | undefined { + for (const meta of this.metaMap.values()) { + if (meta.referenceKey === referenceKey) return meta + } + } + + private normalizeImportId(id: string): string { + return this.manager.config.command !== 'build' && + id.includes('/node_modules/') + ? cleanUrl(id) + : id + } +} + +function deriveMetaMap( + claimMap: ServerReferenceClaimMap, +): Map { + const normalizedClaimMap = new DefaultMap< + string, + Map + >(() => new Map()) + for (const claims of claimMap.values()) { + for (const [owner, claim] of claims) { + normalizedClaimMap.get(claim.importId).set(owner, claim) + } + } + + const metaMap = new Map() + for (const [importId, claims] of normalizedClaimMap) { + metaMap.set(importId, aggregateClaims(importId, claims)) + } + return metaMap +} + +function aggregateClaims( + importId: string, + claims: Map, +): ServerReferenceMeta { + let aggregate: ServerReferenceMeta | undefined + const exportOwners = new Map() + for (const [claimOwner, claim] of claims) { + // A mismatch indicates incompatible plugin integration claims, not an + // application authoring error. + if (!aggregate) { + aggregate = { + importId: claim.importId, + referenceKey: claim.referenceKey, + exportNames: [], + } + } else if ( + aggregate.importId !== claim.importId || + aggregate.referenceKey !== claim.referenceKey + ) { + throw new Error( + `[vite-rsc] conflicting server reference identity for '${importId}'`, + ) + } + for (const name of claim.exportNames) { + const existingOwner = exportOwners.get(name) + if (existingOwner && existingOwner !== claimOwner) { + throw new Error( + `[vite-rsc] server reference '${claim.referenceKey}#${name}' is claimed by both '${existingOwner}' and '${claimOwner}'`, + ) + } + exportOwners.set(name, claimOwner) + } + } + assert(aggregate) + aggregate.exportNames = [...exportOwners.keys()].sort() + return aggregate +} + +class DefaultMap extends Map { + constructor(private createDefault: (key: K) => V) { + super() + } + + override get(key: K): V { + if (super.has(key)) { + return super.get(key)! + } + const value = this.createDefault(key) + this.set(key, value) + return value + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c7be2173..f6272aafc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -655,6 +655,34 @@ importers: specifier: ^8.1.4 version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/custom-server-function: + dependencies: + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: latest + version: link:../../../plugin-react + '@vitejs/plugin-rsc': + specifier: latest + version: link:../.. + rsc-html-stream: + specifier: ^0.0.7 + version: 0.0.7 + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/e2e: devDependencies: '@rolldown/plugin-babel':