diff --git a/examples/vite-rsc/.gitignore b/examples/vite-rsc/.gitignore new file mode 100644 index 0000000000..f06235c460 --- /dev/null +++ b/examples/vite-rsc/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/examples/vite-rsc/README.md b/examples/vite-rsc/README.md new file mode 100644 index 0000000000..66ba6d8b00 --- /dev/null +++ b/examples/vite-rsc/README.md @@ -0,0 +1,5 @@ +# Vite + RSC + Nitro Example + +Copied from https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter + +The difference from the original template is to export `default.fetch` handler from `entry.ssr.tsx` instead of `entry.rsc.tsx`. diff --git a/examples/vite-rsc/app/action.tsx b/examples/vite-rsc/app/action.tsx new file mode 100644 index 0000000000..6b5029dcb5 --- /dev/null +++ b/examples/vite-rsc/app/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/examples/vite-rsc/app/assets/nitro.svg b/examples/vite-rsc/app/assets/nitro.svg new file mode 100644 index 0000000000..d6450f9467 --- /dev/null +++ b/examples/vite-rsc/app/assets/nitro.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/vite-rsc/app/assets/react.svg b/examples/vite-rsc/app/assets/react.svg new file mode 100644 index 0000000000..6c87de9bb3 --- /dev/null +++ b/examples/vite-rsc/app/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/vite-rsc/app/assets/vite.svg b/examples/vite-rsc/app/assets/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/examples/vite-rsc/app/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/vite-rsc/app/client.tsx b/examples/vite-rsc/app/client.tsx new file mode 100644 index 0000000000..f857e6355e --- /dev/null +++ b/examples/vite-rsc/app/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/examples/vite-rsc/app/framework/entry.browser.tsx b/examples/vite-rsc/app/framework/entry.browser.tsx new file mode 100644 index 0000000000..cce683560e --- /dev/null +++ b/examples/vite-rsc/app/framework/entry.browser.tsx @@ -0,0 +1,139 @@ +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 { GlobalErrorBoundary } from "./error-boundary"; +import type { RscPayload } from "./entry.rsc"; +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(globalThis.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(globalThis.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) { + globalThis.addEventListener("popstate", onNavigation); + + const oldPushState = globalThis.history.pushState; + globalThis.history.pushState = function (...args) { + const res = oldPushState.apply(this, args); + onNavigation(); + return res; + }; + + const oldReplaceState = globalThis.history.replaceState; + globalThis.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args); + onNavigation(); + return res; + }; + + function onClick(e: MouseEvent) { + const 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); + globalThis.removeEventListener("popstate", onNavigation); + globalThis.history.pushState = oldPushState; + globalThis.history.replaceState = oldReplaceState; + }; +} + +// eslint-disable-next-line unicorn/prefer-top-level-await +main(); diff --git a/examples/vite-rsc/app/framework/entry.rsc.tsx b/examples/vite-rsc/app/framework/entry.rsc.tsx new file mode 100644 index 0000000000..dfe6f57fe7 --- /dev/null +++ b/examples/vite-rsc/app/framework/entry.rsc.tsx @@ -0,0 +1,126 @@ +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/deserializes 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 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 { + // eslint-disable-next-line prefer-spread + const data = await action.apply(null, args); + returnValue = { ok: true, data }; + } catch (error_) { + returnValue = { ok: false, data: error_ }; + 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 { + // 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/examples/vite-rsc/app/framework/entry.ssr.tsx b/examples/vite-rsc/app/framework/entry.ssr.tsx new file mode 100644 index 0000000000..b5fb08a555 --- /dev/null +++ b/examples/vite-rsc/app/framework/entry.ssr.tsx @@ -0,0 +1,85 @@ +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 default { + fetch: async (request: Request) => { + const rscEntryModule = await import.meta.viteRsc.loadModule< + typeof import("./entry.rsc") + >("rsc", "index"); + return rscEntryModule.default(request); + }, +}; + +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 { + // 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/examples/vite-rsc/app/framework/error-boundary.tsx b/examples/vite-rsc/app/framework/error-boundary.tsx new file mode 100644 index 0000000000..674bc41d6f --- /dev/null +++ b/examples/vite-rsc/app/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; + }>; +}> { + override state: { error?: Error } = {}; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + reset = () => { + this.setState({ error: null }); + }; + + override 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/examples/vite-rsc/app/framework/request.tsx b/examples/vite-rsc/app/framework/request.tsx new file mode 100644 index 0000000000..d68a29547c --- /dev/null +++ b/examples/vite-rsc/app/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/examples/vite-rsc/app/index.css b/examples/vite-rsc/app/index.css new file mode 100644 index 0000000000..f4d2128c01 --- /dev/null +++ b/examples/vite-rsc/app/index.css @@ -0,0 +1,112 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1rem; +} + +.read-the-docs { + color: #888; + text-align: left; +} diff --git a/examples/vite-rsc/app/root.tsx b/examples/vite-rsc/app/root.tsx new file mode 100644 index 0000000000..e3da1d5408 --- /dev/null +++ b/examples/vite-rsc/app/root.tsx @@ -0,0 +1,77 @@ +import "./index.css"; // css import is automatically injected in exported server components +import viteLogo from "./assets/vite.svg"; +import { getServerCounter, updateServerCounter } from "./action.tsx"; +import reactLogo from "./assets/react.svg"; +import nitroLogo from "./assets/nitro.svg"; +import { ClientCounter } from "./client.tsx"; + +export function Root(props: { url: URL }) { + return ( + + + {/* eslint-disable-next-line unicorn/text-encoding-identifier-case */} + + + + Nitro + Vite + RSC + + + + + + ); +} + +function App(props: { url: URL }) { + return ( +
+ +

