diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index 47519e639..8e98ce9e4 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -33,6 +33,7 @@ npm create vite@latest -- --template rsc - [`./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. +- [`./examples/node-stream`](./examples/node-stream) - Starter application served end to end through Node.js request, response, and stream APIs. - [`./examples/client-first`](./examples/client-first) - Experimental client-owned page that consumes RSC function results. - [`./examples/action-reachability`](./examples/action-reachability) - Route-aware dispatch for a retained server action. - [`./examples/browser-mode`](./examples/browser-mode) - Advanced setup that runs both RSC and React client environments in the browser with custom module loading. @@ -524,6 +525,20 @@ This module provides Vite-integrated RSC runtime APIs based on - `encodeReply`: Serializes server function arguments - `createTemporaryReferenceSet`: Creates a temporary reference set shared by deserialization and reply serialization +### Node.js stream APIs variants + +Node.js environments can use runtime-specific entry points that integrate React's native Node.js stream APIs with Vite's generated manifests: + +| Environment | Entry point | Node.js stream API | +| ------------ | ------------------------------------ | ------------------------ | +| RSC server | `@vitejs/plugin-rsc/rsc/server.node` | `renderToPipeableStream` | +| RSC client | `@vitejs/plugin-rsc/rsc/client.node` | `createFromNodeStream` | +| RSC static | `@vitejs/plugin-rsc/rsc/static.node` | `prerenderToNodeStream` | +| RSC combined | `@vitejs/plugin-rsc/rsc.node` | Server and client APIs | +| SSR | `@vitejs/plugin-rsc/ssr.node` | `createFromNodeStream` | + +See [`examples/node-stream`](./examples/node-stream) for an end-to-end Node.js handler using these APIs. + ### `@vitejs/plugin-rsc/browser` This module provides Vite-integrated RSC runtime APIs based on diff --git a/packages/plugin-rsc/e2e/node-stream.test.ts b/packages/plugin-rsc/e2e/node-stream.test.ts new file mode 100644 index 000000000..2f00c9073 --- /dev/null +++ b/packages/plugin-rsc/e2e/node-stream.test.ts @@ -0,0 +1,13 @@ +import { test } from '@playwright/test' +import { useFixture } from './fixture' +import { defineStarterTest } from './starter' + +test.describe('dev', () => { + const f = useFixture({ root: 'examples/node-stream', mode: 'dev' }) + defineStarterTest(f) +}) + +test.describe('build', () => { + const f = useFixture({ root: 'examples/node-stream', mode: 'build' }) + defineStarterTest(f) +}) diff --git a/packages/plugin-rsc/examples/node-stream/README.md b/packages/plugin-rsc/examples/node-stream/README.md new file mode 100644 index 000000000..57989e58c --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/README.md @@ -0,0 +1,17 @@ +# Node.js streams + +This example uses the same application as [`examples/starter`](../starter), but its framework layer keeps rendering on native Node.js streams from the incoming request through the outgoing response: + +```text +IncomingMessage + -> RSC renderToPipeableStream + -> PassThrough branches + -> SSR createFromNodeStream + -> React DOM renderToPipeableStream + -> RSC payload injection Transform + -> ServerResponse +``` + +[`vite.config.ts`](./vite.config.ts) sets `serverHandler: false` and installs the handler in both `configureServer` and `configurePreviewServer`. During development, the plugin imports the RSC entry through Vite's RSC environment runner so that server HMR remains active. Preview imports the built RSC entry directly. + +[`rsc-html-stream.server.node.ts`](./src/framework/rsc-html-stream.server.node.ts) is the Node.js stream counterpart of `rsc-html-stream/server`. The browser entry continues using `rsc-html-stream/client`, because browser fetch and hydration use Web streams. diff --git a/packages/plugin-rsc/examples/node-stream/package.json b/packages/plugin-rsc/examples/node-stream/package.json new file mode 100644 index 000000000..046cffaf0 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/package.json @@ -0,0 +1,26 @@ +{ + "name": "@vitejs/plugin-rsc-examples-node-stream", + "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/busboy": "^1.5.4", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "busboy": "^1.6.0", + "rsc-html-stream": "^0.0.8", + "vite": "^8.2.1" + } +} diff --git a/packages/plugin-rsc/examples/node-stream/public/vite.svg b/packages/plugin-rsc/examples/node-stream/public/vite.svg new file mode 100644 index 000000000..ee9fadaf9 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/public/vite.svg @@ -0,0 +1 @@ + diff --git a/packages/plugin-rsc/examples/node-stream/src/action.tsx b/packages/plugin-rsc/examples/node-stream/src/action.tsx new file mode 100644 index 000000000..4fc55d65b --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/action.tsx @@ -0,0 +1,11 @@ +'use server' + +let serverCounter = 0 + +export async function getServerCounter() { + return serverCounter +} + +export async function updateServerCounter(change: number) { + serverCounter += change +} diff --git a/packages/plugin-rsc/examples/node-stream/src/assets/react.svg b/packages/plugin-rsc/examples/node-stream/src/assets/react.svg new file mode 100644 index 000000000..a6fa09ba2 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/assets/react.svg @@ -0,0 +1,3 @@ + diff --git a/packages/plugin-rsc/examples/node-stream/src/client.tsx b/packages/plugin-rsc/examples/node-stream/src/client.tsx new file mode 100644 index 000000000..29bb5d367 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import React from 'react' + +export function ClientCounter() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/node-stream/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/node-stream/src/framework/entry.browser.tsx new file mode 100644 index 000000000..00dc9beef --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/entry.browser.tsx @@ -0,0 +1,124 @@ +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() { + let setPayload: (v: RscPayload) => void + + const initialPayload = await createFromReadableStream(rscStream) + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } + + 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 + }) + + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +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 && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + !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/node-stream/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/node-stream/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..744f7d931 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/entry.rsc.tsx @@ -0,0 +1,111 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { PassThrough } from 'node:stream' +import { finished } from 'node:stream/promises' +import { + renderToPipeableStream, + loadServerAction, + decodeReply, + decodeReplyFromBusboy, + decodeAction, + decodeFormState, + createTemporaryReferenceSet, +} from '@vitejs/plugin-rsc/rsc/server.node' +import busboy from 'busboy' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root' +import { + parseRenderRequest, + readRequestBody, + requestToFormData, +} from './request.node' + +export type RscPayload = { + root: React.ReactNode + returnValue?: { ok: boolean; data: unknown } + formState?: ReactFormState +} + +export default async function handler( + request: IncomingMessage, + response: ServerResponse, +): Promise { + const renderRequest = parseRenderRequest(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) { + const contentType = request.headers['content-type'] + temporaryReferences = createTemporaryReferenceSet() + let args: unknown[] + if (contentType?.startsWith('multipart/form-data')) { + const parser = busboy({ headers: request.headers }) + const argsPromise = decodeReplyFromBusboy(parser, { + temporaryReferences, + }) + request.pipe(parser) + args = await argsPromise + } else { + const body = await readRequestBody(request) + args = await decodeReply(body.toString(), { 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 { + const formData = await requestToFormData(request) + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + response.statusCode = 500 + response.end('Internal Server Error: server action failed') + return + } + } + } + + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const pipeableStream = renderToPipeableStream(rscPayload, { + temporaryReferences, + }) + + if (renderRequest.isRsc) { + response.statusCode = actionStatus ?? 200 + response.setHeader('content-type', 'text/x-component;charset=utf-8') + pipeableStream.pipe(response) + await finished(response) + return + } + + const rscStream = new PassThrough() + pipeableStream.pipe(rscStream) + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr') + >('ssr', 'index') + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + response.statusCode = ssrResult.status ?? 200 + response.setHeader('content-type', 'text/html') + ssrResult.stream.pipe(response) + await finished(response) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/node-stream/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/node-stream/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..62f9dc062 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/entry.ssr.tsx @@ -0,0 +1,88 @@ +import { PassThrough, type Readable } from 'node:stream' +import { + createFromNodeStream, + getClientEntryUrl, +} from '@vitejs/plugin-rsc/ssr.node' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import type { RenderToPipeableStreamOptions } from 'react-dom/server' +import { renderToPipeableStream } from 'react-dom/server.node' +import type { RscPayload } from './entry.rsc' +import { injectRSCPayload } from './rsc-html-stream.server.node' + +export async function renderHTML( + rscStream: Readable, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: Readable; status?: number }> { + const rscStreamForSsr = new PassThrough() + const rscStreamForBrowser = options.debugNojs ? undefined : new PassThrough() + rscStream.pipe(rscStreamForSsr) + if (rscStreamForBrowser) { + rscStream.pipe(rscStreamForBrowser) + } + + let payload: Promise | undefined + function SsrRoot() { + payload ??= createFromNodeStream(rscStreamForSsr) + return React.use(payload).root + } + + const bootstrapScriptContent = `import(${JSON.stringify(getClientEntryUrl())})` + let htmlStream: Readable + let status: number | undefined + try { + htmlStream = await renderToNodeStream(, { + bootstrapScriptContent: options.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options.nonce, + formState: options.formState, + }) + } catch (e) { + status = 500 + htmlStream = await renderToNodeStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options.debugNojs ? '' : bootstrapScriptContent), + nonce: options.nonce, + }, + ) + } + + if (rscStreamForBrowser) { + htmlStream = htmlStream.pipe( + injectRSCPayload(rscStreamForBrowser, { nonce: options.nonce }), + ) + } + + return { stream: htmlStream, status } +} + +function renderToNodeStream( + node: React.ReactNode, + options: RenderToPipeableStreamOptions, +): Promise { + return new Promise((resolve, reject) => { + const output = new PassThrough() + const stream = renderToPipeableStream(node, { + ...options, + onShellReady() { + stream.pipe(output) + resolve(output) + }, + onShellError(error) { + reject(error) + }, + }) + }) +} diff --git a/packages/plugin-rsc/examples/node-stream/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/node-stream/src/framework/error-boundary.tsx new file mode 100644 index 000000000..1c7e047c1 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/error-boundary.tsx @@ -0,0 +1,76 @@ +'use client' + +import React from 'react' + +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +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 + } +} + +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/node-stream/src/framework/request.node.ts b/packages/plugin-rsc/examples/node-stream/src/framework/request.node.ts new file mode 100644 index 000000000..18e0dc261 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/request.node.ts @@ -0,0 +1,54 @@ +import type { IncomingMessage } from 'node:http' +import { Readable } from 'node:stream' +import { HEADER_ACTION_ID, URL_POSTFIX } from './request' + +type RenderRequest = { + isRsc: boolean + isAction: boolean + actionId?: string + url: URL +} + +export function parseRenderRequest(request: IncomingMessage): RenderRequest { + const host = request.headers.host ?? 'localhost' + const url = new URL(request.url ?? '/', `http://${host}`) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const actionId = request.headers[HEADER_ACTION_ID] + if (Array.isArray(actionId)) { + throw new Error(`Multiple ${HEADER_ACTION_ID} headers`) + } + if (isAction && !actionId) { + throw new Error('Missing action id header for RSC action request') + } + return { isRsc: true, isAction, actionId, url } + } + return { isRsc: false, isAction, url } +} + +export async function readRequestBody( + request: IncomingMessage, +): Promise { + const chunks: Buffer[] = [] + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return Buffer.concat(chunks) +} + +export async function requestToFormData( + request: IncomingMessage, +): Promise { + const headers = new Headers() + const contentType = request.headers['content-type'] + if (contentType) headers.set('content-type', contentType) + const init: RequestInit & { duplex: 'half' } = { + method: 'POST', + headers, + body: Readable.toWeb(request) as ReadableStream, + duplex: 'half', + } + const webRequest = new Request('http://localhost', init) + return webRequest.formData() +} diff --git a/packages/plugin-rsc/examples/node-stream/src/framework/request.tsx b/packages/plugin-rsc/examples/node-stream/src/framework/request.tsx new file mode 100644 index 000000000..678433561 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/request.tsx @@ -0,0 +1,19 @@ +export const URL_POSTFIX = '_.rsc' +export const HEADER_ACTION_ID = 'x-rsc-action' + +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, + }) +} diff --git a/packages/plugin-rsc/examples/node-stream/src/framework/rsc-html-stream.server.node.ts b/packages/plugin-rsc/examples/node-stream/src/framework/rsc-html-stream.server.node.ts new file mode 100644 index 000000000..726d84a00 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/rsc-html-stream.server.node.ts @@ -0,0 +1,96 @@ +import { Transform, type Readable, type TransformCallback } from 'node:stream' + +// Node stream counterpart of rsc-html-stream/server. Flight chunks are injected +// before the closing document tags while both inputs continue streaming. +const trailer = '' + +export function injectRSCPayload( + rscStream: Readable, + options?: { nonce?: string }, +): Transform { + const decoder = new TextDecoder() + let htmlTail = '' + let timer: NodeJS.Timeout | undefined + let rscPromise: Promise | undefined + + const transform = new Transform({ + transform(chunk: Buffer, _encoding, callback: TransformCallback) { + htmlTail += decoder.decode(chunk, { stream: true }) + schedule() + callback() + }, + flush(callback: TransformCallback) { + if (timer) clearTimeout(timer) + flushHtml(false) + void startRsc().then(() => { + htmlTail += decoder.decode() + flushHtml(true) + transform.push(trailer) + callback() + }, callback) + }, + }) + + function schedule() { + if (timer) return + timer = setTimeout(() => { + timer = undefined + flushHtml(false) + void startRsc().catch((error) => transform.destroy(error)) + }, 0) + } + + function flushHtml(final: boolean) { + const keep = final ? 0 : trailer.length + const length = Math.max(0, htmlTail.length - keep) + if (length > 0) { + transform.push(htmlTail.slice(0, length)) + htmlTail = htmlTail.slice(length) + } + if (final) { + transform.push( + htmlTail.endsWith(trailer) + ? htmlTail.slice(0, -trailer.length) + : htmlTail, + ) + htmlTail = '' + } + } + + function startRsc(): Promise { + return (rscPromise ??= writeRscStream(rscStream, transform, options?.nonce)) + } + + return transform +} + +async function writeRscStream( + rscStream: Readable, + transform: Transform, + nonce: string | undefined, +) { + const decoder = new TextDecoder('utf-8', { fatal: true }) + for await (const chunk of rscStream) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + try { + writeChunk(JSON.stringify(decoder.decode(bytes, { stream: true }))) + } catch { + writeChunk( + `Uint8Array.from(atob(${JSON.stringify(bytes.toString('base64'))}), m => m.codePointAt(0))`, + ) + } + } + const remaining = decoder.decode() + if (remaining) writeChunk(JSON.stringify(remaining)) + + function writeChunk(chunk: string) { + const script = `(self.__FLIGHT_DATA||=[]).push(${chunk})` + transform.push( + `${escapeScript(script)}`, + ) + } +} + +function escapeScript(script: string): string { + return script.replace(/