From 822e348728a98b7a67509539c7fa9f1948d9920e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Thu, 19 Mar 2026 16:34:15 +0100 Subject: [PATCH 1/6] fix: static file handler performance --- packages/react-server/lib/handlers/static.mjs | 49 +++++++++++++------ .../lib/http/middlewares/compose.mjs | 8 +++ packages/react-server/server/telemetry.mjs | 8 +++ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/packages/react-server/lib/handlers/static.mjs b/packages/react-server/lib/handlers/static.mjs index 0a2aa229..84417f41 100644 --- a/packages/react-server/lib/handlers/static.mjs +++ b/packages/react-server/lib/handlers/static.mjs @@ -17,11 +17,15 @@ const cwd = sys.cwd(); export default async function staticHandler(dir, options = {}) { const files = new Map(); + const misses = new Set(); const exists = (path) => { if (files.has(path)) { return true; } + if (misses.has(path)) { + return false; + } try { const file = statSync(join(cwd, options.cwd ?? ".", path)); if (file.isFile()) { @@ -40,6 +44,7 @@ export default async function staticHandler(dir, options = {}) { } catch { // ignore } + misses.add(path); return false; }; @@ -53,14 +58,22 @@ export default async function staticHandler(dir, options = {}) { let { pathname } = context.url; let contentEncoding = undefined; - let prelude = null; - const acceptEncoding = context.request.headers.get("accept-encoding"); - const isBrotli = acceptEncoding?.includes("br"); - const isGzip = acceptEncoding?.includes("gzip"); + // Resolve the file: try the path directly, then as /index.html + let basename; + if (exists(pathname)) { + basename = pathname; + } else { + const indexPath = `${pathname}/index.html`.replace(/^\/+/g, "/"); + if (exists(indexPath)) { + basename = indexPath; + } else { + // Neither the path nor its index.html exist in this handler's directory. + // Bail out early — no point checking postponed/compressed variants. + return; + } + } - const basename = ( - exists(pathname) ? pathname : `${pathname}/index.html` - ).replace(/^\/+/g, "/"); + let prelude = null; if (exists(`${basename}.postponed.json`)) { prelude = basename; pathname = basename; @@ -80,14 +93,20 @@ export default async function staticHandler(dir, options = {}) { ]); prerender$(POSTPONE_STATE, postponed); prerender$(PRERENDER_CACHE_DATA, cacheData); - } else if (isBrotli && exists(`${basename}.br`)) { - pathname = `${basename}.br`; - contentEncoding = "br"; - } else if (isGzip && exists(`${basename}.gz`)) { - pathname = `${basename}.gz`; - contentEncoding = "gzip"; - } else if (exists(basename)) { - pathname = basename; + } else { + const acceptEncoding = context.request.headers.get("accept-encoding"); + const isBrotli = acceptEncoding?.includes("br"); + const isGzip = acceptEncoding?.includes("gzip"); + + if (isBrotli && exists(`${basename}.br`)) { + pathname = `${basename}.br`; + contentEncoding = "br"; + } else if (isGzip && exists(`${basename}.gz`)) { + pathname = `${basename}.gz`; + contentEncoding = "gzip"; + } else { + pathname = basename; + } } if (pathname !== "/" && exists(pathname)) { diff --git a/packages/react-server/lib/http/middlewares/compose.mjs b/packages/react-server/lib/http/middlewares/compose.mjs index 3dfe5ce9..03b6c5e5 100644 --- a/packages/react-server/lib/http/middlewares/compose.mjs +++ b/packages/react-server/lib/http/middlewares/compose.mjs @@ -2,6 +2,7 @@ import { getTracer, getOtelContext, makeSpanContext, + isTracingEnabled, } from "../../../server/telemetry.mjs"; // ── Human-readable middleware display names ── @@ -41,6 +42,13 @@ export function compose(middlewares) { if (!fn) return undefined; context.next = () => dispatch(i + 1); + // Fast path: skip all span machinery when telemetry is disabled + if (!isTracingEnabled()) { + const result = await fn(context); + if (result === undefined) return context.next(); + return result; + } + // ── Telemetry: per-middleware span ── const tracer = getTracer(); const parentCtx = context._otelCtx ?? getOtelContext(); diff --git a/packages/react-server/server/telemetry.mjs b/packages/react-server/server/telemetry.mjs index 36c39b91..92072f31 100644 --- a/packages/react-server/server/telemetry.mjs +++ b/packages/react-server/server/telemetry.mjs @@ -102,6 +102,14 @@ async function otelApi() { // ─── Public helpers ────────────────────────────────────────────────────────── +/** + * Returns true when a real (non-noop) tracer has been configured. + * Use this to skip span construction overhead on the hot path. + */ +export function isTracingEnabled() { + return getRuntime(OTEL_TRACER) != null; +} + /** * Returns the active tracer (or a no-op tracer when telemetry is disabled). */ From 226270d57c2d41ce1cd88b0418039a0d2c51b573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Thu, 19 Mar 2026 17:43:02 +0100 Subject: [PATCH 2/6] fix: response streaming pipeline --- examples/benchmark/bench.mjs | 187 ++++++++++++++++++ examples/benchmark/package.json | 14 ++ examples/benchmark/pages/(root).layout.jsx | 12 ++ examples/benchmark/pages/cached.jsx | 42 ++++ examples/benchmark/pages/deep.jsx | 25 +++ examples/benchmark/pages/index.jsx | 7 + examples/benchmark/pages/large.jsx | 87 ++++++++ examples/benchmark/pages/medium.jsx | 39 ++++ examples/benchmark/pages/small.jsx | 31 +++ examples/benchmark/pages/wide.jsx | 27 +++ examples/benchmark/public/data.json | 5 + examples/benchmark/react-server.config.mjs | 3 + packages/react-server/lib/handlers/static.mjs | 7 +- packages/react-server/lib/http/middleware.mjs | 87 ++++---- packages/react-server/server/render-rsc.jsx | 21 +- packages/react-server/server/symbols.mjs | 1 + pnpm-lock.yaml | 6 + 17 files changed, 549 insertions(+), 52 deletions(-) create mode 100644 examples/benchmark/bench.mjs create mode 100644 examples/benchmark/package.json create mode 100644 examples/benchmark/pages/(root).layout.jsx create mode 100644 examples/benchmark/pages/cached.jsx create mode 100644 examples/benchmark/pages/deep.jsx create mode 100644 examples/benchmark/pages/index.jsx create mode 100644 examples/benchmark/pages/large.jsx create mode 100644 examples/benchmark/pages/medium.jsx create mode 100644 examples/benchmark/pages/small.jsx create mode 100644 examples/benchmark/pages/wide.jsx create mode 100644 examples/benchmark/public/data.json create mode 100644 examples/benchmark/react-server.config.mjs diff --git a/examples/benchmark/bench.mjs b/examples/benchmark/bench.mjs new file mode 100644 index 00000000..11bc3d9d --- /dev/null +++ b/examples/benchmark/bench.mjs @@ -0,0 +1,187 @@ +/** + * Benchmark harness for @lazarv/react-server production performance. + * + * Usage: + * 1. pnpm --filter @lazarv/react-server-example-benchmark build + * 2. node bench.mjs + * + * Runs autocannon against each benchmark route and prints a summary table. + */ +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; + +process.env.NODE_ENV = "production"; + +const PORT = 3210; +const DURATION = 10; // seconds per test +const CONNECTIONS = 50; + +// ── Boot production server ────────────────────────────────────────────────── + +const { reactServer } = await import("@lazarv/react-server/node"); +const { middlewares } = await reactServer({ + origin: `http://localhost:${PORT}`, + host: "localhost", + port: PORT, + outDir: ".react-server", +}); + +const server = createServer(middlewares); +await new Promise((resolve) => server.listen(PORT, resolve)); + +console.log(`\nBenchmark server on http://localhost:${PORT}`); +console.log(`Running ${DURATION}s per test, ${CONNECTIONS} connections\n`); + +// ── Benchmark definitions ─────────────────────────────────────────────────── + +const BENCHMARKS = [ + { name: "minimal", path: "/", desc: "Minimal SSR (tiny page)" }, + { name: "small", path: "/small", desc: "Small SSR (~20 elements)" }, + { name: "medium", path: "/medium", desc: "Medium SSR (50 products)" }, + { name: "large", path: "/large", desc: "Large SSR (500-row table)" }, + { name: "deep", path: "/deep", desc: "Deep nesting (100 levels)" }, + { name: "wide", path: "/wide", desc: "Wide tree (1000 siblings)" }, + { name: "cached", path: "/cached", desc: "Cached medium page" }, + { + name: "static-json", + path: "/data.json", + desc: "Static file (JSON)", + }, + { + name: "static-js", + path: null, // resolved dynamically + desc: "Static file (JS bundle)", + }, + { name: "404-miss", path: "/nonexistent", desc: "404 miss → SSR" }, +]; + +// ── Find an actual JS bundle path ─────────────────────────────────────────── + +import { readdirSync } from "node:fs"; +try { + const clientFiles = readdirSync(".react-server/client"); + const jsBundle = clientFiles.find( + (f) => f.endsWith(".mjs") && f.includes(".") + ); + if (jsBundle) { + BENCHMARKS.find((b) => b.name === "static-js").path = `/client/${jsBundle}`; + } +} catch { + // skip if not found +} + +// ── Run autocannon ────────────────────────────────────────────────────────── + +async function runAutocannon(url, extraArgs = []) { + return new Promise((resolve, reject) => { + const args = [ + "autocannon", + "-c", + String(CONNECTIONS), + "-d", + String(DURATION), + "--json", + ...extraArgs, + url, + ]; + const proc = spawn("npx", args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + proc.stdout.on("data", (d) => (stdout += d)); + proc.on("close", (code) => { + try { + resolve(JSON.parse(stdout)); + } catch { + reject(new Error(`autocannon failed (code ${code}): ${stdout}`)); + } + }); + proc.on("error", reject); + }); +} + +// ── Warm up ───────────────────────────────────────────────────────────────── + +console.log("Warming up..."); +for (const b of BENCHMARKS) { + if (!b.path) continue; + try { + await fetch(`http://localhost:${PORT}${b.path}`); + } catch { + // ignore + } +} +// Second pass to ensure caches are primed +for (const b of BENCHMARKS) { + if (!b.path) continue; + try { + await fetch(`http://localhost:${PORT}${b.path}`); + } catch { + // ignore + } +} +console.log("Warm-up done.\n"); + +// ── Run benchmarks ────────────────────────────────────────────────────────── + +const results = []; + +for (const b of BENCHMARKS) { + if (!b.path) { + console.log(`⏭ Skipping ${b.name} (no path resolved)`); + continue; + } + + process.stdout.write(`▶ ${b.name.padEnd(14)} ${b.desc}...`); + const url = `http://localhost:${PORT}${b.path}`; + const data = await runAutocannon(url); + + const result = { + name: b.name, + desc: b.desc, + path: b.path, + reqSec: data.requests.average, + latencyAvg: data.latency.average, + latencyP50: data.latency.p50, + latencyP99: data.latency.p99, + throughputMB: (data.throughput.average / 1024 / 1024).toFixed(1), + total2xx: data["2xx"], + errors: data.errors, + }; + results.push(result); + + console.log( + ` ${result.reqSec.toFixed(0)} req/s | avg ${result.latencyAvg}ms | p99 ${result.latencyP99}ms` + ); +} + +// ── Summary table ─────────────────────────────────────────────────────────── + +console.log("\n" + "═".repeat(110)); +console.log( + " " + + "Benchmark".padEnd(16) + + "Req/s".padStart(10) + + "Avg (ms)".padStart(10) + + "P50 (ms)".padStart(10) + + "P99 (ms)".padStart(10) + + "Throughput".padStart(12) + + " " + + "Description" +); +console.log("─".repeat(110)); +for (const r of results) { + console.log( + " " + + r.name.padEnd(16) + + String(r.reqSec.toFixed(0)).padStart(10) + + String(r.latencyAvg).padStart(10) + + String(r.latencyP50).padStart(10) + + String(r.latencyP99).padStart(10) + + `${r.throughputMB} MB/s`.padStart(12) + + " " + + r.desc + ); +} +console.log("═".repeat(110)); + +server.close(); +process.exit(0); diff --git a/examples/benchmark/package.json b/examples/benchmark/package.json new file mode 100644 index 00000000..e46a2aeb --- /dev/null +++ b/examples/benchmark/package.json @@ -0,0 +1,14 @@ +{ + "name": "@lazarv/react-server-example-benchmark", + "private": true, + "description": "Performance benchmark example for @lazarv/react-server", + "scripts": { + "dev": "react-server", + "build": "react-server build", + "start": "react-server start", + "bench": "node bench.mjs" + }, + "dependencies": { + "@lazarv/react-server": "workspace:^" + } +} diff --git a/examples/benchmark/pages/(root).layout.jsx b/examples/benchmark/pages/(root).layout.jsx new file mode 100644 index 00000000..c65a987e --- /dev/null +++ b/examples/benchmark/pages/(root).layout.jsx @@ -0,0 +1,12 @@ +export default function RootLayout({ children }) { + return ( + + + + + Benchmark + + {children} + + ); +} diff --git a/examples/benchmark/pages/cached.jsx b/examples/benchmark/pages/cached.jsx new file mode 100644 index 00000000..40082857 --- /dev/null +++ b/examples/benchmark/pages/cached.jsx @@ -0,0 +1,42 @@ +/** + * Cached page — same content as /medium but with response caching enabled. + * Measures the response cache fast path vs uncached rendering. + */ +import { useResponseCache } from "@lazarv/react-server"; + +function ProductCard({ id }) { + return ( +
+
+

Product {id}

+

+ High-quality item with excellent features. Perfect for everyday use. + Rating: {(id % 5) + 1}/5 stars. +

+
+ ${((id * 17 + 29) % 200) + 9.99} + {id % 3 === 0 && (On Sale)} +
+ +
+ ); +} + +export default function Cached() { + useResponseCache(60000); + + const products = Array.from({ length: 50 }, (_, i) => i + 1); + return ( +
+
+

Products (Cached)

+

Showing {products.length} items

+
+
+ {products.map((id) => ( + + ))} +
+
+ ); +} diff --git a/examples/benchmark/pages/deep.jsx b/examples/benchmark/pages/deep.jsx new file mode 100644 index 00000000..573a8286 --- /dev/null +++ b/examples/benchmark/pages/deep.jsx @@ -0,0 +1,25 @@ +/** + * Deep nesting page — 100 levels of component nesting. + * Tests React reconciler / RSC serialization overhead with deep trees. + * Small HTML output but deep virtual DOM. + */ + +function Wrapper({ depth, children }) { + if (depth <= 0) return
{children}
; + return ( +
+ {children} +
+ ); +} + +export default function Deep() { + return ( +
+

Deep Nesting (100 levels)

+ +

Leaf node at the bottom of the tree.

+
+
+ ); +} diff --git a/examples/benchmark/pages/index.jsx b/examples/benchmark/pages/index.jsx new file mode 100644 index 00000000..d7b05c7d --- /dev/null +++ b/examples/benchmark/pages/index.jsx @@ -0,0 +1,7 @@ +/** + * Index page — minimal SSR, no data, tiny HTML. + * Baseline for measuring framework overhead. + */ +export default function Index() { + return

Benchmark

; +} diff --git a/examples/benchmark/pages/large.jsx b/examples/benchmark/pages/large.jsx new file mode 100644 index 00000000..66085538 --- /dev/null +++ b/examples/benchmark/pages/large.jsx @@ -0,0 +1,87 @@ +/** + * Large page — a data table with 500 rows × 8 columns. + * ~4000+ elements, ~80KB+ HTML. Typical admin dashboard / report. + */ + +const COLUMNS = [ + "ID", + "Name", + "Email", + "Department", + "Role", + "Status", + "Joined", + "Score", +]; + +const DEPARTMENTS = [ + "Engineering", + "Marketing", + "Sales", + "Support", + "Design", + "Product", + "Finance", + "Legal", +]; +const ROLES = [ + "Manager", + "Senior", + "Junior", + "Lead", + "Director", + "Intern", + "Principal", + "Staff", +]; +const STATUSES = ["Active", "Inactive", "On Leave", "Probation"]; + +function TableRow({ i }) { + const dept = DEPARTMENTS[i % DEPARTMENTS.length]; + const role = ROLES[i % ROLES.length]; + const status = STATUSES[i % STATUSES.length]; + const score = ((i * 7 + 13) % 100) + 1; + return ( + + {i} + + User {i} {dept[0]} + + user{i}@example.com + {dept} + {role} + {status} + + 2024-{String((i % 12) + 1).padStart(2, "0")}- + {String((i % 28) + 1).padStart(2, "0")} + + {score} + + ); +} + +export default function Large() { + const rows = Array.from({ length: 500 }, (_, i) => i + 1); + return ( +
+
+

Employee Directory

+

{rows.length} records

+
+ + + + {COLUMNS.map((col) => ( + + ))} + + + + {rows.map((i) => ( + + ))} + +
{col}
+
+ ); +} diff --git a/examples/benchmark/pages/medium.jsx b/examples/benchmark/pages/medium.jsx new file mode 100644 index 00000000..a200f1fa --- /dev/null +++ b/examples/benchmark/pages/medium.jsx @@ -0,0 +1,39 @@ +/** + * Medium page — a product listing with 50 items. + * ~200 elements, ~10KB HTML. Typical e-commerce or dashboard page. + */ + +function ProductCard({ id }) { + return ( +
+
+

Product {id}

+

+ High-quality item with excellent features. Perfect for everyday use. + Rating: {(id % 5) + 1}/5 stars. +

+
+ ${((id * 17 + 29) % 200) + 9.99} + {id % 3 === 0 && (On Sale)} +
+ +
+ ); +} + +export default function Medium() { + const products = Array.from({ length: 50 }, (_, i) => i + 1); + return ( +
+
+

Products

+

Showing {products.length} items

+
+
+ {products.map((id) => ( + + ))} +
+
+ ); +} diff --git a/examples/benchmark/pages/small.jsx b/examples/benchmark/pages/small.jsx new file mode 100644 index 00000000..5ed57544 --- /dev/null +++ b/examples/benchmark/pages/small.jsx @@ -0,0 +1,31 @@ +/** + * Small page — a handful of elements, typical for a landing page hero section. + * ~20 elements, ~1KB HTML. + */ +export default function Small() { + return ( +
+
+ +
+
+

Welcome to Our Platform

+

+ Build modern web applications with server components. Fast, reliable, + and easy to use. +

+
+ + +
+
+
+

© 2026 Benchmark App

+
+
+ ); +} diff --git a/examples/benchmark/pages/wide.jsx b/examples/benchmark/pages/wide.jsx new file mode 100644 index 00000000..64911c9b --- /dev/null +++ b/examples/benchmark/pages/wide.jsx @@ -0,0 +1,27 @@ +/** + * Wide page — 1000 sibling components. + * Tests RSC serialization and HTML rendering with broad, flat trees. + * ~3000 elements, ~30KB HTML. + */ + +function Item({ i }) { + return ( +
  • + Item #{i}{i % 2 === 0 ? "even" : "odd"} +
  • + ); +} + +export default function Wide() { + const items = Array.from({ length: 1000 }, (_, i) => i + 1); + return ( +
    +

    Wide Tree (1000 siblings)

    +
      + {items.map((i) => ( + + ))} +
    +
    + ); +} diff --git a/examples/benchmark/public/data.json b/examples/benchmark/public/data.json new file mode 100644 index 00000000..3ce57983 --- /dev/null +++ b/examples/benchmark/public/data.json @@ -0,0 +1,5 @@ +{ + "benchmark": true, + "items": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "description": "Static JSON file for benchmarking static file serving performance." +} diff --git a/examples/benchmark/react-server.config.mjs b/examples/benchmark/react-server.config.mjs new file mode 100644 index 00000000..c59a1749 --- /dev/null +++ b/examples/benchmark/react-server.config.mjs @@ -0,0 +1,3 @@ +export default { + root: "pages", +}; diff --git a/packages/react-server/lib/handlers/static.mjs b/packages/react-server/lib/handlers/static.mjs index 84417f41..0cb88c51 100644 --- a/packages/react-server/lib/handlers/static.mjs +++ b/packages/react-server/lib/handlers/static.mjs @@ -10,6 +10,7 @@ import { POSTPONE_STATE, PRELUDE_HTML, PRERENDER_CACHE_DATA, + RESPONSE_BUFFER, } from "../../server/symbols.mjs"; import * as sys from "../sys.mjs"; @@ -160,7 +161,7 @@ export default async function staticHandler(dir, options = {}) { prerender$(PRELUDE_HTML, res); return; } - return new Response(res, { + const response = new Response(res, { headers: { "content-type": file.mime.includes("text/") || file.mime === "application/json" @@ -179,6 +180,10 @@ export default async function staticHandler(dir, options = {}) { ...(contentEncoding && { "content-encoding": contentEncoding }), }, }); + if (!(res instanceof ReadableStream)) { + response[RESPONSE_BUFFER] = res; + } + return response; } } catch (error) { if (error.code !== "ENOENT") { diff --git a/packages/react-server/lib/http/middleware.mjs b/packages/react-server/lib/http/middleware.mjs index 428ac39a..7c6c8bd0 100644 --- a/packages/react-server/lib/http/middleware.mjs +++ b/packages/react-server/lib/http/middleware.mjs @@ -6,7 +6,11 @@ import { isDeno } from "../sys.mjs"; import { compose } from "./middlewares/compose.mjs"; import { ContextStorage } from "../../server/context.mjs"; import { getRuntime } from "../../server/runtime.mjs"; -import { AFTER_CONTEXT, LOGGER_CONTEXT } from "../../server/symbols.mjs"; +import { + AFTER_CONTEXT, + LOGGER_CONTEXT, + RESPONSE_BUFFER, +} from "../../server/symbols.mjs"; import { getMetrics, startRequestSpan, @@ -170,48 +174,55 @@ export function createMiddleware(handler, options = {}) { } return; } - // Convert the Web ReadableStream to a Node Readable and pipe into ServerResponse. - // Use AbortController to coordinate cleanup when client disconnects or stream completes. - const nodeReadable = Readable.fromWeb(response.body); + // Fast path: buffer-backed responses skip stream conversion entirely. + // Responses tagged with RESPONSE_BUFFER already have their full body in memory. + const directBuffer = response[RESPONSE_BUFFER]; + if (directBuffer) { + res.end(Buffer.from(directBuffer)); + } else { + // Convert the Web ReadableStream to a Node Readable and pipe into ServerResponse. + // Use AbortController to coordinate cleanup when client disconnects or stream completes. + const nodeReadable = Readable.fromWeb(response.body); - // Destroy stream when aborted (client disconnect or error) - signal.addEventListener( - "abort", - () => { - try { - nodeReadable.destroy(new Error("aborted")); - } catch { - // no-op - } - }, - { once: true } - ); + // Destroy stream when aborted (client disconnect or error) + signal.addEventListener( + "abort", + () => { + try { + nodeReadable.destroy(new Error("aborted")); + } catch { + // no-op + } + }, + { once: true } + ); - // Abort on client disconnect - const onDisconnect = () => abortController.abort(); - res.once("close", onDisconnect); - req.once("aborted", onDisconnect); + // Abort on client disconnect + const onDisconnect = () => abortController.abort(); + res.once("close", onDisconnect); + req.once("aborted", onDisconnect); - try { - await new Promise((resolve, reject) => { - // Use { once: true } for auto-cleanup - const onFinish = () => resolve(); - const onReadableError = (err) => reject(err); - const onResError = (err) => reject(err); + try { + await new Promise((resolve, reject) => { + // Use { once: true } for auto-cleanup + const onFinish = () => resolve(); + const onReadableError = (err) => reject(err); + const onResError = (err) => reject(err); - nodeReadable.once("error", onReadableError); - res.once("error", onResError); - res.once("finish", onFinish); + nodeReadable.once("error", onReadableError); + res.once("error", onResError); + res.once("finish", onFinish); - // End dest when source ends (default true) - nodeReadable.pipe(res); - }); - } finally { - // Trigger abort to clean up the signal listener - abortController.abort(); - // Remove disconnect listeners - res.off("close", onDisconnect); - req.off("aborted", onDisconnect); + // End dest when source ends (default true) + nodeReadable.pipe(res); + }); + } finally { + // Trigger abort to clean up the signal listener + abortController.abort(); + // Remove disconnect listeners + res.off("close", onDisconnect); + req.off("aborted", onDisconnect); + } } // ── Telemetry: finish root span and record metrics ── diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx index f4e7e26a..6a699deb 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -45,6 +45,7 @@ import { RENDER_STREAM, RENDER_TEMPORARY_REFERENCES, RENDER_WAIT, + RESPONSE_BUFFER, STYLES_CONTEXT, SERVER_FUNCTION_NOT_FOUND, } from "@lazarv/react-server/server/symbols.mjs"; @@ -701,20 +702,14 @@ export async function render(Component, props = {}, options = {}) { )?.get([context.url, "text/html", outlet, HTML_CACHE]); if (responseFromCacheEntries !== CACHE_MISS) { const [responseFromCache] = responseFromCacheEntries; - const stream = new ReadableStream({ - type: "bytes", - async start(controller) { - controller.enqueue(new Uint8Array(responseFromCache.buffer)); - controller.close(); - }, + const buffer = new Uint8Array(responseFromCache.buffer); + const response = new Response(buffer, { + status: responseFromCache.status, + statusText: responseFromCache.statusText, + headers: responseFromCache.headers, }); - return resolve( - new Response(stream, { - status: responseFromCache.status, - statusText: responseFromCache.statusText, - headers: responseFromCache.headers, - }) - ); + response[RESPONSE_BUFFER] = buffer; + return resolve(response); } } diff --git a/packages/react-server/server/symbols.mjs b/packages/react-server/server/symbols.mjs index 176e4440..578799a6 100644 --- a/packages/react-server/server/symbols.mjs +++ b/packages/react-server/server/symbols.mjs @@ -66,3 +66,4 @@ export const OTEL_METER = Symbol.for("OTEL_METER"); export const OTEL_SPAN = Symbol.for("OTEL_SPAN"); export const OTEL_CONTEXT = Symbol.for("OTEL_CONTEXT"); export const OTEL_SDK = Symbol.for("OTEL_SDK"); +export const RESPONSE_BUFFER = Symbol.for("RESPONSE_BUFFER"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 127f2fa9..ba7f93c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,12 @@ importers: specifier: ^3.4.3 version: 3.4.4(ts-node@10.9.2(@swc/core@1.11.21)(@types/node@24.9.2)(typescript@5.9.3)) + examples/benchmark: + dependencies: + '@lazarv/react-server': + specifier: workspace:^ + version: link:../../packages/react-server + examples/bun: dependencies: '@lazarv/react-server': From 871a729da822d85ba876566549b73b464685663e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Thu, 19 Mar 2026 21:35:58 +0100 Subject: [PATCH 3/6] fix: esm import caching --- examples/benchmark/.gitignore | 1 + examples/benchmark/bench.mjs | 135 +++++++++++++++--- .../react-server/lib/loader/node-loader.mjs | 11 ++ .../lib/loader/node-loader.react-server.mjs | 11 ++ .../lib/plugins/file-router/entrypoint.jsx | 3 + .../lib/plugins/file-router/plugin.mjs | 122 ++++++++++------ .../react-server/lib/start/ssr-handler.mjs | 8 +- 7 files changed, 221 insertions(+), 70 deletions(-) create mode 100644 examples/benchmark/.gitignore diff --git a/examples/benchmark/.gitignore b/examples/benchmark/.gitignore new file mode 100644 index 00000000..3950ec84 --- /dev/null +++ b/examples/benchmark/.gitignore @@ -0,0 +1 @@ +results-*.json \ No newline at end of file diff --git a/examples/benchmark/bench.mjs b/examples/benchmark/bench.mjs index 11bc3d9d..ce9881a3 100644 --- a/examples/benchmark/bench.mjs +++ b/examples/benchmark/bench.mjs @@ -3,15 +3,31 @@ * * Usage: * 1. pnpm --filter @lazarv/react-server-example-benchmark build - * 2. node bench.mjs + * 2. node bench.mjs [--save