Vite + RSC + Nitro

+
+ +
+
+ + + +
+
Request URL: {props.url?.href}
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • + {/*
  • + Visit{" "} + + ?__rsc + {" "} + to view RSC stream payload. +
  • */} +
  • + Visit{" "} + + ?__nojs + {" "} + to test server action without js enabled. +
  • +
+
+ ); +} diff --git a/examples/vite-rsc/package.json b/examples/vite-rsc/package.json new file mode 100644 index 0000000000..5cfb84fb05 --- /dev/null +++ b/examples/vite-rsc/package.json @@ -0,0 +1,25 @@ +{ + "name": "@vitejs/plugin-rsc-examples-starter", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "https://pkg.pr.new/@vitejs/plugin-rsc@687458d", + "nitro": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "beta" + } +} diff --git a/examples/vite-rsc/tsconfig.json b/examples/vite-rsc/tsconfig.json new file mode 100644 index 0000000000..a7b38dc3ca --- /dev/null +++ b/examples/vite-rsc/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "nitro/tsconfig", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/examples/vite-rsc/vite.config.ts b/examples/vite-rsc/vite.config.ts new file mode 100644 index 0000000000..85ad2922b4 --- /dev/null +++ b/examples/vite-rsc/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "vite"; +import { nitro } from "nitro/vite"; + +import rsc from "@vitejs/plugin-rsc"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [ + nitro({ + experimental: { + vite: { + services: { + ssr: { entry: "./app/framework/entry.ssr.tsx" }, + rsc: { entry: "./app/framework/entry.rsc.tsx" }, + }, + }, + }, + }), + rsc({ serverHandler: false }), + react(), + ], + + environments: { + client: { + build: { + rollupOptions: { + input: { index: "./app/framework/entry.browser.tsx" }, + }, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f08cdb76d..50794d04d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,6 +476,37 @@ importers: specifier: beta version: 8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + examples/vite-rsc: + dependencies: + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: latest + version: 5.1.2(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitejs/plugin-rsc': + specifier: https://pkg.pr.new/@vitejs/plugin-rsc@687458d + version: https://pkg.pr.new/@vitejs/plugin-rsc@687458d(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + nitro: + specifier: link:../.. + version: link:../.. + rsc-html-stream: + specifier: ^0.0.7 + version: 0.0.7 + vite: + specifier: beta + version: 8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + examples/vite-ssr-html: devDependencies: '@tailwindcss/vite': @@ -3132,6 +3163,18 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitejs/plugin-rsc@https://pkg.pr.new/@vitejs/plugin-rsc@687458d': + resolution: {integrity: sha512-0s8D3QrdMOF2cbrtDVq3bbjKUFAW6SL6StTqPVIMlcQQUAg2bCv5prg1B/drYcENrfRPOJVYhPVcDlGCJU27gQ==, tarball: https://pkg.pr.new/@vitejs/plugin-rsc@687458d} + version: 0.5.10 + peerDependencies: + react: '*' + react-dom: '*' + react-server-dom-webpack: '*' + vite: '*' + peerDependenciesMeta: + react-server-dom-webpack: + optional: true + '@vitejs/plugin-vue@6.0.3': resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4035,6 +4078,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -4745,6 +4791,9 @@ packages: is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-regexp@3.1.0: resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} engines: {node: '>=12'} @@ -5590,6 +5639,9 @@ packages: perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + periscopic@4.0.2: + resolution: {integrity: sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5922,6 +5974,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rsc-html-stream@0.0.7: + resolution: {integrity: sha512-v9+fuY7usTgvXdNl8JmfXCvSsQbq2YMd60kOeeMIqCJFZ69fViuIxztHei7v5mlMMa2h3SqS+v44Gu9i9xANZA==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -6349,6 +6404,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turbo-stream@3.1.0: + resolution: {integrity: sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6932,6 +6990,9 @@ packages: zhead@2.2.4: resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -9473,6 +9534,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-rsc@https://pkg.pr.new/@vitejs/plugin-rsc@687458d(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + es-module-lexer: 2.0.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + periscopic: 4.0.2 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + srvx: 0.10.0 + strip-literal: 3.1.0 + turbo-stream: 3.1.0 + vite: 8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.53 @@ -10376,6 +10451,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -11248,6 +11325,10 @@ snapshots: dependencies: '@types/estree': 1.0.8 + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + is-regexp@3.1.0: {} is-stream@2.0.1: {} @@ -12311,6 +12392,12 @@ snapshots: perfect-debounce@2.0.0: {} + periscopic@4.0.2: + dependencies: + '@types/estree': 1.0.8 + is-reference: 3.0.3 + zimmerframe: 1.1.4 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -12743,6 +12830,8 @@ snapshots: transitivePeerDependencies: - supports-color + rsc-html-stream@0.0.7: {} + run-applescript@7.1.0: {} rxjs@7.8.2: @@ -13178,6 +13267,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + turbo-stream@3.1.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -13701,6 +13792,8 @@ snapshots: zhead@2.2.4: {} + zimmerframe@1.1.4: {} + zod@3.22.3: {} zod@3.25.76: {} diff --git a/src/build/vite/env.ts b/src/build/vite/env.ts index 15c0a5c28e..19979a52de 100644 --- a/src/build/vite/env.ts +++ b/src/build/vite/env.ts @@ -68,7 +68,7 @@ export function createServiceEnvironment( return { consumer: "server", build: { - rollupOptions: { input: serviceConfig.entry }, + rollupOptions: { input: { index: serviceConfig.entry } }, minify: ctx.nitro!.options.minify, sourcemap: ctx.nitro!.options.sourcemap, outDir: join(ctx.nitro!.options.buildDir, "vite/services", name), diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index e5f46e2dc4..ed66378280 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -285,7 +285,7 @@ function nitroService(ctx: NitroPluginContext): VitePlugin { function createContext(pluginConfig: NitroPluginConfig): NitroPluginContext { return { pluginConfig, - services: {}, + services: { ...pluginConfig.experimental?.vite?.services }, _entryPoints: {}, }; } diff --git a/src/build/vite/types.ts b/src/build/vite/types.ts index 9f2d6146a0..ed64f604ab 100644 --- a/src/build/vite/types.ts +++ b/src/build/vite/types.ts @@ -34,7 +34,12 @@ export interface NitroPluginConfig extends NitroConfig { * * @default true */ - serverReload: boolean; + serverReload?: boolean; + + /** + * Additional Vite environment services to register. + */ + services?: Record; }; }; } diff --git a/src/runtime/internal/vite/node-runner.mjs b/src/runtime/internal/vite/node-runner.mjs index 964e76e047..39391dae51 100644 --- a/src/runtime/internal/vite/node-runner.mjs +++ b/src/runtime/internal/vite/node-runner.mjs @@ -155,6 +155,22 @@ parentPort.on("message", (payload) => { process.on("unhandledRejection", (error) => console.error(error)); process.on("uncaughtException", (error) => console.error(error)); +// ----- RSC Support ----- + +// define __VITE_ENVIRONMENT_RUNNER_IMPORT__ for RSC support +// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/README.md#__vite_environment_runner_import__ + +globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async function ( + environmentName, + id +) { + const env = envs[environmentName]; + if (!env) { + throw new Error(`Vite environment "${environmentName}" is not registered`); + } + return env.runner.import(id); +}; + // ----- Server ----- async function reload() { diff --git a/test/examples.test.ts b/test/examples.test.ts index 6d5234f6a9..9e5b484437 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -8,15 +8,17 @@ import type { ViteDevServer } from "vite"; const examplesDir = fileURLToPath(new URL("../examples", import.meta.url)); -const { createServer, createBuilder } = (await import( +const { createServer, createBuilder, rolldownVersion } = (await import( process.env.NITRO_VITE_PKG || "vite" )) as typeof import("vite"); +const isRolldown = !!rolldownVersion; + const skip = new Set(["websocket"]); const skipDev = new Set(["auto-imports", "cached-handler"]); -const skipProd = new Set(); +const skipProd = new Set(isRolldown ? [] : ["vite-rsc"]); for (const example of await readdir(examplesDir)) { if (example.startsWith("_")) continue; diff --git a/tsconfig.json b/tsconfig.json index 86db00fea8..0348a13229 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ }, "exclude": [ "examples/import-alias/**", + "examples/vite-rsc/**", "test/fixture/server/routes/jsx.tsx", "examples/vite-ssr-solid/src/entry-server.tsx", "examples/vite-ssr-solid/src/entry-client.tsx"