From 7cc792358a90ab244b50eb1714a9e9c4935dcc3f Mon Sep 17 00:00:00 2001 From: Daniel Saewitz Date: Mon, 6 Jul 2026 00:13:00 -0400 Subject: [PATCH 01/23] feat(plugin-rsc): expose Node.js stream APIs (renderToPipeableStream, createFromNodeStream) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-export Node.js-native stream variants from react-server-dom-webpack as new entry points (@vitejs/plugin-rsc/rsc.node, ssr.node) without duplicating existing code — each .node file re-exports from its edge counterpart and adds only the Node-specific APIs. Closes #1162 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/plugin-rsc/src/react/rsc.node.ts | 25 ++++++++++++++++ packages/plugin-rsc/src/react/ssr.node.ts | 16 +++++++++++ packages/plugin-rsc/src/rsc.node.tsx | 35 +++++++++++++++++++++++ packages/plugin-rsc/src/ssr.node.tsx | 3 ++ packages/plugin-rsc/src/types/index.ts | 9 ++++++ packages/plugin-rsc/tsdown.config.ts | 4 +++ 6 files changed, 92 insertions(+) create mode 100644 packages/plugin-rsc/src/react/rsc.node.ts create mode 100644 packages/plugin-rsc/src/react/ssr.node.ts create mode 100644 packages/plugin-rsc/src/rsc.node.tsx create mode 100644 packages/plugin-rsc/src/ssr.node.tsx diff --git a/packages/plugin-rsc/src/react/rsc.node.ts b/packages/plugin-rsc/src/react/rsc.node.ts new file mode 100644 index 000000000..3ba5ebba7 --- /dev/null +++ b/packages/plugin-rsc/src/react/rsc.node.ts @@ -0,0 +1,25 @@ +// @ts-ignore +import * as ReactServerNode from '@vitejs/plugin-rsc/vendor/react-server-dom/server.node' +import { createClientManifest } from '../core/rsc' +import type { PipeableStream, RenderToPipeableStreamOptions } from '../types' + +export * from './rsc' + +export function renderToPipeableStream( + data: T, + options?: RenderToPipeableStreamOptions, + extraOptions?: { + /** + * @internal + */ + onClientReference?: (metadata: { id: string; name: string }) => void + }, +): PipeableStream { + return ReactServerNode.renderToPipeableStream( + data, + createClientManifest({ + onClientReference: extraOptions?.onClientReference, + }), + options, + ) +} diff --git a/packages/plugin-rsc/src/react/ssr.node.ts b/packages/plugin-rsc/src/react/ssr.node.ts new file mode 100644 index 000000000..90e71abe6 --- /dev/null +++ b/packages/plugin-rsc/src/react/ssr.node.ts @@ -0,0 +1,16 @@ +// @ts-ignore +import * as ReactClientNode from '@vitejs/plugin-rsc/vendor/react-server-dom/client.node' +import { createServerConsumerManifest } from '../core/ssr' +import type { CreateFromNodeStreamOptions } from '../types' + +export * from './ssr' + +export function createFromNodeStream( + stream: import('node:stream').Readable, + options: CreateFromNodeStreamOptions = {}, +): Promise { + return ReactClientNode.createFromNodeStream(stream, { + serverConsumerManifest: createServerConsumerManifest(), + ...options, + }) +} diff --git a/packages/plugin-rsc/src/rsc.node.tsx b/packages/plugin-rsc/src/rsc.node.tsx new file mode 100644 index 000000000..4e81ed601 --- /dev/null +++ b/packages/plugin-rsc/src/rsc.node.tsx @@ -0,0 +1,35 @@ +import assetsManifest from 'virtual:vite-rsc/assets-manifest' +import type { ResolvedAssetDeps } from './plugin' +import { renderToPipeableStream as originalRenderToPipeableStream } from './react/rsc.node' +import type { PipeableStream } from './types' + +export * from './rsc' + +export function renderToPipeableStream( + data: T, + options?: object, + extraOptions?: { + /** + * @experimental + */ + onClientReference?: (metadata: { + id: string + name: string + deps: ResolvedAssetDeps + }) => void + }, +): PipeableStream { + return originalRenderToPipeableStream(data, options, { + onClientReference(metadata) { + const deps = assetsManifest.clientReferenceDeps[metadata.id] ?? { + js: [], + css: [], + } + extraOptions?.onClientReference?.({ + id: metadata.id, + name: metadata.name, + deps, + }) + }, + }) +} diff --git a/packages/plugin-rsc/src/ssr.node.tsx b/packages/plugin-rsc/src/ssr.node.tsx new file mode 100644 index 000000000..d4a1dfe29 --- /dev/null +++ b/packages/plugin-rsc/src/ssr.node.tsx @@ -0,0 +1,3 @@ +export * from './ssr' + +export { createFromNodeStream } from './react/ssr.node' diff --git a/packages/plugin-rsc/src/types/index.ts b/packages/plugin-rsc/src/types/index.ts index 81df2c56f..adb46a89e 100644 --- a/packages/plugin-rsc/src/types/index.ts +++ b/packages/plugin-rsc/src/types/index.ts @@ -119,6 +119,15 @@ type DebugChannel = { writable?: WritableStream } +export interface PipeableStream { + pipe(destination: T): T + abort(reason?: unknown): void +} + +export type RenderToPipeableStreamOptions = RenderToReadableStreamOptions + +export type CreateFromNodeStreamOptions = CreateFromReadableStreamEdgeOptions + // TODO: for now keep them unknown // export type ServerTemporaryReferenceSet = WeakMap // export type ClientTemporaryReferenceSet = Map diff --git a/packages/plugin-rsc/tsdown.config.ts b/packages/plugin-rsc/tsdown.config.ts index 04fb8b73e..c190a7a8a 100644 --- a/packages/plugin-rsc/tsdown.config.ts +++ b/packages/plugin-rsc/tsdown.config.ts @@ -7,20 +7,24 @@ export default defineConfig({ 'src/plugin.ts', 'src/browser.ts', 'src/ssr.tsx', + 'src/ssr.node.tsx', 'src/rsc.tsx', 'src/rsc/server.tsx', 'src/rsc/client.ts', 'src/rsc/static.tsx', + 'src/rsc.node.tsx', 'src/core/browser.ts', 'src/core/ssr.ts', 'src/core/rsc.ts', 'src/core/plugin.ts', 'src/react/browser.ts', 'src/react/ssr.ts', + 'src/react/ssr.node.ts', 'src/react/rsc.ts', 'src/react/rsc/server.ts', 'src/react/rsc/client.ts', 'src/react/rsc/static.ts', + 'src/react/rsc.node.ts', 'src/transforms/index.ts', 'src/plugins/cjs.ts', 'src/utils/rpc.ts', From b1baf481d5128ec3d2d7f28deb0963b6e01ab305 Mon Sep 17 00:00:00 2001 From: Daniel Saewitz Date: Sun, 16 Aug 2026 11:15:13 -0400 Subject: [PATCH 02/23] refactor(plugin-rsc): align Node.js stream APIs with sub-path structure Restructure Node.js stream entry points from flat paths (/rsc.node, /ssr.node) to match the existing sub-path pattern: - /rsc/server.node (renderToPipeableStream) - /rsc/client.node (createFromNodeStream) - /rsc/static.node (prerenderToNodeStream) - /rsc.node (barrel re-export of server.node + client.node) Add .node vendor subpaths to optimizeDeps.include so CJS vendor files are properly pre-bundled in dev mode. Add examples/node-stream demonstrating Node.js stream APIs with an e2e test covering both dev and build modes. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/plugin-rsc/e2e/node-stream.test.ts | 32 +++++ .../examples/node-stream/package.json | 24 ++++ .../examples/node-stream/src/client.tsx | 12 ++ .../src/framework/entry.browser.tsx | 124 ++++++++++++++++++ .../node-stream/src/framework/entry.rsc.tsx | 102 ++++++++++++++ .../node-stream/src/framework/entry.ssr.tsx | 65 +++++++++ .../src/framework/error-boundary.tsx | 76 +++++++++++ .../node-stream/src/framework/request.tsx | 53 ++++++++ .../examples/node-stream/src/root.tsx | 20 +++ .../examples/node-stream/tsconfig.json | 13 ++ .../examples/node-stream/vite.config.ts | 36 +++++ packages/plugin-rsc/src/plugin.ts | 4 + .../plugin-rsc/src/react/rsc/client.node.ts | 31 +++++ .../react/{rsc.node.ts => rsc/server.node.ts} | 16 +-- .../plugin-rsc/src/react/rsc/static.node.ts | 27 ++++ packages/plugin-rsc/src/react/ssr.node.ts | 16 --- packages/plugin-rsc/src/rsc.node.tsx | 40 +----- packages/plugin-rsc/src/rsc/client.node.ts | 3 + packages/plugin-rsc/src/rsc/server.node.tsx | 32 +++++ packages/plugin-rsc/src/rsc/static.node.tsx | 29 ++++ packages/plugin-rsc/src/ssr.node.tsx | 3 - packages/plugin-rsc/tsdown.config.ts | 9 +- pnpm-lock.yaml | 28 ++++ 23 files changed, 730 insertions(+), 65 deletions(-) create mode 100644 packages/plugin-rsc/e2e/node-stream.test.ts create mode 100644 packages/plugin-rsc/examples/node-stream/package.json create mode 100644 packages/plugin-rsc/examples/node-stream/src/client.tsx create mode 100644 packages/plugin-rsc/examples/node-stream/src/framework/entry.browser.tsx create mode 100644 packages/plugin-rsc/examples/node-stream/src/framework/entry.rsc.tsx create mode 100644 packages/plugin-rsc/examples/node-stream/src/framework/entry.ssr.tsx create mode 100644 packages/plugin-rsc/examples/node-stream/src/framework/error-boundary.tsx create mode 100644 packages/plugin-rsc/examples/node-stream/src/framework/request.tsx create mode 100644 packages/plugin-rsc/examples/node-stream/src/root.tsx create mode 100644 packages/plugin-rsc/examples/node-stream/tsconfig.json create mode 100644 packages/plugin-rsc/examples/node-stream/vite.config.ts create mode 100644 packages/plugin-rsc/src/react/rsc/client.node.ts rename packages/plugin-rsc/src/react/{rsc.node.ts => rsc/server.node.ts} (67%) create mode 100644 packages/plugin-rsc/src/react/rsc/static.node.ts delete mode 100644 packages/plugin-rsc/src/react/ssr.node.ts create mode 100644 packages/plugin-rsc/src/rsc/client.node.ts create mode 100644 packages/plugin-rsc/src/rsc/server.node.tsx create mode 100644 packages/plugin-rsc/src/rsc/static.node.tsx delete mode 100644 packages/plugin-rsc/src/ssr.node.tsx 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..795f8025d --- /dev/null +++ b/packages/plugin-rsc/e2e/node-stream.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test' +import { useFixture } from './fixture' +import { expectNoPageError, waitForHydration } from './helper' + +test.describe('dev', () => { + const f = useFixture({ root: 'examples/node-stream', mode: 'dev' }) + defineTests(f) +}) + +test.describe('build', () => { + const f = useFixture({ root: 'examples/node-stream', mode: 'build' }) + defineTests(f) +}) + +function defineTests(f: ReturnType) { + test('basic render', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.locator('h1')).toHaveText('RSC Node Stream') + await expect(page.locator('[data-testid="url"]')).toContainText('URL: /') + }) + + test('client component hydration', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + const button = page.locator('[data-testid="counter"]') + await expect(button).toHaveText('Count: 0') + await button.click() + await expect(button).toHaveText('Count: 1') + }) +} 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..ebe91b039 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/package.json @@ -0,0 +1,24 @@ +{ + "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/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.8", + "vite": "^8.2.1" + } +} 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..1937637a3 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/client.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useState } from 'react' + +export function Counter() { + const [count, setCount] = 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..da1b97258 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/entry.rsc.tsx @@ -0,0 +1,102 @@ +import { PassThrough, Readable } from 'node:stream' +import { + renderToPipeableStream, + loadServerAction, + decodeReply, + decodeAction, + decodeFormState, + createTemporaryReferenceSet, +} from '@vitejs/plugin-rsc/rsc/server.node' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.tsx' + +export type RscPayload = { + root: React.ReactNode + returnValue?: { ok: boolean; data: unknown } + formState?: ReactFormState +} + +export default { fetch: handler } + +async function handler(request: Request): Promise { + const renderRequest = parseRenderRequest(request) + request = renderRequest.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.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 { + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } + } + } + + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const pipeableStream = renderToPipeableStream( + rscPayload, + rscOptions, + ) + + const passThrough = new PassThrough() + pipeableStream.pipe(passThrough) + const webStream = Readable.toWeb(passThrough) as ReadableStream + + if (renderRequest.isRsc) { + return new Response(webStream, { + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const ssrResult = await ssrEntryModule.renderHTML(webStream, { + formState, + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + 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/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..65b4dbd56 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/entry.ssr.tsx @@ -0,0 +1,65 @@ +import { + createFromReadableStream, + getClientEntryUrl, +} 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 }> { + const [rscStream1, rscStream2] = rscStream.tee() + + let payload: Promise | undefined + function SsrRoot() { + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + const bootstrapScriptContent = `import(${JSON.stringify(getClientEntryUrl())})` + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } +} 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.tsx b/packages/plugin-rsc/examples/node-stream/src/framework/request.tsx new file mode 100644 index 000000000..4cf961973 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/request.tsx @@ -0,0 +1,53 @@ +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +type RenderRequest = { + isRsc: boolean + isAction: boolean + actionId?: string + request: Request + url: URL +} + +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/node-stream/src/root.tsx b/packages/plugin-rsc/examples/node-stream/src/root.tsx new file mode 100644 index 000000000..66c6a9b2c --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/root.tsx @@ -0,0 +1,20 @@ +import { Counter } from './client.tsx' + +export function Root(props: { url: URL }) { + return ( + + + + + RSC Node Stream + + +
+

