vite-plugin-react-server
Version:
Vite plugin for React Server Components (RSC)
640 lines (576 loc) • 24.2 kB
text/typescript
/**
* renderPage.client.ts
*
* PURPOSE: Client-side static page rendering for React Server Components
*
* ARCHITECTURE OVERVIEW:
*
* CLIENT-SIDE vs SERVER-SIDE:
* - Server-side: RSC generation in main thread, HTML generation in worker
* - Client-side: RSC generation in worker, HTML generation in main thread
*
* FLOW:
* 1. RSC Worker generates RSC content with HTML wrapper
* 2. RSC content is buffered to allow dual consumption
* 3. Buffered RSC stream is consumed twice:
* - For RSC file writing (index.rsc)
* - For HTML transformation (index.html)
* 4. HTML transform processes RSC content in main thread
* 5. Both files are written to filesystem
*
* KEY INSIGHT: Node.js streams can only be consumed once, so we buffer the RSC
* content to allow it to be used for both RSC file generation and HTML transformation.
* This follows the pattern from collectRscContent.ts.
*
* HELPER FUNCTIONS:
* - createBufferedRscStream: Creates a buffered stream for dual consumption
* - createRscToHtmlStream: Transforms RSC content to HTML in main thread
*
* USAGE:
* ```typescript
* const result = await renderPage({
* route: "/",
* pagePath: "src/page/page.tsx",
* // ... other options
* });
*
* // result.html.pipe(htmlFileWriter);
* // result.rsc.pipe(rscFileWriter);
* ```
*/
import { createRenderMetrics } from "../metrics/createRenderMetrics.js";
import type { RenderMetrics } from "../metrics/types.js";
import { routeToURL } from "../utils/routeToURL.js";
import type { RenderPageFn } from "./types.js";
import { handleError } from "../error/handleError.js";
import { assertNonReactServer } from "../config/getCondition.js";
import { createRscStream } from "../stream/createRscStream.client.js";
import { resolveComponents } from "../helpers/resolveComponents.client.js";
import { join } from "node:path";
import { createStreamMetrics } from "../metrics/createStreamMetrics.js";
import { performance } from "node:perf_hooks";
import { createRscToHtmlStream } from "./rscToHtmlStream.client.js";
assertNonReactServer();
/**
* Client version of renderPage that uses the react-client pattern
* This works in REVERSE from the server plugin:
* - Server: Main thread (RSC) + HTML worker (HTML)
* - Client: RSC worker (RSC) + Main thread (HTML)
*/
export const renderPage: RenderPageFn = async function* _renderPageClient(
handlerOptions
) {
if (handlerOptions.verbose) {
handlerOptions.logger?.info(
`[renderPage.client] onEvent callback exists: ${!!handlerOptions.onEvent}`
);
handlerOptions.logger?.info(
`[renderPage.client] onMetrics callback exists: ${!!handlerOptions.onMetrics}`
);
}
// Track if we've yielded a result to prevent multiple yields
let hasYielded = false;
let errorResult: any = null;
// Create a wrapper around onEvent to handle route.error events
const wrappedOnEvent = (event: any) => {
// Call the original onEvent first
if (handlerOptions.onEvent) {
handlerOptions.onEvent(event);
}
// Handle route.error events by storing result for later yielding
if (event.type === "route.error" && !hasYielded) {
hasYielded = true;
// Check if this should cause a panic
const panicError = handleError({
error: event.data.error,
logger: handlerOptions.logger,
panicThreshold: event.data.panicThreshold,
context: `route.error (${event.data.route})`,
});
if (panicError != null) {
// This is a panic error, store error result
errorResult = {
type: "error",
error: panicError,
metrics: {
rscHeadless: { duration: 0, chunks: 0, bytes: 0 },
html: { duration: 0, chunks: 0, bytes: 0 },
},
};
} else {
// This is a non-panic error, store skip result
errorResult = {
type: "skip",
reason: event.data.error.message || "Non-panic error occurred",
html: { duration: 0, chunks: 0, bytes: 0 },
rsc: { duration: 0, chunks: 0, bytes: 0 },
metrics: {
rscHeadless: { duration: 0, chunks: 0, bytes: 0 },
html: { duration: 0, chunks: 0, bytes: 0 },
},
};
}
}
};
// Skip if no pagePath AND no PageComponent provided (fallback case)
if (!handlerOptions.pagePath && !handlerOptions.PageComponent) {
// Create empty stream wrappers for skip case
const emptyStreamWrapper = {
pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => {
destination.end();
return destination;
},
abort: () => {
// No cleanup needed
},
};
yield {
type: "skip",
reason: "No pagePath and no PageComponent provided",
html: emptyStreamWrapper,
rsc: emptyStreamWrapper,
metrics: {
rscFull: createRenderMetrics({
route: handlerOptions.route,
type: "rsc-full",
fromMainThread: false,
fromRscWorker: true,
fromHtmlWorker: false,
}) as RenderMetrics & { type: "rsc-full" },
rscHeadless: createRenderMetrics({
route: handlerOptions.route,
type: "rsc-headless",
fromMainThread: false,
fromRscWorker: true,
fromHtmlWorker: false,
}) as RenderMetrics & { type: "rsc-headless" },
html: createRenderMetrics({
route: handlerOptions.route,
type: "html",
fromMainThread: true,
fromRscWorker: false,
fromHtmlWorker: false,
}) as RenderMetrics & { type: "html" },
},
};
return;
}
if (!handlerOptions.url) {
handlerOptions.url = routeToURL(
handlerOptions.route,
handlerOptions.moduleBaseURL,
handlerOptions.build.rscOutputPath
);
}
const baseDir = join(
handlerOptions.build.outDir,
handlerOptions.build.static
);
const routePath = handlerOptions.route.replace(/^\//, "");
// Create metrics upfront with proper types - REVERSE from server
const htmlMetrics = createRenderMetrics({
route: handlerOptions.route,
type: "html",
fromMainThread: true, // Client: HTML rendered on main thread
fromRscWorker: false,
fromHtmlWorker: false,
baseDir,
routePath,
fileName: handlerOptions.build.htmlOutputPath,
outputPath: join(baseDir, routePath, handlerOptions.build.htmlOutputPath),
});
const rscFullMetrics = createRenderMetrics({
route: handlerOptions.route,
type: "rsc-full",
fromMainThread: false,
fromRscWorker: true, // Client: RSC rendered on RSC worker
fromHtmlWorker: false,
});
const rscHeadlessMetrics = createRenderMetrics({
route: handlerOptions.route,
type: "rsc-headless",
fromMainThread: false,
fromRscWorker: true, // Client: RSC rendered on RSC worker
fromHtmlWorker: false,
baseDir,
routePath,
fileName: handlerOptions.build.rscOutputPath,
outputPath: join(baseDir, routePath, handlerOptions.build.rscOutputPath),
});
// Declare variables outside try block so they can be accessed in catch block
let headlessRscStream: any = null;
let fullRscStream: any = null;
let htmlHandler: any = null;
try {
if (handlerOptions.verbose) {
handlerOptions.logger?.info(
`[renderPage.client] Client-side rendering for route: ${handlerOptions.route}`
);
}
// Step 1: Resolve paths to built paths using the server manifest
// The client version needs to use the server manifest to get the built paths
// for the page components, not the static manifest
const resolvePathWithManifest = (path: string, manifest: any): string => {
const entry = manifest[path];
if (entry && entry.file) {
return entry.file;
}
return path;
};
// Use manifest for page component resolution (client version works in reverse)
const manifest = handlerOptions.manifest || {};
const resolvedPagePath = handlerOptions.pagePath ? resolvePathWithManifest(handlerOptions.pagePath, manifest) : undefined;
const resolvedPropsPath = handlerOptions.propsPath ? resolvePathWithManifest(handlerOptions.propsPath, manifest) : undefined;
const resolvedRootPath = handlerOptions.rootPath ? resolvePathWithManifest(handlerOptions.rootPath, manifest) : undefined;
const resolvedHtmlPath = handlerOptions.htmlPath ? resolvePathWithManifest(handlerOptions.htmlPath, manifest) : undefined;
if (handlerOptions.verbose) {
handlerOptions.logger?.info(`[renderPage.client] Resolved paths for route ${handlerOptions.route}:`);
handlerOptions.logger?.info(` page: ${handlerOptions.pagePath} -> ${resolvedPagePath}`);
handlerOptions.logger?.info(` props: ${handlerOptions.propsPath} -> ${resolvedPropsPath}`);
handlerOptions.logger?.info(` root: ${handlerOptions.rootPath} -> ${resolvedRootPath}`);
handlerOptions.logger?.info(` html: ${handlerOptions.htmlPath} -> ${resolvedHtmlPath}`);
handlerOptions.logger?.info(` manifest keys: ${Object.keys(manifest).join(', ')}`);
handlerOptions.logger?.info(` HTML path issue: htmlPath='${handlerOptions.htmlPath}', resolved='${resolvedHtmlPath}', manifest has Html entry: ${!!manifest[handlerOptions.htmlPath || '']}`);
handlerOptions.logger?.info(` About to pass htmlPath='${resolvedHtmlPath}' to RSC stream`);
}
const worker = handlerOptions.worker ?? handlerOptions.rscWorker;
// Step 2: Resolve components using the RSC worker with built paths
// This separates component resolution from RSC generation, making the
// subsequent RSC render completely synchronous
if (!worker) {
throw new Error("RSC worker is required for client-side component resolution");
}
// Preload components in the worker for faster subsequent RSC stream generation
try {
await resolveComponents({
route: handlerOptions.route,
pagePath: resolvedPagePath,
propsPath: resolvedPropsPath,
rootPath: resolvedRootPath,
htmlPath: resolvedHtmlPath,
pageExportName: handlerOptions.pageExportName,
propsExportName: handlerOptions.propsExportName,
rootExportName: handlerOptions.rootExportName,
htmlExportName: handlerOptions.htmlExportName,
worker: worker,
rscWorker: worker,
onMetrics: handlerOptions.onMetrics,
logger: handlerOptions.logger,
verbose: handlerOptions.verbose,
});
} catch (componentResolutionError) {
// Handle component resolution failures gracefully
const error = componentResolutionError instanceof Error
? componentResolutionError
: new Error(String(componentResolutionError));
// Check if this component resolution error should cause a panic based on panicThreshold
const panicError = handleError({
error,
critical: false,
logger: handlerOptions.logger,
panicThreshold: handlerOptions.panicThreshold,
context: `Component resolution failed for route ${handlerOptions.route}`,
});
// If this should cause a panic, yield error and return
if (panicError) {
yield {
type: "error",
error: panicError,
metrics: {
rscFull: rscFullMetrics,
rscHeadless: rscHeadlessMetrics,
html: htmlMetrics,
},
};
return;
}
// Otherwise, treat this as a non-critical error and continue with client-only HTML
// This allows the build to complete with a client-only page
handlerOptions.logger?.warn(
`[renderPage.client] Component resolution failed for route ${handlerOptions.route}, continuing with client-only HTML: ${error.message}`
);
// Create a client-only HTML stream wrapper with minimal HTML
const clientOnlyHtmlStreamWrapper = {
pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => {
// Write a minimal client-only HTML structure
const minimalHtml = `<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body><div id="root"></div><template id="«R»"></template></body></html>`;
destination.write(minimalHtml);
destination.end();
return destination;
},
abort: () => {
// No cleanup needed for simple HTML string
},
};
// Create an empty RSC stream wrapper
const emptyRscStreamWrapper = {
pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => {
// No RSC content for failed component resolution
destination.end();
return destination;
},
abort: () => {
// No cleanup needed
},
};
// Yield skip result with client-only HTML and empty RSC
yield {
type: "skip",
reason: error,
html: clientOnlyHtmlStreamWrapper,
rsc: emptyRscStreamWrapper,
metrics: {
rscFull: rscFullMetrics,
rscHeadless: rscHeadlessMetrics,
html: htmlMetrics,
},
};
return;
}
// Step 2: Create handler options
// Components are now preloaded in the worker, so we can use the original handler options
const newHandlerOptions = {
...handlerOptions,
// Pass page paths to the RSC worker so it knows what to render
pagePath: resolvedPagePath,
propsPath: resolvedPropsPath,
rootPath: resolvedRootPath,
htmlPath: resolvedHtmlPath,
};
if (handlerOptions.verbose) {
handlerOptions.logger?.info(
`[renderPage.client] handlerOptions.clientPipeableStreamOptions: ${JSON.stringify(handlerOptions.clientPipeableStreamOptions)}`
);
handlerOptions.logger?.info(
`[renderPage.client] newHandlerOptions.clientPipeableStreamOptions: ${JSON.stringify(newHandlerOptions.clientPipeableStreamOptions)}`
);
handlerOptions.logger?.info(
`[renderPage.client] newHandlerOptions page paths: pagePath=${newHandlerOptions.pagePath}, propsPath=${newHandlerOptions.propsPath}, rootPath=${newHandlerOptions.rootPath}, htmlPath=${newHandlerOptions.htmlPath}`
);
}
// Component resolution is already measured in resolveComponents
// No need to measure module resolution time here anymore
// Create headless RSC stream first (for .rsc file)
const uniqueId = handlerOptions.id ?? `${handlerOptions.route}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const headlessRscStreamLocal = createRscStream({
...newHandlerOptions,
id: `${handlerOptions.route}-headless-${uniqueId}`,
rscTimeout: handlerOptions.rscTimeout || 5000,
onMetrics: handlerOptions.onMetrics,
// Headless RSC stream: page content only (for .rsc file)
htmlPath: '', // No HTML wrapper - just page content
pagePath: newHandlerOptions.pagePath || '', // Ensure pagePath is always a string
url: newHandlerOptions.url || '', // Ensure url is always a string
pageProps: newHandlerOptions.pageProps || {}, // Ensure pageProps is always an object
onEvent: wrappedOnEvent,
});
// Create full RSC stream that reuses the headless stream elements
const fullRscStreamLocal = createRscStream({
...newHandlerOptions,
id: `${handlerOptions.route}-full-${uniqueId}`,
rscTimeout: handlerOptions.rscTimeout || 5000,
onMetrics: handlerOptions.onMetrics,
// Full RSC stream: include HTML wrapper (for HTML generation)
// Pass through the resolved htmlPath so custom Html components work in client mode
htmlPath: resolvedHtmlPath,
pagePath: newHandlerOptions.pagePath || '', // Ensure pagePath is always a string
url: newHandlerOptions.url || '', // Ensure url is always a string
pageProps: newHandlerOptions.pageProps || {}, // Ensure pageProps is always an object
// Reuse headless stream elements - the worker will handle this with the unique ID
reuseHeadlessStreamId: headlessRscStreamLocal.id,
onEvent: wrappedOnEvent,
});
// Assign to the outer variables
headlessRscStream = headlessRscStreamLocal;
fullRscStream = fullRscStreamLocal;
// The headless stream will be consumed naturally by the file writing
// The full stream will reuse the headless stream elements for HTML generation
// Step 3: Create HTML transform stream
if (handlerOptions.verbose) {
handlerOptions.logger?.info(
`[renderPage.client] Creating HTML transform stream with clientPipeableStreamOptions: ${JSON.stringify(newHandlerOptions.clientPipeableStreamOptions)}`
);
}
// Create HTML stream using the full RSC stream (which reuses headless stream elements)
const htmlTransformStream = createRscToHtmlStream({
...newHandlerOptions,
htmlTimeout: handlerOptions.htmlTimeout || 15000,
route: handlerOptions.route,
logger: handlerOptions.logger,
verbose: handlerOptions.verbose,
rscStream: fullRscStreamLocal.rscStream,
});
htmlHandler = {
htmlStream: htmlTransformStream,
abort: () => {
htmlTransformStream.abort();
}
};
// Create stream wrappers for file writing
const rscStreamWrapper = {
pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => {
const streamMetrics = createStreamMetrics();
streamMetrics.startTime = performance.now();
// Use the headless RSC stream directly for the .rsc file
const rscFileStream = headlessRscStream.rscStream;
rscFileStream.on("data", (chunk: Buffer) => {
streamMetrics.chunks++;
streamMetrics.bytes += chunk.length;
});
rscFileStream.on("end", () => {
streamMetrics.duration = performance.now() - streamMetrics.startTime;
streamMetrics.endTime = performance.now();
rscHeadlessMetrics.streamMetrics = streamMetrics;
rscHeadlessMetrics.chunkRate = streamMetrics.chunks / (streamMetrics.duration / 1000);
rscHeadlessMetrics.processingTime = streamMetrics.duration;
rscHeadlessMetrics.memoryUsage = process.memoryUsage();
rscHeadlessMetrics.chunks = streamMetrics.chunks;
});
rscFileStream.pipe(destination);
return destination;
},
abort: () => headlessRscStream.abort(),
};
const htmlStreamWrapper = {
pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => {
if (handlerOptions.verbose) {
handlerOptions.logger?.info(
`[renderPage.client] Piping HTML stream to destination for route: ${handlerOptions.route}`
);
}
// Use the HTML transform stream's pipe method directly (same as server side)
return htmlTransformStream.pipe(destination);
},
abort: () => {
fullRscStream.abort();
if (htmlHandler.abort) {
htmlHandler.abort();
}
},
on: (event: string, listener: (...args: any[]) => void) => {
// Forward error events from the HTML transform stream to the wrapper
if (event === 'error') {
// Access the actual stream from the transform result
const htmlStream = (htmlTransformStream as any).htmlStream;
if (htmlStream && typeof htmlStream.on === 'function') {
htmlStream.on('error', listener);
}
}
return htmlStreamWrapper;
},
};
// Don't emit initial metrics - wait for file writes to complete
// The onMetrics callback will be called after both file.write.done events
// Check if we have an error result to yield (with timeout protection)
// Wait a short time for any pending route.error events
await new Promise(resolve => setTimeout(resolve, 100));
if (errorResult) {
yield errorResult;
return;
}
yield {
type: "success",
html: htmlStreamWrapper,
rsc: rscStreamWrapper,
metrics: {
rscFull: rscFullMetrics,
rscHeadless: rscHeadlessMetrics,
html: htmlMetrics,
},
} as const;
} catch (error) {
// Clean up resources
try {
if (headlessRscStream) headlessRscStream.abort();
if (fullRscStream) fullRscStream.abort();
if (htmlHandler?.abort) htmlHandler.abort();
} catch (cleanupError: unknown) {
handlerOptions.logger?.warn(`Failed to cleanup streams on error: ${cleanupError}`);
}
const panicError = handleError({
error,
logger: handlerOptions.logger,
context: "renderPageClient",
panicThreshold: handlerOptions.panicThreshold,
});
if (panicError != null) {
yield {
type: "error",
error: panicError,
metrics: {
rscFull: rscFullMetrics,
rscHeadless: rscHeadlessMetrics,
html: htmlMetrics,
},
};
} else {
// For non-panic errors, we still want to write the HTML file (client-only)
// but skip the RSC file since there was a server error
// Create a fallback RSC stream with React.Fragment (same as server environment)
const fallbackRscStream = createRscStream({
...handlerOptions,
url: `${handlerOptions.url}`,
route: `${handlerOptions.route}`,
cssFiles: handlerOptions.cssFiles || new Map(),
globalCss: handlerOptions.globalCss || new Map(),
id: `${handlerOptions.route}-fallback-${Date.now()}`,
rscTimeout: handlerOptions.rscTimeout || 5000,
onMetrics: handlerOptions.onMetrics,
// Use React.Fragment as fallback (same as server environment)
pagePath: '', // This will cause the default page to be used, but we'll override it
pageProps: {}, // Ensure pageProps is always an object
});
// Create HTML stream that processes the fallback RSC stream to ensure performance timing script is injected
const fallbackHtmlStream = createRscToHtmlStream({
id: handlerOptions.id,
route: handlerOptions.route,
url: handlerOptions.url,
moduleRootPath: handlerOptions.moduleRootPath,
moduleBasePath: handlerOptions.moduleBasePath,
moduleBaseURL: handlerOptions.moduleBaseURL,
projectRoot: handlerOptions.projectRoot,
panicThreshold: handlerOptions.panicThreshold,
verbose: handlerOptions.verbose,
signal: handlerOptions.signal,
logger: handlerOptions.logger,
htmlTimeout: handlerOptions.htmlTimeout,
clientPipeableStreamOptions: handlerOptions.clientPipeableStreamOptions,
onMetrics: handlerOptions.onMetrics,
build: handlerOptions.build,
});
// Create a wrapper that pipes the fallback RSC stream through the HTML transform
const clientOnlyHtmlStreamWrapper = {
pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => {
// Pipe the fallback RSC stream through the HTML transform to ensure performance timing script is injected
return fallbackHtmlStream.pipe(destination);
},
abort: () => {
// Clean up the fallback RSC stream
fallbackRscStream.abort();
},
};
// Create an empty RSC stream wrapper
const emptyRscStreamWrapper = {
pipe: <Writable extends NodeJS.WritableStream>(destination: Writable) => {
// No RSC content for skipped routes
destination.end();
return destination;
},
abort: () => {
// No cleanup needed
},
};
yield {
type: "skip",
reason: error,
html: clientOnlyHtmlStreamWrapper,
rsc: emptyRscStreamWrapper,
metrics: {
rscFull: rscFullMetrics,
rscHeadless: rscHeadlessMetrics,
html: htmlMetrics,
},
};
}
}
};