HTML Renderer Pipeline
Understand how Dinou isolates Server Components rendering from Client SSR through a dual-process bridge using render-app-to-html.js and render-html.js.
Key Files Involved:
• Parent manager:./dinou/core/render-app-to-html.js
• Child renderer:./dinou/core/render-html.js
💡 Overview
React 19 separates the execution of Server Components (RSC) and Client Components. The server needs to run under the react-server environment condition, while client-side rendering (SSR) must run under the standard React environment. Attempting to run both in the same Node.js process leads to V8 memory conflicts and duplicate module definitions.
Dinou bypasses this boundary limit by executing these tasks in two isolated Node.js processes communicating through an IPC pipe and standard outputs.
🔄 The Two-Process SSR Pipeline
The lifecycle of an HTML request follows this decoupled execution graph:
⚡ Parent Handler: render-app-to-html.js
This module acts as the orchestrator running inside the master Express server process. It handles child process lifecycle management, serializes requests across the IPC channel, and manages response streaming.
render-app-to-html.js Code Structure & Functions
The file defines the following helper variables, utilities, and main export:
1. Dependencies & Module Imports
The orchestrator imports core Node.js modules like child_process (specifically fork), fs, path, and url. In addition, it imports project utilities such as getJSX and requestStorage (for AsyncLocalStorage request context tracking).
Depending on the build tool configuration (Webpack vs. ESM), the parent dynamically imports the corresponding React server rendering engine:
const path = require("path");
const { fork } = require("child_process");
const url = require("url");
const fs = require("fs");
const getJSX = require("./get-jsx.js");
const { requestStorage } = require("./request-context.js");
const isDevelopment = process.env.NODE_ENV !== "production";
const isWebpack = process.env.DINOU_BUILD_TOOL === "webpack";
const { renderToPipeableStream } = isWebpack
? require("react-server-dom-webpack/server")
: require("@roggc/react-server-dom-esm/server");2. Global Helper Functions
Two key helpers are defined at the module scope to load manifests and format URLs:
getManifest(): Synchronously reads the client build manifest (react-client-manifest.json) from the current distribution directory. In production builds, this manifest is cached in memory (cachedManifest) to minimize filesystem overhead.toFileUrl(p): Converts absolute physical files paths to absolutefile://URLs required by dynamic ESM loaders.
Additionally, the script resolves paths for loader hooks and standard render modules, and imports URL resolver helpers:
const manifestPath = path.resolve(
process.cwd(),
isWebpack
? (isDevelopment ? "public/react-client-manifest.json" : "dist3/react-client-manifest.json")
: "react_client_manifest/react-client-manifest.json"
);
let cachedManifest = null;
function getManifest() {
if (!isDevelopment && cachedManifest) return cachedManifest;
try {
const content = fs.readFileSync(manifestPath, "utf8");
const parsed = JSON.parse(content);
if (parsed && Object.keys(parsed).length > 0) {
cachedManifest = parsed;
}
return cachedManifest || parsed;
} catch (e) {
if (cachedManifest) {
console.warn("Using cached client manifest due to read error:", e.message);
return cachedManifest;
}
console.error("Error reading client manifest:", e);
return {};
}
}
function toFileUrl(p) {
return url.pathToFileURL(p).href;
}
const registerLoaderPath = toFileUrl(
path.join(__dirname, "register-loader.mjs"),
);
const renderHtmlPath = path.resolve(__dirname, "render-html.js");
const ESSENTIAL_NODE_ARGS = [];
const loaderArg = `--import=${registerLoaderPath}`;
const childExecArgv = ESSENTIAL_NODE_ARGS.concat(loaderArg);
const { resolveRelativeUrl } = require("./url-resolver");3. createParentResponseWrapper(reqPath, res, child)
This function returns a mocked response helper object. When Server Components execute in the parent process under an AsyncLocalStorage execution context (requestStorage.run), any mutations on headers, cookies, redirects, or HTTP status codes are intercepted by this wrapper:
- Headers Clean (Headers not sent yet): Calls are forwarded directly to Express's native response methods (e.g.
res.cookieorres.setHeader). - Headers Sent (Streaming started): Since HTTP headers cannot be altered once streaming to the client has begun, the wrapper falls back to injecting inline JavaScript
<script>blocks directly into the HTML response stream to apply changes client-side (such as updatingdocument.cookieor changingwindow.location.href).
function createParentResponseWrapper(reqPath, res, child) {
let hasRedirected = false;
const safeRedirect = (targetUrl) => {
if (hasRedirected) return;
hasRedirected = true;
const resolvedUrl = resolveRelativeUrl(targetUrl, reqPath);
let finalUrl = "/";
if (
typeof resolvedUrl === "string" &&
resolvedUrl.startsWith("/") &&
!resolvedUrl.startsWith("//")
) {
finalUrl = resolvedUrl;
} else {
console.warn(
`[Dinou Security] Blocked unsafe redirect to: ${targetUrl}`,
);
}
if (res.headersSent) {
console.log(
`[Dinou] Streaming active. Redirecting via JavaScript to: ${finalUrl}`,
);
const safeUrl = JSON.stringify(finalUrl);
res.write(`<script>window.location.href = ${safeUrl};</script>`);
res.end();
child.stdout.unpipe(res);
child.kill();
} else {
res.redirect(302, finalUrl);
child.stdout.unpipe(res);
child.kill();
}
};
return {
setHeader: (name, value) => {
if (res.headersSent) {
console.warn(
`[Dinou Warning] Cannot set header '${name}' because streaming started.`,
);
} else {
res.setHeader(name, value);
}
},
cookie: (name, value, options) => {
if (res.headersSent) {
if (options && options.httpOnly) {
console.error(
`[Dinou Error] Cannot set HttpOnly cookie '${name}' because streaming has already started.`,
);
return;
}
console.log(
`[Dinou] Streaming active. Setting cookie '${name}' via JS.`,
);
let cookieStr = `${name}=${encodeURIComponent(value)}`;
if (options) {
if (options.path) cookieStr += `; path=${options.path}`;
if (options.domain) cookieStr += `; domain=${options.domain}`;
if (options.maxAge) cookieStr += ` max-age=${options.maxAge}`;
if (options.expires)
cookieStr += `; expires=${new Date(options.expires).toUTCString()}`;
if (options.secure) cookieStr += `; secure`;
if (options.sameSite)
cookieStr += `; samesite=${options.sameSite}`;
}
const safeCookieStr = JSON.stringify(cookieStr);
res.write(`<script>document.cookie = ${safeCookieStr};</script>`);
} else {
res.cookie(name, value, options);
}
},
clearCookie: (name, options) => {
if (res.headersSent) {
console.log(
`[Dinou] Streaming active. Clearing cookie '${name}' via JS.`,
);
let cookieStr = `${name}=; Max-Age=0`;
const path = options?.path || "/";
cookieStr += `; path=${path}`;
if (options) {
if (options.domain) cookieStr += `; domain=${options.domain}`;
if (options.secure) cookieStr += `; secure`;
if (options.sameSite) cookieStr += `; samesite=${options.sameSite}`;
}
cookieStr += ";";
const safeCookieStr = JSON.stringify(cookieStr);
res.write(`<script>document.cookie = ${safeCookieStr};</script>`);
} else {
res.clearCookie(name, options);
}
},
redirect: (arg1, arg2) => {
const url = arg2 || arg1;
safeRedirect(url);
},
status: (code) => {
if (res.headersSent) {
console.warn(
`[Dinou Warning] HTTP status '${code}' ignored because streaming started.`,
);
} else {
res.status(code);
}
},
};
}4. Main Export: renderAppToHtml(...)
This is the entry point invoked by the Express server. It handles cache optimization, process spawning, IPC communication setup, and output streaming:
function renderAppToHtml(
reqPath,
paramsString,
contextForChild,
res,
capturedStatus = null,
isDynamic = false,
forceNotFound = false,
) {
// Spawns the child process renderer: fork(renderHtmlPath, [args], { stdio: [..., fd:4] })
const child = fork(
renderHtmlPath,
[
reqPath,
paramsString,
contextForChild ? JSON.stringify(contextForChild) : JSON.stringify({}),
isDynamic ? "true" : "false",
],
{
execArgv: childExecArgv,
stdio: ["ignore", "pipe", "pipe", "ipc", "pipe"], // fd 4 is the RSC stream pipe
},
);
const query = JSON.parse(paramsString || "{}");
const rscPath = path.resolve(process.cwd(), "dist2", reqPath.replace(/^\//, ""), "rsc.rsc");
const hasStaticRsc = !isDynamic && fs.existsSync(rscPath);
if (hasStaticRsc) {
// IF CACHED: pipes compiled static dist2/rsc.rsc directly into fd 4
try {
const rscBuffer = fs.readFileSync(rscPath);
child.stdio[4].write(rscBuffer);
child.stdio[4].end();
} catch (err) {
console.error(`[Dinou] Failed to read static RSC from ${rscPath}:`, err.message);
if (child.stdio[4]) child.stdio[4].destroy();
}
} else {
// IF DYNAMIC: renders Server Components (RSC) to binary Flight payload stream
const isNotFound = {};
const parentRes = createParentResponseWrapper(reqPath, res, child);
const context = {
req: contextForChild ? contextForChild.req : {},
res: parentRes,
};
requestStorage.run(context, () => {
getJSX(reqPath, query, isNotFound, isDevelopment, forceNotFound)
.then((jsx) => {
if (isNotFound.value) {
parentRes.status(404);
}
const manifest = getManifest();
const { pipe } = isWebpack
? renderToPipeableStream(jsx, manifest)
: renderToPipeableStream(jsx, url.pathToFileURL(process.cwd()).href + "/");
pipe(child.stdio[4]);
})
.catch((err) => {
console.error("Error rendering JSX in parent renderAppToHtml:", err);
if (child.stdio[4]) child.stdio[4].destroy();
});
});
}
// Sets up IPC message listener: child.on("message", createParentResponseWrapper proxy)
child.on("message", (message) => {
// ... handles message commands
});
// Streams output: child.stdout.pipe(res)
return child.stdout;
}Detailed Steps of the Render Lifecycle
- Child Process Spawning: The parent forks
render-html.jsto run standard React SSR in a clean sandbox. Thestdioarray is mapped with a custom file descriptor:stdio[1] (stdout): Set to"pipe"to read the compiled HTML chunks back from the child.stdio[3] (ipc): Set to"ipc"to establish the bidirectional command channel.stdio[4] (pipe): Mapped to a custom write stream (child.stdio[4]) dedicated to piping the RSC flight binary data.
- Cache Resolution (RSC Cache Bypass):
- If Static RSC is cached: Reads the pre-built
dist2/../rsc.rscbuffer and writes it directly to the child's RSC file descriptorstdio[4], completely bypassing dynamic rendering. - If Dynamic RSC is requested: Invokes
getJSX()within the request context to render Server Components, compiling them into a pipeable Flight stream which is written tostdio[4].
- If Static RSC is cached: Reads the pre-built
- IPC Command Listener: It registers a listener on the
"message"event from the child. When the child performs actions that change response metadata, they are processed through the IPC channel:child.on("message", (message) => { if (message && message.type === "DINOU_CONTEXT_COMMAND") { const { command, args } = message; if ( command === "setHeader" || command === "clearCookie" || command === "cookie" || command === "status" || command === "redirect" ) { // SCENARIO 1: STREAMING ALREADY STARTED (Headers sent) if (res.headersSent) { if (command === "redirect") { const rawUrl = args.length === 1 ? args[0] : args[1]; const resolvedUrl = resolveRelativeUrl(rawUrl, reqPath); let finalUrl = resolvedUrl.startsWith("/") && !resolvedUrl.startsWith("//") ? resolvedUrl : "/"; res.write(`<script>window.location.href = ${JSON.stringify(finalUrl)};</script>`); res.end(); child.stdout.unpipe(res); child.kill(); return; } if (command === "cookie") { const [name, value, options] = args; if (options && options.httpOnly) return; let cookieStr = `${name}=${encodeURIComponent(value)}`; // ... constructs options ... res.write(`<script>document.cookie = ${JSON.stringify(cookieStr)};</script>`); return; } // ... } // SCENARIO 2: HEADERS NOT YET SENT (Normal Express usage) if (typeof res[command] === "function") { if (command === "redirect") { let status = args.length === 2 ? args[0] : 302; let rawUrl = args.length === 2 ? args[1] : args[0]; const resolvedUrl = resolveRelativeUrl(rawUrl, reqPath); let finalUrl = resolvedUrl.startsWith("/") && !resolvedUrl.startsWith("//") ? resolvedUrl : "/"; res.redirect(status, finalUrl); child.stdout.unpipe(res); child.kill(); return; } res[command].apply(res, args); } } } });
IPC & Process Pipeline Flow Diagram:
⚙️ Child Process: render-html.js
The child process runs in a clean standard React rendering thread (free from the react-server environment condition). Below is the complete step-by-step breakdown of its internal execution pipeline:
render-html.js Code Structure & Functions
The file defines the following global structures, internal utilities, and self-execution hook:
1. Webpack Runtime Global Mocks
React Client Components compiled by bundlers rely on specific globals like __webpack_require__ and __webpack_chunk_load__ to resolve chunks in the browser. Since the isolated child process runs inside a native Node.js V8 context, it overrides these globals at startup to redirect module resolution:
global.__webpack_require__ = function (id) {
if (global.__webpack_require_map__ && global.__webpack_require_map__[id]) {
return require(global.__webpack_require_map__[id]); // Redirects to local system file path
}
if (typeof id === "string" && id.startsWith("./")) {
id = path.resolve(process.cwd(), id);
}
return require(id);
};
global.__webpack_chunk_load__ = () => Promise.resolve(); // Chunks are already on diskglobal.__webpack_require__: Intercepts imports. If a component request matches a key inglobal.__webpack_require_map__, it maps the identifier to the absolute physical file on disk (calculated from the Client Manifest) and calls Node's nativerequire().global.__webpack_chunk_load__: Mocks dynamic loading. Because all bundle assets already reside locally on the disk, dynamic loading is a no-op that resolves immediately.
2. Core Environment Setup & Require Hooks
Before executing JSX or user styles, the child process establishes its JIT compiler hooks to prevent syntax or resolution errors:
const babelRegister = require("@babel/register");
babelRegister({
ignore: [/node_modules[\\/](?!dinou)/], // Compile app and framework core files
presets: [
["@babel/preset-react", { runtime: "automatic" }],
"@babel/preset-typescript",
],
plugins: ["@babel/transform-modules-commonjs"],
extensions: [".js", ".jsx", ".ts", ".tsx"],
});
require("./css-require-hook.js")(); // Parse CSS Modules to class maps
addHook({
extensions,
name: (localName, filepath) => createScopedName(localName, filepath) + ".[ext]",
publicPath: "/assets/",
}); // Parse image/svg imports to static public URL strings- Babel Register: Compiles React 19 JSX brackets and TypeScript constructs into raw CommonJS.
- CSS Require Hook: Compiles PostCSS classes into JSON keymaps, outputting scoped class names (e.g., mapping
.containerto.container__x3a2) matching the client stylesheet builds. - Asset Require Hook (
addHook): Intercepts file extensions for static assets (like.pngor.svg) to prevent evaluation errors, returning a static URL string (e.g.,/assets/logo.a1b2c3.png).
3. Error Rendering Functions
In the event of a compiler or React rendering crash, render-html.js uses dedicated templates to output a complete, standalone error HTML payload:
function formatErrorHtml(error) {
const message = error.message || "Unknown error";
const stack = error.stack ? error.stack.replace(/\n/g, "<br>").replace(/\s/g, " ") : "No stack trace available";
return `<!DOCTYPE html><html>...<body><h1 class="error-title">An Error Occurred</h1><p class="error-message">${message}</p><div class="error-stack">${stack}</div></body></html>`;
}
function writeErrorOutput(error, isProd) {
process.stdout.write(
isProd ? formatErrorHtmlProduction(error) : formatErrorHtml(error)
);
process.stderr.write(
JSON.stringify({ error: error.message, stack: error.stack })
);
}formatErrorHtml: Produces a stylized HTML overlay displaying the error stack trace, tailored for local debugging.formatErrorHtmlProduction: Outputs a minimal HTML template containing a script that logs the error context to the client browser's console, hiding implementation details from the user.writeErrorOutput: Directs the formatted HTML directly toprocess.stdoutand writes the raw JSON traceback metadata block toprocess.stderrbefore exiting the process.
4. Manifest & Import Map Utilities
Before starting the React stream, the child reads the compilation manifests to populate Webpack and ESM resolvers:
function getSsrManifest() {
const ssrManifest = JSON.parse(fs.readFileSync(ssrManifestPath, "utf8"));
const clientManifest = JSON.parse(fs.readFileSync(clientManifestPath, "utf8"));
const requireMap = {};
for (const [fileUrl, entry] of Object.entries(clientManifest)) {
if (entry && entry.id !== undefined) {
requireMap[entry.id] = fileURLToPath(fileUrl); // Mappings table
}
}
global.__webpack_require_map__ = requireMap; // Populate require() polyfill
return ssrManifest;
}getSsrManifest(): Populates the global__webpack_require_map__by converting all client manifestfile://URLs to absolute paths, ensuring runtime require lookups succeed.getImportMapHtml(): Builds a<script type="importmap">element dynamically to resolve ES module specifiers in non-webpack environments.
How Module Resolution Differs: Webpack vs. ESM (esbuild/Rollup)
Webpack relies on abstract module IDs (e.g., numeric IDs like 102) instead of actual file paths. During Server-Side Rendering (SSR), React needs getSsrManifest() to build __webpack_require_map__, mapping those abstract IDs back to physical file paths on disk for Node's require(). Under Webpack, getImportMapHtml() is a no-op that returns an empty string, since the Webpack runtime handles module loading in the browser.
ESM-based runtimes (using esbuild and Rollup) write native relative ES module paths directly into the Flight stream. The child process resolves these paths natively via standard dynamic import() statements, rendering getSsrManifest() unnecessary. However, the client browser needs to resolve module specifiers (bare imports like import React from 'react') to physical URLs. This is solved by getImportMapHtml(), which reads react-client-manifest.json to inject a <script type="importmap"> element dynamically in non-webpack environments.
5. Main Render Implementation: renderToStream
This asynchronous function orchestrates the reading, reconstruction, and rendering of the React tree:
async function renderToStream(
reqPath,
query,
serializedBox,
isDynamic,
) {
const context = {
req: serializedBox.req,
res: createResponseProxy(),
};
// 1. Run inside the asynchronous execution context
await requestStorage.run(context, async () => {
try {
const { createReadStream } = require("fs");
const rscStream = createReadStream(null, { fd: 4 });
const { pathToFileURL } = require("url");
const baseUrl = pathToFileURL(process.cwd()).href + "/";
// 2. Reconstruct Client-Safe JSX Components Graph
const jsx = isWebpack
? await createFromNodeStream(rscStream, getSsrManifest())
: await createFromNodeStream(rscStream, baseUrl, baseUrl);
// 3. Compile JSX to HTML chunks streamed to stdout
const stream = renderToPipeableStream(jsx, {
onShellReady() {
if (!isWebpack) {
const importMapHtml = getImportMapHtml(); // Inject importmaps in ESM
process.stdout.write(importMapHtml);
}
stream.pipe(process.stdout);
},
onError(error) {
// 4. Advanced Error Recovery Boundary
process.nextTick(async () => {
if (stream && !stream.destroyed) {
try {
stream.unpipe(process.stdout);
stream.destroy();
} catch { }
}
const isProd = process.env.NODE_ENV === "production";
try {
const errorJSX = await getErrorJSX(reqPath, query, error, isDevelopment);
if (!context.res.headersSent) context.res.status(500);
if (errorJSX === undefined) {
writeErrorOutput(error, isProd);
process.exit(1); // Hard Fallback
}
// Render custom boundary (error.tsx)
const errorStream = renderToPipeableStream(errorJSX, {
onShellReady() {
if (!isWebpack) {
const importMapHtml = getImportMapHtml();
process.stdout.write(importMapHtml);
}
errorStream.pipe(process.stdout);
},
onError(err) {
console.error("Error rendering error JSX:", err);
writeErrorOutput(error, isProd);
process.exit(1);
},
bootstrapModules: isDevelopment
? [
getAssetFromManifest("error.js"),
isWebpack ? undefined : getAssetFromManifest("runtime.js"),
].filter(Boolean)
: [getAssetFromManifest("error.js")],
bootstrapScriptContent: `window.__DINOU_ERROR_MESSAGE__=${JSON.stringify(
error.message || "Unknown error",
)};window.__DINOU_ERROR_NAME__=${JSON.stringify(error.name)};${isDevelopment
? `window.__DINOU_ERROR_STACK__=${JSON.stringify(error.stack || "")};`
: ""
}${isDevelopment ? `window.HMR_WEBSOCKET_URL="ws://localhost:3001";` : ""}`,
});
} catch (err) {
console.error("Render error (no error.tsx?):", err);
writeErrorOutput(error, isProd);
process.exit(1);
}
});
},
bootstrapModules: isDevelopment
? [
getAssetFromManifest("main.js"),
isWebpack ? undefined : getAssetFromManifest("runtime.js"),
].filter(Boolean)
: [getAssetFromManifest("main.js")],
...(isDevelopment
? {
bootstrapScriptContent: `window.HMR_WEBSOCKET_URL="ws://localhost:3001";`,
}
: {}),
});
} catch (error) {
if (context && context.res && typeof context.res.status === "function") {
if (!context.res.headersSent) context.res.status(500);
}
process.stdout.write(formatErrorHtml(error));
process.stderr.write(
JSON.stringify({ error: error.message, stack: error.stack }),
);
process.exit(1);
}
});
}During execution, renderToStream opens a stream on file descriptor fd:4 to read the binary Flight stream, parsing it with React's createFromNodeStream() within the isolated requestStorage.run context.
The rebuilt component graph is then compiled to HTML chunks via React's renderToPipeableStream(). The orchestration executes through the following structural blocks:
- Context Isolation (
requestStorage.run): Wraps the rendering thread inside a thread-safe AsyncLocalStorage container. This ensures sub-components can access request headers, query parameters, and cookie contexts without cross-talk. - Client Asset Resolution (
bootstrapModules): Maps client hydration bundles usinggetAssetFromManifest(). This dynamic lookup maps the static identifiers (likemain.jsandruntime.js) to the physical hash-appended build assets inside the client manifest. - HMR WebSocket Injection (
bootstrapScriptContent): In development mode, it registers the globalwindow.HMR_WEBSOCKET_URLstring, allowing the client-side browser to open hot reloading pipes. - Advanced Error Recovery Hook (
onError): If an SSR compiler or runtime crash is intercepted:- Stdout Detaching: The child immediately detaches the active stream from
process.stdoutand destroys it to prevent corrupted layouts from reaching the browser. - Custom Error Boundary: It resolves the project's custom
error.tsxcomponent viagetErrorJSX(). If found, it status-codes the request to 500 and renders the boundary, injecting metadata logs (__DINOU_ERROR_MESSAGE__,__DINOU_ERROR_STACK__) to the browser window. - Hard Fallback Exit: If no custom template exists or rendering the error component fails, it writes the raw stack trace template (
formatErrorHtml) and exits the process viaprocess.exit(1).
- Stdout Detaching: The child immediately detaches the active stream from
6. Self-Executing Startup Hook
At the very end of render-html.js, the script parses the command line arguments passed by the parent fork call and executes renderToStream() immediately:
const reqPath = process.argv[2] || "/";
const paramsString = process.argv[3] || "{}";
const contextJson = process.argv[4] || "{}";
const isDynamic = process.argv[5] === "true";
renderToStream(reqPath, paramsString, contextJson, isDynamic);🛠️ Common Tweak Recipes
You can customize the HTML wrapper document envelope by editing the template generator in core/render-app-to-html.js to include analytics, font preloads, or custom headers.
Tweak the CSS structure in formatErrorHtml() inside core/render-html.js to brand development crash screens to match your project aesthetic.