Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7cc7923
feat(plugin-rsc): expose Node.js stream APIs (renderToPipeableStream,…
switz Jul 6, 2026
b1baf48
refactor(plugin-rsc): align Node.js stream APIs with sub-path structure
switz Aug 16, 2026
4c289c8
feat(plugin-rsc): complete Node stream example
hi-ogawa Aug 17, 2026
ed3dd93
test(plugin-rsc): align Node stream app with starter
hi-ogawa Aug 17, 2026
62c3e0b
docs(plugin-rsc): document Node stream APIs
hi-ogawa Aug 17, 2026
df68f35
refactor(plugin-rsc): isolate Node stream runtimes
hi-ogawa Aug 17, 2026
173581c
refactor(plugin-rsc): share RSC client manifest setup
hi-ogawa Aug 17, 2026
d4d93c2
feat(plugin-rsc): expose Busboy reply decoding
hi-ogawa Aug 17, 2026
c6d06d1
test(plugin-rsc): reuse starter action coverage
hi-ogawa Aug 17, 2026
3550001
refactor(plugin-rsc): stream progressive action forms
hi-ogawa Aug 17, 2026
b1a2483
nit
hi-ogawa Aug 17, 2026
57f388d
docs(plugin-rsc): link Node stream type references
hi-ogawa Aug 17, 2026
824b256
nit
hi-ogawa Aug 17, 2026
6a323bb
nit
hi-ogawa Aug 17, 2026
67ef614
fix(plugin-rsc): distinguish stream option types
hi-ogawa Aug 17, 2026
46529e0
fix(plugin-rsc): keep source map lookup internal
hi-ogawa Aug 17, 2026
fd02d2c
refactor(plugin-rsc): centralize prerender result type
hi-ogawa Aug 17, 2026
1a0b9ba
nit
hi-ogawa Aug 17, 2026
f181ff5
fix(plugin-rsc): expose web streams from node entries
hi-ogawa Aug 17, 2026
e0bc93e
nit
hi-ogawa Aug 17, 2026
f4147bb
nit
hi-ogawa Aug 17, 2026
cdac614
nit
hi-ogawa Aug 17, 2026
a27ac7d
fix(plugin-rsc): scope node dependency optimization
hi-ogawa Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/plugin-rsc/e2e/node-stream.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
17 changes: 17 additions & 0 deletions packages/plugin-rsc/examples/node-stream/README.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions packages/plugin-rsc/examples/node-stream/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions packages/plugin-rsc/examples/node-stream/public/vite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions packages/plugin-rsc/examples/node-stream/src/action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use server'

let serverCounter = 0

export async function getServerCounter() {
return serverCounter
}

export async function updateServerCounter(change: number) {
serverCounter += change
}
3 changes: 3 additions & 0 deletions packages/plugin-rsc/examples/node-stream/src/assets/react.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions packages/plugin-rsc/examples/node-stream/src/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import React from 'react'

export function ClientCounter() {
const [count, setCount] = React.useState(0)

return (
<button onClick={() => setCount((count) => count + 1)}>
Client Counter: {count}
</button>
)
}
Original file line number Diff line number Diff line change
@@ -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<RscPayload>(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<RscPayload>(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<RscPayload>(fetch(renderRequest), {
temporaryReferences,
})
setPayload(payload)
const { ok, data } = payload.returnValue!
if (!ok) throw data
return data
})

const browserRoot = (
<React.StrictMode>
<GlobalErrorBoundary>
<BrowserRoot />
</GlobalErrorBoundary>
</React.StrictMode>
)
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()
111 changes: 111 additions & 0 deletions packages/plugin-rsc/examples/node-stream/src/framework/entry.rsc.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
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: <Root url={renderRequest.url} />,
formState,
returnValue,
}
const pipeableStream = renderToPipeableStream<RscPayload>(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()
}
Loading
Loading