RSC Node Stream

+

URL: {props.url?.pathname}

+ +
+ + + ) +} diff --git a/packages/plugin-rsc/examples/node-stream/tsconfig.json b/packages/plugin-rsc/examples/node-stream/tsconfig.json new file mode 100644 index 000000000..9cc91d213 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["src"] +} diff --git a/packages/plugin-rsc/examples/node-stream/vite.config.ts b/packages/plugin-rsc/examples/node-stream/vite.config.ts new file mode 100644 index 000000000..cae15e5ab --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/vite.config.ts @@ -0,0 +1,36 @@ +import react from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [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/plugin.ts b/packages/plugin-rsc/src/plugin.ts index b29404cbc..fea033db3 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -578,6 +578,7 @@ export default function vitePluginRsc( 'react-dom/server.edge', 'react-dom/static.edge', `${reactServerDomPackageName}/client.edge`, + `${reactServerDomPackageName}/client.node`, ], exclude: [PKG_NAME, ...optimizeDepsExclude], }, @@ -604,8 +605,11 @@ export default function vitePluginRsc( 'react/jsx-runtime', 'react/jsx-dev-runtime', `${reactServerDomPackageName}/server.edge`, + `${reactServerDomPackageName}/server.node`, `${reactServerDomPackageName}/static.edge`, + `${reactServerDomPackageName}/static.node`, `${reactServerDomPackageName}/client.edge`, + `${reactServerDomPackageName}/client.node`, ], exclude: [PKG_NAME, ...optimizeDepsExclude], }, diff --git a/packages/plugin-rsc/src/react/rsc/client.node.ts b/packages/plugin-rsc/src/react/rsc/client.node.ts new file mode 100644 index 000000000..d0873f101 --- /dev/null +++ b/packages/plugin-rsc/src/react/rsc/client.node.ts @@ -0,0 +1,31 @@ +// @ts-ignore +import * as ReactClientNode from '@vitejs/plugin-rsc/vendor/react-server-dom/client.node' +import { + createServerDecodeClientManifest, + createServerManifest, +} from '../../core/rsc' +import type { CreateFromNodeStreamOptions } from '../../types' + +export * from './client' + +export function createFromNodeStream( + stream: import('node:stream').Readable, + options: CreateFromNodeStreamOptions = {}, + extraOptions?: { + /** + * @experimental + * @default false + */ + preserveServerReferences?: boolean + }, +): Promise { + return ReactClientNode.createFromNodeStream(stream, { + serverConsumerManifest: { + serverModuleMap: createServerManifest({ + preserveServerReferences: extraOptions?.preserveServerReferences, + }), + moduleMap: createServerDecodeClientManifest(), + }, + ...options, + }) +} diff --git a/packages/plugin-rsc/src/react/rsc.node.ts b/packages/plugin-rsc/src/react/rsc/server.node.ts similarity index 67% rename from packages/plugin-rsc/src/react/rsc.node.ts rename to packages/plugin-rsc/src/react/rsc/server.node.ts index 3ba5ebba7..c9f0e17f6 100644 --- a/packages/plugin-rsc/src/react/rsc.node.ts +++ b/packages/plugin-rsc/src/react/rsc/server.node.ts @@ -1,19 +1,17 @@ // @ts-ignore import * as ReactServerNode from '@vitejs/plugin-rsc/vendor/react-server-dom/server.node' -import { createClientManifest } from '../core/rsc' -import type { PipeableStream, RenderToPipeableStreamOptions } from '../types' +import { + createClientManifest, + type CreateClientManifestOptions, +} from '../../core/rsc' +import type { PipeableStream, RenderToPipeableStreamOptions } from '../../types' -export * from './rsc' +export * from './server' export function renderToPipeableStream( data: T, options?: RenderToPipeableStreamOptions, - extraOptions?: { - /** - * @internal - */ - onClientReference?: (metadata: { id: string; name: string }) => void - }, + extraOptions?: CreateClientManifestOptions, ): PipeableStream { return ReactServerNode.renderToPipeableStream( data, diff --git a/packages/plugin-rsc/src/react/rsc/static.node.ts b/packages/plugin-rsc/src/react/rsc/static.node.ts new file mode 100644 index 000000000..3a2d06f47 --- /dev/null +++ b/packages/plugin-rsc/src/react/rsc/static.node.ts @@ -0,0 +1,27 @@ +// @ts-ignore +import * as ReactStaticNode from '@vitejs/plugin-rsc/vendor/react-server-dom/static.node' +import { + createClientManifest, + type CreateClientManifestOptions, +} from '../../core/rsc' +import type { RenderToPipeableStreamOptions } from '../../types' + +export * from './static' + +export interface PrerenderToNodeStreamResult { + prelude: import('node:stream').Readable +} + +export function prerenderToNodeStream( + data: T, + options?: RenderToPipeableStreamOptions, + extraOptions?: CreateClientManifestOptions, +): Promise { + return ReactStaticNode.prerenderToNodeStream( + data, + createClientManifest({ + onClientReference: extraOptions?.onClientReference, + }), + options, + ) +} diff --git a/packages/plugin-rsc/src/react/ssr.node.ts b/packages/plugin-rsc/src/react/ssr.node.ts deleted file mode 100644 index 90e71abe6..000000000 --- a/packages/plugin-rsc/src/react/ssr.node.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-ignore -import * as ReactClientNode from '@vitejs/plugin-rsc/vendor/react-server-dom/client.node' -import { createServerConsumerManifest } from '../core/ssr' -import type { CreateFromNodeStreamOptions } from '../types' - -export * from './ssr' - -export function createFromNodeStream( - stream: import('node:stream').Readable, - options: CreateFromNodeStreamOptions = {}, -): Promise { - return ReactClientNode.createFromNodeStream(stream, { - serverConsumerManifest: createServerConsumerManifest(), - ...options, - }) -} diff --git a/packages/plugin-rsc/src/rsc.node.tsx b/packages/plugin-rsc/src/rsc.node.tsx index 4e81ed601..429ab0e63 100644 --- a/packages/plugin-rsc/src/rsc.node.tsx +++ b/packages/plugin-rsc/src/rsc.node.tsx @@ -1,35 +1,7 @@ -import assetsManifest from 'virtual:vite-rsc/assets-manifest' -import type { ResolvedAssetDeps } from './plugin' -import { renderToPipeableStream as originalRenderToPipeableStream } from './react/rsc.node' -import type { PipeableStream } from './types' +export { + encryptActionBoundArgs, + decryptActionBoundArgs, +} from './utils/encryption-runtime' -export * from './rsc' - -export function renderToPipeableStream( - data: T, - options?: object, - extraOptions?: { - /** - * @experimental - */ - onClientReference?: (metadata: { - id: string - name: string - deps: ResolvedAssetDeps - }) => void - }, -): PipeableStream { - return originalRenderToPipeableStream(data, options, { - onClientReference(metadata) { - const deps = assetsManifest.clientReferenceDeps[metadata.id] ?? { - js: [], - css: [], - } - extraOptions?.onClientReference?.({ - id: metadata.id, - name: metadata.name, - deps, - }) - }, - }) -} +export * from './rsc/server.node' +export * from './rsc/client.node' diff --git a/packages/plugin-rsc/src/rsc/client.node.ts b/packages/plugin-rsc/src/rsc/client.node.ts new file mode 100644 index 000000000..207a7896e --- /dev/null +++ b/packages/plugin-rsc/src/rsc/client.node.ts @@ -0,0 +1,3 @@ +import './shared' + +export * from '../react/rsc/client.node' diff --git a/packages/plugin-rsc/src/rsc/server.node.tsx b/packages/plugin-rsc/src/rsc/server.node.tsx new file mode 100644 index 000000000..5d1ed211c --- /dev/null +++ b/packages/plugin-rsc/src/rsc/server.node.tsx @@ -0,0 +1,32 @@ +import { renderToPipeableStream as originalRenderToPipeableStream } from '../react/rsc/server.node' +import type { PipeableStream, RenderToPipeableStreamOptions } from '../types' +import { + createOnClientReference, + type OnClientReference, +} from './client-reference' +import './shared' + +export { + createClientManifest, + createServerManifest, + loadServerAction, +} from '../core/rsc' + +export * from '../react/rsc/server.node' + +export function renderToPipeableStream( + data: T, + options?: RenderToPipeableStreamOptions, + extraOptions?: { + /** + * @experimental + */ + onClientReference?: OnClientReference + }, +): PipeableStream { + return originalRenderToPipeableStream(data, options, { + onClientReference: extraOptions?.onClientReference + ? createOnClientReference(extraOptions.onClientReference) + : undefined, + }) +} diff --git a/packages/plugin-rsc/src/rsc/static.node.tsx b/packages/plugin-rsc/src/rsc/static.node.tsx new file mode 100644 index 000000000..d14f4ff3e --- /dev/null +++ b/packages/plugin-rsc/src/rsc/static.node.tsx @@ -0,0 +1,29 @@ +import { + prerenderToNodeStream as originalPrerenderToNodeStream, + type PrerenderToNodeStreamResult, +} from '../react/rsc/static.node' +import type { RenderToPipeableStreamOptions } from '../types' +import { + createOnClientReference, + type OnClientReference, +} from './client-reference' +import './shared' + +export { type PrerenderToNodeStreamResult } from '../react/rsc/static.node' + +export function prerenderToNodeStream( + data: T, + options?: RenderToPipeableStreamOptions, + extraOptions?: { + /** + * @experimental + */ + onClientReference?: OnClientReference + }, +): Promise { + return originalPrerenderToNodeStream(data, options, { + onClientReference: extraOptions?.onClientReference + ? createOnClientReference(extraOptions.onClientReference) + : undefined, + }) +} diff --git a/packages/plugin-rsc/src/ssr.node.tsx b/packages/plugin-rsc/src/ssr.node.tsx deleted file mode 100644 index d4a1dfe29..000000000 --- a/packages/plugin-rsc/src/ssr.node.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export * from './ssr' - -export { createFromNodeStream } from './react/ssr.node' diff --git a/packages/plugin-rsc/tsdown.config.ts b/packages/plugin-rsc/tsdown.config.ts index c190a7a8a..4900552a6 100644 --- a/packages/plugin-rsc/tsdown.config.ts +++ b/packages/plugin-rsc/tsdown.config.ts @@ -7,24 +7,27 @@ export default defineConfig({ 'src/plugin.ts', 'src/browser.ts', 'src/ssr.tsx', - 'src/ssr.node.tsx', 'src/rsc.tsx', 'src/rsc/server.tsx', 'src/rsc/client.ts', 'src/rsc/static.tsx', 'src/rsc.node.tsx', + 'src/rsc/server.node.tsx', + 'src/rsc/client.node.ts', + 'src/rsc/static.node.tsx', 'src/core/browser.ts', 'src/core/ssr.ts', 'src/core/rsc.ts', 'src/core/plugin.ts', 'src/react/browser.ts', 'src/react/ssr.ts', - 'src/react/ssr.node.ts', 'src/react/rsc.ts', 'src/react/rsc/server.ts', 'src/react/rsc/client.ts', 'src/react/rsc/static.ts', - 'src/react/rsc.node.ts', + 'src/react/rsc/server.node.ts', + 'src/react/rsc/client.node.ts', + 'src/react/rsc/static.node.ts', 'src/transforms/index.ts', 'src/plugins/cjs.ts', 'src/utils/rpc.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28bf12fc6..01e887351 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,6 +773,34 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/node-stream: + 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.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: latest + version: link:../../../plugin-react + '@vitejs/plugin-rsc': + specifier: latest + version: link:../.. + rsc-html-stream: + specifier: ^0.0.8 + version: 0.0.8 + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/performance-track: dependencies: react: From 4c289c82252ad4ae948a13aceb0f5e084e545b6f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:15:17 +0900 Subject: [PATCH 03/23] feat(plugin-rsc): complete Node stream example Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/node-stream.test.ts | 20 +++- .../examples/node-stream/src/action.ts | 11 +++ .../node-stream/src/framework/entry.rsc.tsx | 81 ++++++++-------- .../node-stream/src/framework/entry.ssr.tsx | 70 +++++++++----- .../node-stream/src/framework/request.node.ts | 51 ++++++++++ .../node-stream/src/framework/request.tsx | 38 +------- .../framework/rsc-html-stream.server.node.ts | 96 +++++++++++++++++++ .../src/framework/server-plugin.ts | 64 +++++++++++++ .../examples/node-stream/src/root.tsx | 11 ++- .../examples/node-stream/vite.config.ts | 3 +- .../plugin-rsc/src/react/rsc/client.node.ts | 9 +- packages/plugin-rsc/src/react/ssr.node.ts | 15 +++ packages/plugin-rsc/src/ssr.node.tsx | 2 + packages/plugin-rsc/tsdown.config.ts | 2 + 14 files changed, 367 insertions(+), 106 deletions(-) create mode 100644 packages/plugin-rsc/examples/node-stream/src/action.ts create mode 100644 packages/plugin-rsc/examples/node-stream/src/framework/request.node.ts create mode 100644 packages/plugin-rsc/examples/node-stream/src/framework/rsc-html-stream.server.node.ts create mode 100644 packages/plugin-rsc/examples/node-stream/src/framework/server-plugin.ts create mode 100644 packages/plugin-rsc/src/react/ssr.node.ts create mode 100644 packages/plugin-rsc/src/ssr.node.tsx diff --git a/packages/plugin-rsc/e2e/node-stream.test.ts b/packages/plugin-rsc/e2e/node-stream.test.ts index 795f8025d..68d6ec1a8 100644 --- a/packages/plugin-rsc/e2e/node-stream.test.ts +++ b/packages/plugin-rsc/e2e/node-stream.test.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' import { useFixture } from './fixture' -import { expectNoPageError, waitForHydration } from './helper' +import { expectNoPageError, testNoJs, waitForHydration } from './helper' test.describe('dev', () => { const f = useFixture({ root: 'examples/node-stream', mode: 'dev' }) @@ -29,4 +29,22 @@ function defineTests(f: ReturnType) { await button.click() await expect(button).toHaveText('Count: 1') }) + + test('server action', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await testServerAction(page) + }) + + testNoJs('server action without JavaScript', async ({ page }) => { + await page.goto(f.url()) + await testServerAction(page) + }) +} + +async function testServerAction(page: import('@playwright/test').Page) { + const button = page.getByTestId('server-counter') + const before = Number((await button.textContent())?.match(/\d+/)?.[0]) + await button.click() + await expect(button).toHaveText(`Server count: ${before + 1}`) } diff --git a/packages/plugin-rsc/examples/node-stream/src/action.ts b/packages/plugin-rsc/examples/node-stream/src/action.ts new file mode 100644 index 000000000..3b7d8788b --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/action.ts @@ -0,0 +1,11 @@ +'use server' + +let serverCount = 0 + +export async function getServerCount() { + return serverCount +} + +export async function incrementServerCount() { + serverCount++ +} 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 index da1b97258..31c2f8129 100644 --- a/packages/plugin-rsc/examples/node-stream/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/node-stream/src/framework/entry.rsc.tsx @@ -1,4 +1,6 @@ -import { PassThrough, Readable } from 'node:stream' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { PassThrough } from 'node:stream' +import { finished } from 'node:stream/promises' import { renderToPipeableStream, loadServerAction, @@ -8,8 +10,12 @@ import { createTemporaryReferenceSet, } from '@vitejs/plugin-rsc/rsc/server.node' import type { ReactFormState } from 'react-dom/client' -import { Root } from '../root.tsx' -import { parseRenderRequest } from './request.tsx' +import { Root } from '../root' +import { + parseRenderRequest, + readRequestBody, + requestBodyToFormData, +} from './request.node' export type RscPayload = { root: React.ReactNode @@ -17,24 +23,25 @@ export type RscPayload = { formState?: ReactFormState } -export default { fetch: handler } - -async function handler(request: Request): Promise { +export default async function handler( + request: IncomingMessage, + response: ServerResponse, +): Promise { const renderRequest = parseRenderRequest(request) - request = renderRequest.request let returnValue: RscPayload['returnValue'] | undefined let formState: ReactFormState | undefined let temporaryReferences: unknown | undefined let actionStatus: number | undefined if (renderRequest.isAction === true) { + const body = await readRequestBody(request) if (renderRequest.actionId) { - const contentType = request.headers.get('content-type') - const body = contentType?.startsWith('multipart/form-data') - ? await request.formData() - : await request.text() + const contentType = request.headers['content-type'] + const replyBody = contentType?.startsWith('multipart/form-data') + ? await requestBodyToFormData(body, contentType) + : body.toString() temporaryReferences = createTemporaryReferenceSet() - const args = await decodeReply(body, { temporaryReferences }) + const args = await decodeReply(replyBody, { temporaryReferences }) const action = await loadServerAction(renderRequest.actionId) try { const data = await action.apply(null, args) @@ -44,15 +51,18 @@ async function handler(request: Request): Promise { actionStatus = 500 } } else { - const formData = await request.formData() + const formData = await requestBodyToFormData( + body, + request.headers['content-type'], + ) const decodedAction = await decodeAction(formData) try { const result = await decodedAction() formState = await decodeFormState(result, formData) } catch (e) { - return new Response('Internal Server Error: server action failed', { - status: 500, - }) + response.statusCode = 500 + response.end('Internal Server Error: server action failed') + return } } } @@ -62,39 +72,32 @@ async function handler(request: Request): Promise { formState, returnValue, } - const rscOptions = { temporaryReferences } - const pipeableStream = renderToPipeableStream( - rscPayload, - rscOptions, - ) - - const passThrough = new PassThrough() - pipeableStream.pipe(passThrough) - const webStream = Readable.toWeb(passThrough) as ReadableStream + const pipeableStream = renderToPipeableStream(rscPayload, { + temporaryReferences, + }) if (renderRequest.isRsc) { - return new Response(webStream, { - status: actionStatus, - headers: { - 'content-type': 'text/x-component;charset=utf-8', - }, - }) + 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.tsx') + typeof import('./entry.ssr') >('ssr', 'index') - const ssrResult = await ssrEntryModule.renderHTML(webStream, { + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { formState, debugNojs: renderRequest.url.searchParams.has('__nojs'), }) - return new Response(ssrResult.stream, { - status: ssrResult.status, - headers: { - 'Content-type': 'text/html', - }, - }) + response.statusCode = ssrResult.status ?? 200 + response.setHeader('content-type', 'text/html') + ssrResult.stream.pipe(response) + await finished(response) } if (import.meta.hot) { 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 index 65b4dbd56..3a1065cbd 100644 --- a/packages/plugin-rsc/examples/node-stream/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/node-stream/src/framework/entry.ssr.tsx @@ -1,43 +1,51 @@ +import { PassThrough, type Readable } from 'node:stream' import { - createFromReadableStream, + createFromNodeStream, getClientEntryUrl, -} from '@vitejs/plugin-rsc/ssr' +} from '@vitejs/plugin-rsc/ssr.node' 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 { 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: ReadableStream, + rscStream: Readable, options: { formState?: ReactFormState nonce?: string debugNojs?: boolean }, -): Promise<{ stream: ReadableStream; status?: number }> { - const [rscStream1, rscStream2] = rscStream.tee() +): Promise<{ stream: Readable; status?: number }> { + const rscStreamForSsr = new PassThrough() + const rscStreamForInjection = options.debugNojs + ? undefined + : new PassThrough() + rscStream.pipe(rscStreamForSsr) + if (rscStreamForInjection) { + rscStream.pipe(rscStreamForInjection) + } let payload: Promise | undefined function SsrRoot() { - payload ??= createFromReadableStream(rscStream1) + payload ??= createFromNodeStream(rscStreamForSsr) return React.use(payload).root } const bootstrapScriptContent = `import(${JSON.stringify(getClientEntryUrl())})` - let htmlStream: ReadableStream + let htmlStream: Readable let status: number | undefined try { - htmlStream = await renderToReadableStream(, { - bootstrapScriptContent: options?.debugNojs + htmlStream = await renderToNodeStream(, { + bootstrapScriptContent: options.debugNojs ? undefined : bootstrapScriptContent, - nonce: options?.nonce, - formState: options?.formState, + nonce: options.nonce, + formState: options.formState, }) } catch (e) { status = 500 - htmlStream = await renderToReadableStream( + htmlStream = await renderToNodeStream( @@ -46,20 +54,36 @@ export async function renderHTML( { bootstrapScriptContent: `self.__NO_HYDRATE=1;` + - (options?.debugNojs ? '' : bootstrapScriptContent), - nonce: options?.nonce, + (options.debugNojs ? '' : bootstrapScriptContent), + nonce: options.nonce, }, ) } - let responseStream: ReadableStream = htmlStream - if (!options?.debugNojs) { - responseStream = responseStream.pipeThrough( - injectRSCPayload(rscStream2, { - nonce: options?.nonce, - }), + if (rscStreamForInjection) { + htmlStream = htmlStream.pipe( + injectRSCPayload(rscStreamForInjection, { nonce: options.nonce }), ) } - return { stream: responseStream, status } + return { stream: htmlStream, status } +} + +function renderToNodeStream( + node: React.ReactNode, + options: Parameters[1], +): 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/request.node.ts b/packages/plugin-rsc/examples/node-stream/src/framework/request.node.ts new file mode 100644 index 000000000..7f11af6c6 --- /dev/null +++ b/packages/plugin-rsc/examples/node-stream/src/framework/request.node.ts @@ -0,0 +1,51 @@ +import type { IncomingMessage } from 'node:http' +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 requestBodyToFormData( + body: Buffer, + contentType: string | undefined, +): Promise { + const headers = new Headers() + if (contentType) headers.set('content-type', contentType) + const request = new Request('http://localhost', { + method: 'POST', + headers, + body: new Uint8Array(body), + }) + return request.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 index 4cf961973..678433561 100644 --- a/packages/plugin-rsc/examples/node-stream/src/framework/request.tsx +++ b/packages/plugin-rsc/examples/node-stream/src/framework/request.tsx @@ -1,13 +1,5 @@ -const URL_POSTFIX = '_.rsc' -const HEADER_ACTION_ID = 'x-rsc-action' - -type RenderRequest = { - isRsc: boolean - isAction: boolean - actionId?: string - request: Request - url: URL -} +export const URL_POSTFIX = '_.rsc' +export const HEADER_ACTION_ID = 'x-rsc-action' export function createRscRenderRequest( urlString: string, @@ -25,29 +17,3 @@ export function createRscRenderRequest( 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/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(/