+
๐พ
+
No cache events recorded yet.
+
+ Use "use cache" in your server components to see cache
+ hits and misses here.
+
+
+ );
+ }
+
+ return (
+
+
+ {events.length > 0 && (
+
+
+
+ {hitCount} hit{hitCount !== 1 ? "s" : ""}
+
+
+ {missCount} miss{missCount !== 1 ? "es" : ""}
+
+ {revalidateCount > 0 && (
+
+ {revalidateCount} revalidat
+ {revalidateCount !== 1 ? "ions" : "e"}
+
+ )}
+
+
+ {["all", "hit", "miss", "revalidate"].map((f) => (
+
+ ))}
+
+
+ )}
+ {events.length > 0 && (
+
+ {filtered
+ .slice()
+ .toReversed()
+ .map((event, i) => (
+
+
+ {formatTime(event.timestamp)}
+
+
+ {event.type}
+
+
+ {event.provider}
+
+
+
+ {event.fn || "anonymous"}
+ {" "}
+ {event.args && event.args.length > 0 && (
+
+ {formatArgs(event.args)}
+
+ )}
+
+ {event.file && (
+
+ {event.file}:{event.line}
+
+ )}
+ {event.ttl != null && event.type !== "hit" && (
+
+ TTL: {formatTTL(event.ttl)}
+
+ )}
+ {event.provider !== "request" && event._keys && (
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/packages/react-server/devtools/client/panels/ComponentRoutes.jsx b/packages/react-server/devtools/client/panels/ComponentRoutes.jsx
new file mode 100644
index 00000000..9624689b
--- /dev/null
+++ b/packages/react-server/devtools/client/panels/ComponentRoutes.jsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { match } from "../../../lib/route-match.mjs";
+
+const TYPE_CLASSES = {
+ server: "indigo",
+ client: "green",
+ fallback: "teal",
+};
+
+function Tag({ color, children }) {
+ return
+
Component Routes ({routes.length})
+
+
+ Route
+ Type
+ Flags
+
+ {routes.map((route, i) => {
+ const colorClass = TYPE_CLASSES[route.type] ?? "gray";
+ const params = activeMap.get(i);
+ const active = !!params;
+ const paramEntries = params
+ ? Object.entries(params).filter(([, v]) => v !== undefined)
+ : [];
+ return (
+
+
+ {route.path || "/"}
+ {paramEntries.length > 0 && (
+
+ {`{ ${paramEntries.map(([k, v]) => `${k}: ${JSON.stringify(Array.isArray(v) ? v.join("/") : v)}`).join(", ")} }`}
+
+ )}
+
+
{route.type}
+
+ {route.remote && remote}
+ {route.exact && exact}
+ {route.hasLoading && loading}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/react-server/devtools/client/panels/LivePanel.jsx b/packages/react-server/devtools/client/panels/LivePanel.jsx
new file mode 100644
index 00000000..1b3e0fa7
--- /dev/null
+++ b/packages/react-server/devtools/client/panels/LivePanel.jsx
@@ -0,0 +1,110 @@
+"use client";
+
+const STATE_CLASSES = {
+ starting: "amber",
+ waiting: "violet",
+ running: "green",
+ connected: "green",
+ finished: "gray",
+ aborted: "red",
+ error: "red",
+};
+
+const STATE_ICONS = {
+ starting: "\u23f3",
+ waiting: "\u23f8\ufe0f",
+ running: "\u25b6\ufe0f",
+ connected: "\u25b6\ufe0f",
+ finished: "\u2705",
+ aborted: "\ud83d\udeab",
+ error: "\u274c",
+};
+
+function Tag({ color = "indigo", children }) {
+ return
+ {liveOutlets.map((outlet, i) => {
+ const server = serverState[outlet.name];
+ const state = server?.state ?? "connected";
+ const displayName = server?.displayName ?? null;
+ const specifier = server?.specifier ?? null;
+ const yields = server?.yields ?? null;
+ const lastYieldAt = server?.lastYieldAt ?? null;
+ const startedAt = server?.startedAt ?? null;
+ const streaming = server?.streaming ?? false;
+ const error = server?.error ?? null;
+ const isRemote = outlet.remote;
+
+ const stateClass = STATE_CLASSES[state] ?? "gray";
+ const stateIcon = STATE_ICONS[state] ?? "";
+
+ return (
+
+
+
{stateIcon}
+
+ {displayName || "LiveComponent"}
+
+
{state}
+ {streaming &&
streaming}
+ {isRemote &&
remote}
+
+
+ {specifier && (
+
+ {specifier}
+
+ )}
+
+ {outlet.url && (
+
+ {isRemote ? outlet.url : `outlet: ${outlet.name}`}
+
+ )}
+
+
+ {typeof yields === "number" && (
+
+ yields: {yields}
+
+ )}
+ {lastYieldAt && last yield: {timeAgo(lastYieldAt)}}
+ {startedAt && started: {timeAgo(startedAt)}}
+
+
+ {error &&
{error}
}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/react-server/devtools/client/panels/LogsPanel.jsx b/packages/react-server/devtools/client/panels/LogsPanel.jsx
new file mode 100644
index 00000000..842331e7
--- /dev/null
+++ b/packages/react-server/devtools/client/panels/LogsPanel.jsx
@@ -0,0 +1,484 @@
+"use client";
+
+import {
+ useState,
+ useEffect,
+ useRef,
+ useCallback,
+ useMemo,
+ useLayoutEffect,
+} from "react";
+
+/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ ANSI escape code โ React span converter.
+ Supports SGR codes (colors, bold, italic, underline, dim, etc.)
+ for 4-bit, 8-bit, and 24-bit (true-color) ANSI sequences.
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+
+const ANSI_4BIT_FG = {
+ 30: "#1e1e1e",
+ 31: "#cd3131",
+ 32: "#0dbc79",
+ 33: "#e5e510",
+ 34: "#2472c8",
+ 35: "#bc3fbc",
+ 36: "#11a8cd",
+ 37: "#e5e5e5",
+ 90: "#666666",
+ 91: "#f14c4c",
+ 92: "#23d18b",
+ 93: "#f5f543",
+ 94: "#3b8eea",
+ 95: "#d670d6",
+ 96: "#29b8db",
+ 97: "#ffffff",
+};
+
+const ANSI_4BIT_BG = {
+ 40: "#1e1e1e",
+ 41: "#cd3131",
+ 42: "#0dbc79",
+ 43: "#e5e510",
+ 44: "#2472c8",
+ 45: "#bc3fbc",
+ 46: "#11a8cd",
+ 47: "#e5e5e5",
+ 100: "#666666",
+ 101: "#f14c4c",
+ 102: "#23d18b",
+ 103: "#f5f543",
+ 104: "#3b8eea",
+ 105: "#d670d6",
+ 106: "#29b8db",
+ 107: "#ffffff",
+};
+
+const XTERM_256 = (() => {
+ const c = [];
+ c.push(
+ "#000000",
+ "#cd3131",
+ "#0dbc79",
+ "#e5e510",
+ "#2472c8",
+ "#bc3fbc",
+ "#11a8cd",
+ "#e5e5e5"
+ );
+ c.push(
+ "#666666",
+ "#f14c4c",
+ "#23d18b",
+ "#f5f543",
+ "#3b8eea",
+ "#d670d6",
+ "#29b8db",
+ "#ffffff"
+ );
+ for (let r = 0; r < 6; r++)
+ for (let g = 0; g < 6; g++)
+ for (let b = 0; b < 6; b++)
+ c.push(
+ `#${[r, g, b].map((v) => (v ? v * 40 + 55 : 0).toString(16).padStart(2, "0")).join("")}`
+ );
+ for (let i = 0; i < 24; i++) {
+ const v = (i * 10 + 8).toString(16).padStart(2, "0");
+ c.push(`#${v}${v}${v}`);
+ }
+ return c;
+})();
+
+// Matches SGR sequences (\x1b[...m) for color parsing.
+// Also matches non-SGR CSI sequences to skip them (they're stripped
+// server-side but this is belt-and-suspenders).
+// oxlint-disable-next-line no-control-regex
+const ANSI_SGR_RE = new RegExp("\\x1b\\[([0-9;]*)m", "g");
+/* oxlint-disable no-control-regex */
+const ANSI_CSI_NON_SGR_RE = new RegExp(
+ "\\x1b\\[[0-9;]*[A-HJKSTfhlnr]|\\x1b\\][\\s\\S]*?(?:\\x07|\\x1b\\\\)|\\r",
+ "g"
+);
+/* oxlint-enable no-control-regex */
+
+function ansiToSpans(rawText) {
+ // Strip any leftover non-SGR sequences before parsing colors
+ const text = rawText.replace(ANSI_CSI_NON_SGR_RE, "");
+ const spans = [];
+ let style = {};
+ let lastIndex = 0;
+
+ ANSI_SGR_RE.lastIndex = 0;
+ let match;
+ while ((match = ANSI_SGR_RE.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ spans.push({
+ text: text.slice(lastIndex, match.index),
+ style: { ...style },
+ });
+ }
+ lastIndex = ANSI_SGR_RE.lastIndex;
+
+ const codes = match[1] ? match[1].split(";").map(Number) : [0];
+ let i = 0;
+ while (i < codes.length) {
+ const code = codes[i];
+ if (code === 0) {
+ style = {};
+ } else if (code === 1) {
+ style.fontWeight = "bold";
+ } else if (code === 2) {
+ style.opacity = "0.7";
+ } else if (code === 3) {
+ style.fontStyle = "italic";
+ } else if (code === 4) {
+ style.textDecoration = "underline";
+ } else if (code === 9) {
+ style.textDecoration = "line-through";
+ } else if (code === 22) {
+ delete style.fontWeight;
+ delete style.opacity;
+ } else if (code === 23) {
+ delete style.fontStyle;
+ } else if (code === 24 || code === 29) {
+ delete style.textDecoration;
+ } else if (code === 39) {
+ delete style.color;
+ } else if (code === 49) {
+ delete style.backgroundColor;
+ } else if (ANSI_4BIT_FG[code]) {
+ style.color = ANSI_4BIT_FG[code];
+ } else if (ANSI_4BIT_BG[code]) {
+ style.backgroundColor = ANSI_4BIT_BG[code];
+ } else if (code === 38 || code === 48) {
+ const prop = code === 38 ? "color" : "backgroundColor";
+ if (codes[i + 1] === 5 && codes.length > i + 2) {
+ style[prop] = XTERM_256[codes[i + 2]] || style[prop];
+ i += 2;
+ } else if (codes[i + 1] === 2 && codes.length > i + 4) {
+ style[prop] = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`;
+ i += 4;
+ }
+ }
+ i++;
+ }
+ }
+
+ if (lastIndex < text.length) {
+ spans.push({ text: text.slice(lastIndex), style: { ...style } });
+ }
+
+ return spans;
+}
+
+/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+
+function formatTime(ts) {
+ if (!ts) return "";
+ const d = new Date(ts);
+ return d.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ fractionalSecondDigits: 3,
+ });
+}
+
+const AnsiLine = ({ text }) => {
+ const spans = useMemo(() => ansiToSpans(text), [text]);
+ return spans.map((span, i) =>
+ Object.keys(span.style).length > 0 ? (
+ {
+ if (node) rowRefs.current.set(entry.id, node);
+ else rowRefs.current.delete(entry.id);
+ }}
+ />
+ );
+ }
+
+ return (
+
+
+
+ {[
+ { id: "all", label: `All (${entries.length})` },
+ { id: "stdout", label: `stdout (${stdoutCount})` },
+ { id: "stderr", label: `stderr (${stderrCount})` },
+ ].map((f) => (
+
+ ))}
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+ {!autoScroll && (
+
+ )}
+
+ );
+}
diff --git a/packages/react-server/devtools/client/panels/OutletPanel.jsx b/packages/react-server/devtools/client/panels/OutletPanel.jsx
new file mode 100644
index 00000000..3804ca17
--- /dev/null
+++ b/packages/react-server/devtools/client/panels/OutletPanel.jsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { pickColor } from "../highlight-colors.mjs";
+
+export default function OutletPanel({
+ outlets,
+ hostUrl,
+ onHighlight,
+ onClearHighlight,
+ onScrollIntoView,
+}) {
+ const colors = useMemo(
+ () => outlets.map((o) => pickColor(o.name)),
+ [outlets]
+ );
+
+ if (outlets.length === 0) {
+ return (
+
+
๐ฒ
+
No outlet data received yet.
+
+ Waiting for outlet data from the host page...
+
+
+ );
+ }
+
+ return (
+
+
+ Host URL: {hostUrl || "โ"}
+
+
+
+ {outlets.map((outlet, i) => (
+
{
+ onHighlight?.(
+ `[data-devtools-outlet="${outlet.name}"]`,
+ colors[i],
+ outlet.name
+ );
+ }}
+ onMouseLeave={() => {
+ onClearHighlight?.();
+ }}
+ >
+
+
{outlet.name || "PAGE_ROOT"}
+ {outlet.url &&
{outlet.url}
}
+
+
+
+ {outlet._fileRouter && (
+ router
+ )}
+ {outlet.remote && (
+ remote
+ )}
+ {outlet.live && (
+ live
+ )}
+ {outlet.defer && (
+ defer
+ )}
+ {!outlet._fileRouter &&
+ !outlet.remote &&
+ !outlet.live &&
+ !outlet.defer && (
+ static
+ )}
+
+ {outlet.name && outlet.name !== "PAGE_ROOT" && (
+
+ )}
+ {!outlet._fileRouter && (
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/packages/react-server/devtools/client/panels/PayloadPanel.jsx b/packages/react-server/devtools/client/panels/PayloadPanel.jsx
new file mode 100644
index 00000000..35e404b2
--- /dev/null
+++ b/packages/react-server/devtools/client/panels/PayloadPanel.jsx
@@ -0,0 +1,325 @@
+"use client";
+
+import { useState, useMemo, useCallback, useRef } from "react";
+
+const TAG_LABELS = {
+ "": "Model",
+ I: "Module",
+ E: "Error",
+ H: "Hint",
+ D: "Debug",
+ T: "Text",
+ B: "Binary",
+ W: "Console",
+};
+
+const TAG_CLASSES = {
+ "": "indigo",
+ I: "green",
+ E: "red",
+ H: "amber",
+ D: "violet",
+ T: "cyan",
+ B: "teal",
+ W: "orange",
+};
+
+function formatBytes(bytes) {
+ if (bytes === 0) return "0 B";
+ const units = ["B", "KB", "MB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
+}
+
+function Tag({ color, children }) {
+ return {children};
+}
+
+function DataPreview({ data, maxLength = 120 }) {
+ const str = typeof data === "string" ? data : JSON.stringify(data, null, 0);
+
+ const truncated =
+ str?.length > maxLength ? str.slice(0, maxLength) + "..." : str;
+
+ return {truncated};
+}
+
+function ChunkRow({ chunk, highlighted, onHighlight, onClearHighlight }) {
+ const tagClass = TAG_CLASSES[chunk.tag] ?? "gray";
+
+ return (
+ {
+ if (chunk.tag === "I") {
+ const moduleId = Array.isArray(chunk.data)
+ ? chunk.data[0]
+ : chunk.data?.id;
+ if (moduleId) {
+ onHighlight?.(
+ `[data-devtools-client="${moduleId}"]`,
+ "rgba(34, 197, 94, 0.3)",
+ moduleId
+ );
+ }
+ }
+ }}
+ onMouseLeave={() => onClearHighlight?.()}
+ >
+ #{chunk.id}
+ {TAG_LABELS[chunk.tag] ?? chunk.tag}
+ {formatBytes(chunk.size)}
+
+
+ );
+}
+
+function displayModuleId(moduleId, reactServerRoot) {
+ if (!reactServerRoot || !moduleId) return moduleId;
+ // Match absolute path prefix
+ if (moduleId.startsWith(reactServerRoot)) {
+ return "@lazarv/react-server" + moduleId.slice(reactServerRoot.length);
+ }
+ // Match relative paths containing the package directory (e.g. ../../packages/react-server/...)
+ const dirName = reactServerRoot.split("/").slice(-2).join("/");
+ const idx = moduleId.indexOf(dirName + "/");
+ if (idx !== -1) {
+ return "@lazarv/react-server/" + moduleId.slice(idx + dirName.length + 1);
+ }
+ return moduleId;
+}
+
+const SIZE_SEGMENTS = [
+ { key: "rsc", label: "RSC Payload", color: "#22c55e" },
+ { key: "hydration", label: "Hydration", color: "#8b5cf6" },
+ { key: "html", label: "HTML", color: "#0ea5e9" },
+];
+
+function PageSizeBar({ stats }) {
+ const total = stats.htmlSize;
+ const rsc = stats.flightSize || 0;
+ const hydration = stats.hydrationSize || 0;
+ const html = Math.max(0, total - rsc - hydration);
+
+ const segments = [
+ { ...SIZE_SEGMENTS[0], value: rsc },
+ { ...SIZE_SEGMENTS[1], value: hydration },
+ { ...SIZE_SEGMENTS[2], value: html },
+ ].filter((s) => s.value > 0);
+
+ const transferred = stats.htmlTransferSize;
+ const hasCompression = transferred > 0 && transferred < total;
+
+ return (
+
+ {/* Bar */}
+
+ {segments.map((seg) => (
+
+ ))}
+
+ {/* Legend */}
+
+ {segments.map((seg) => (
+
+
+ {seg.label}
+ {formatBytes(seg.value)}
+
+ ))}
+
+ Total
+ {formatBytes(total)}
+
+ {hasCompression && (
+
+
+ Transferred
+
+
+ {formatBytes(transferred)}
+
+
+ )}
+
+
+ );
+}
+
+export default function PayloadPanel({
+ payloads,
+ filter: controlledFilter,
+ onFilterChange,
+ onHighlight,
+ onClearHighlight,
+ reactServerRoot,
+ pageStats,
+}) {
+ const [selectedIdx, setSelectedIdx] = useState(null);
+ const [localFilter, setLocalFilter] = useState("");
+ const filter = controlledFilter ?? localFilter;
+ const setFilter = onFilterChange ?? setLocalFilter;
+ const [highlightedChunkId, setHighlightedChunkId] = useState(null);
+ const listRef = useRef(null);
+
+ const selected =
+ selectedIdx !== null
+ ? payloads[selectedIdx]
+ : payloads[payloads.length - 1];
+
+ const filteredChunks = useMemo(() => {
+ if (!selected?.chunks) return [];
+ if (!filter) return selected.chunks;
+ const f = filter.toLowerCase();
+ return selected.chunks.filter(
+ (c) =>
+ (TAG_LABELS[c.tag] ?? c.tag).toLowerCase().includes(f) ||
+ JSON.stringify(c.data).toLowerCase().includes(f)
+ );
+ }, [selected, filter]);
+
+ const scrollToChunk = useCallback((chunkId) => {
+ const container = listRef.current;
+ if (!container) return;
+ const row = container.querySelector(`[data-chunk-id="${chunkId}"]`);
+ if (row) {
+ row.scrollIntoView({ behavior: "smooth", block: "center" });
+ setHighlightedChunkId(chunkId);
+ setTimeout(() => setHighlightedChunkId(null), 2000);
+ }
+ }, []);
+
+ if (payloads.length === 0) {
+ return (
+
+
๐ฆ
+
No RSC payloads captured yet.
+
+ Navigate in the host app to capture flight data.
+
+
+ );
+ }
+
+ return (
+
+ {/* Page-level size bar (iOS-style) */}
+ {pageStats && pageStats.htmlSize > 0 &&
}
+
+ {/* Payload selector */}
+
+
+
+ setFilter(e.target.value)}
+ className="dt-input"
+ />
+
+
+ {/* Summary badges */}
+ {selected && (
+
+ {formatBytes(selected.totalSize)}
+ {selected.chunkCount} chunks
+ {selected.clientRefs.length} client refs
+ {selected.serverRefs.length} server refs
+ {selected.promises.length} promises
+ {selected.errors.length > 0 && (
+ {selected.errors.length} errors
+ )}
+ {selected.duration}ms
+
+ )}
+
+ {/* Client references */}
+ {selected?.clientRefs.length > 0 && (
+
+ Client References ({selected.clientRefs.length})
+
+ {selected.clientRefs.map((ref, i) => (
+
+ ))}
+
+
+ )}
+
+ {/* Chunk list */}
+
+
+ ID
+ Type
+ Size
+ Data
+
+
+ {filteredChunks.map((chunk, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/packages/react-server/devtools/client/panels/RemotePanel.jsx b/packages/react-server/devtools/client/panels/RemotePanel.jsx
new file mode 100644
index 00000000..7a781cc0
--- /dev/null
+++ b/packages/react-server/devtools/client/panels/RemotePanel.jsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { pickColor } from "../highlight-colors.mjs";
+
+function Tag({ color = "indigo", children }) {
+ return {children};
+}
+
+function Icon({ d, title }) {
+ return (
+
+ );
+}
+
+// Lucide-style icon paths
+const ICON_EXTERNAL =
+ "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6 M15 3h6v6 M10 14L21 3";
+const ICON_EYE =
+ "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z";
+const ICON_ARROW_RIGHT = "M5 12h14 M12 5l7 7-7 7";
+
+export default function RemotePanel({
+ components = [],
+ onHighlight,
+ onClearHighlight,
+ onNavigateOutlet,
+ onScrollIntoView,
+}) {
+ const colors = useMemo(
+ () => components.map((comp) => pickColor(comp.name)),
+ [components]
+ );
+
+ if (components.length === 0) {
+ return (
+
+
๐
+
No remote components tracked.
+
+ Use <RemoteComponent> to load
+ remote RSC components.
+
+
+ );
+ }
+
+ return (
+
+ {components.map((comp, i) => (
+
{
+ if (comp.name) {
+ onHighlight?.(
+ `[data-devtools-outlet="${comp.name}"]`,
+ colors[i],
+ comp.name
+ );
+ }
+ }}
+ onMouseLeave={() => {
+ onClearHighlight?.();
+ }}
+ >
+
+
+ {comp.url}
+
+
+ {comp.url && (
+
e.stopPropagation()}
+ >
+
+
+ )}
+ {comp.name && (
+
+ )}
+ {comp.name && (
+
+ )}
+
+
+ {comp.name && (
+
+ outlet: {comp.name}
+
+ )}
+
+ {comp.ttl != null && (
+
+ TTL: {comp.ttl === Infinity ? "\u221e" : `${comp.ttl}ms`}
+
+ )}
+ {comp.isolate && isolate}
+ {comp.defer && defer}
+ {comp.live && live}
+
+
+ ))}
+
+ );
+}
diff --git a/packages/react-server/devtools/client/panels/RouteTreeView.jsx b/packages/react-server/devtools/client/panels/RouteTreeView.jsx
new file mode 100644
index 00000000..61a43b19
--- /dev/null
+++ b/packages/react-server/devtools/client/panels/RouteTreeView.jsx
@@ -0,0 +1,388 @@
+"use client";
+
+import { useState, useMemo } from "react";
+
+// Types that match as a prefix (active for all child paths)
+const PREFIX_TYPES = new Set(["layout", "middleware", "template"]);
+
+/**
+ * Match a pathname against a file-router route pattern.
+ * Supports: static segments, [param], [[optional]], [...catchAll], [[...optionalCatchAll]]
+ *
+ * When `prefix` is true (used for layouts and middlewares), the pattern only
+ * needs to match a prefix of the pathname โ extra trailing segments are allowed.
+ */
+function matchRoute(pattern, pathname, prefix = false) {
+ if (!pattern || !pathname) return false;
+ if (pattern === pathname) return true;
+ // Root pattern "/" is a prefix of everything
+ if (prefix && (pattern === "/" || pattern === "")) return true;
+
+ const routeSegments = pattern.replace(/^\/|\/$/g, "").split("/");
+ const pathSegments = pathname.replace(/^\/|\/$/g, "").split("/");
+
+ let ri = 0;
+ let pi = 0;
+ while (ri < routeSegments.length) {
+ const seg = routeSegments[ri];
+ // Catch-all: [...param] or [[...param]]
+ if (/^\[\[?\.\.\./.test(seg)) {
+ return true;
+ }
+ // Optional param: [[param]]
+ if (seg.startsWith("[[") && seg.endsWith("]]")) {
+ if (pi < pathSegments.length) pi++;
+ ri++;
+ continue;
+ }
+ // Required param: [param]
+ if (seg.startsWith("[") && seg.endsWith("]")) {
+ if (pi >= pathSegments.length) return false;
+ pi++;
+ ri++;
+ continue;
+ }
+ // Static segment
+ if (pi >= pathSegments.length || seg !== pathSegments[pi]) return false;
+ pi++;
+ ri++;
+ }
+ // Exact match: all path segments consumed. Prefix match: all route segments consumed.
+ return prefix ? true : pi === pathSegments.length;
+}
+
+const TYPE_CLASSES = {
+ page: "indigo",
+ layout: "violet",
+ middleware: "amber",
+ api: "green",
+ error: "red",
+ loading: "cyan",
+ fallback: "teal",
+ template: "pink",
+ outlet: "orange",
+};
+
+function Tag({ color, children }) {
+ return {children};
+}
+
+// Brand icons for file extensions
+const ICON_REACT = (
+
+);
+
+const ICON_JS = (
+
+);
+
+const ICON_TS = (
+
+);
+
+const ICON_MDX = (
+
+);
+
+const ICON_MD = (
+
+);
+
+const ICON_CSS = (
+
+);
+
+const ICON_JSON = (
+
+);
+
+const ICON_FILE = (
+
+);
+
+const EXT_ICON_MAP = {
+ jsx: ICON_REACT,
+ tsx: ICON_REACT,
+ js: ICON_JS,
+ mjs: ICON_JS,
+ ts: ICON_TS,
+ mts: ICON_TS,
+ mdx: ICON_MDX,
+ md: ICON_MD,
+ css: ICON_CSS,
+ json: ICON_JSON,
+};
+
+function ExtIcon({ ext }) {
+ const key = ext?.toLowerCase();
+ const icon = key ? EXT_ICON_MAP[key] : null;
+ return (
+
+ {icon || ICON_FILE}
+
+ );
+}
+
+function relativeSrc(src, cwd) {
+ if (!src) return "";
+ if (cwd && src.startsWith(cwd)) {
+ return src.slice(cwd.length).replace(/^\/+/, "");
+ }
+ // Fallback: show from src/ onwards, or last 2 segments
+ const srcIdx = src.lastIndexOf("/src/");
+ if (srcIdx !== -1) return src.slice(srcIdx + 1);
+ const parts = src.split("/");
+ return parts.slice(-2).join("/");
+}
+
+function RouteRow({ entry, cwd, active }) {
+ const colorClass = TYPE_CLASSES[entry.type] ?? "gray";
+ const rel = relativeSrc(entry.src, cwd);
+
+ return (
+
+
{entry.path || "/"}
+
+
+ {entry.type === "outlet" ? `@${entry.outlet}` : entry.type}
+
+ {entry.method && {entry.method}}
+
+
+ {entry.src ? (
+
+ {rel}
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default function RouteTreeView({
+ manifest,
+ filter: controlledFilter,
+ onFilterChange,
+ typeFilter: controlledTypeFilter,
+ onTypeFilterChange,
+ serverPathname,
+}) {
+ const [localFilter, setLocalFilter] = useState("");
+ const [localTypeFilter, setLocalTypeFilter] = useState("all");
+ const [showActiveOnly, setShowActiveOnly] = useState(false);
+
+ const filter = controlledFilter ?? localFilter;
+ const setFilter = onFilterChange ?? setLocalFilter;
+ const typeFilter = controlledTypeFilter ?? localTypeFilter;
+ const setTypeFilter = onTypeFilterChange ?? setLocalTypeFilter;
+
+ if (!manifest) {
+ return null;
+ }
+
+ const {
+ pages = [],
+ middlewares = [],
+ routes: apiRoutes = [],
+ cwd = "",
+ } = manifest;
+
+ // Merge all route types into a single list
+ const allRoutes = useMemo(() => {
+ const mw = middlewares.map((m) => ({
+ path: m.path,
+ type: "middleware",
+ ext: m.ext,
+ src: m.src,
+ }));
+ const api = apiRoutes.map((r) => ({
+ path: r.path,
+ type: "api",
+ method: r.method,
+ ext: r.ext,
+ src: r.src,
+ }));
+ return [...mw, ...api, ...pages];
+ }, [pages, middlewares, apiRoutes]);
+
+ // Compute which routes are active based on the server pathname
+ // (reflects rewrites, not the client-visible URL)
+ const activeSet = useMemo(() => {
+ const set = new Set();
+ if (!serverPathname) return set;
+ for (let i = 0; i < allRoutes.length; i++) {
+ const r = allRoutes[i];
+ const prefix = PREFIX_TYPES.has(r.type);
+ if (matchRoute(r.path, serverPathname, prefix)) set.add(i);
+ }
+ return set;
+ }, [allRoutes, serverPathname]);
+
+ const filtered = useMemo(() => {
+ let result = allRoutes.map((r, i) => ({ ...r, _idx: i }));
+ if (showActiveOnly) {
+ result = result.filter((r) => activeSet.has(r._idx));
+ }
+ if (typeFilter !== "all") {
+ result = result.filter((p) => p.type === typeFilter);
+ }
+ if (filter) {
+ const f = filter.toLowerCase();
+ result = result.filter(
+ (p) =>
+ p.path?.toLowerCase().includes(f) ||
+ p.src?.toLowerCase().includes(f) ||
+ p.outlet?.toLowerCase().includes(f) ||
+ p.method?.toLowerCase().includes(f)
+ );
+ }
+ return result;
+ }, [allRoutes, activeSet, showActiveOnly, filter, typeFilter]);
+
+ const typeCounts = useMemo(() => {
+ const counts = {};
+ for (const r of allRoutes) {
+ counts[r.type] = (counts[r.type] || 0) + 1;
+ }
+ return counts;
+ }, [allRoutes]);
+
+ const types = Object.keys(typeCounts).toSorted();
+
+ return (
+
+
+
setFilter(e.target.value)}
+ className="dt-input"
+ />
+
+
+
+ {types.map((t) => (
+
+ ))}
+
+
+ {filtered.length} of {allRoutes.length} routes
+
+
+
+
+
+ Route
+ Type
+
+ Source
+
+ {filtered.map((entry, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/packages/react-server/devtools/client/panels/StatusDisplay.jsx b/packages/react-server/devtools/client/panels/StatusDisplay.jsx
new file mode 100644
index 00000000..99db3748
--- /dev/null
+++ b/packages/react-server/devtools/client/panels/StatusDisplay.jsx
@@ -0,0 +1,128 @@
+"use client";
+
+function formatBytes(bytes) {
+ if (bytes === 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
+}
+
+function formatUptime(seconds) {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor(seconds % 60);
+ if (h > 0) return `${h}h ${m}m ${s}s`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+}
+
+function Gauge({ label, value, max, format = formatBytes, color = "#4f46e5" }) {
+ const percent = max > 0 ? Math.min((value / max) * 100, 100) : 0;
+
+ return (
+
+
+ {label}
+
+ {format(value)}
+ {max > 0 && (
+ / {format(max)}
+ )}
+
+
+
+
+ );
+}
+
+function StatCard({ title, children }) {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
+
+function KeyValue({ label, value }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export default function StatusDisplay({ data }) {
+ if (!data) return null;
+
+ const { process: proc, cpu, memory } = data;
+
+ return (
+
+ {/* Process Info */}
+
+
+
+
+
+
+
+ {/* CPU */}
+
+ `${v.toFixed(1)}%`}
+ color={
+ cpu.percent > 80
+ ? "#ef4444"
+ : cpu.percent > 50
+ ? "#f59e0b"
+ : "#22c55e"
+ }
+ />
+
+ v.toFixed(2)).join(", ")}
+ />
+
+
+ {/* Memory */}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/react-server/devtools/client/panels/WorkerPanel.jsx b/packages/react-server/devtools/client/panels/WorkerPanel.jsx
new file mode 100644
index 00000000..117f269d
--- /dev/null
+++ b/packages/react-server/devtools/client/panels/WorkerPanel.jsx
@@ -0,0 +1,172 @@
+"use client";
+
+import { useState } from "react";
+
+function formatTime(ts) {
+ if (!ts) return "โ";
+ const d = new Date(ts);
+ return d.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+
+function shortId(id) {
+ // Show relative-looking path: strip virtual:react-server:worker:: prefix,
+ // and shorten to last 2-3 segments for display
+ let display = id
+ .replace(/^virtual:react-server:worker::/, "")
+ .replace(/\?.*$/, "");
+ const parts = display.split("/");
+ if (parts.length > 3) {
+ display = parts.slice(-3).join("/");
+ }
+ return display;
+}
+
+const STATE_LABELS = {
+ spawning: { label: "Spawning", tagColor: "amber" },
+ ready: { label: "Ready", tagColor: "green" },
+ error: { label: "Error", tagColor: "red" },
+ restarting: { label: "Restarting", tagColor: "amber" },
+};
+
+const TYPE_TAG_COLORS = {
+ server: "violet",
+ client: "sky",
+};
+
+export default function WorkerPanel({
+ serverWorkers = [],
+ clientWorkers = [],
+}) {
+ const [typeFilter, setTypeFilter] = useState("all");
+
+ const allWorkers = [
+ ...serverWorkers.map((w) => ({ ...w, type: "server" })),
+ ...clientWorkers.map((w) => ({ ...w, type: "client" })),
+ ];
+
+ const filtered =
+ typeFilter === "all"
+ ? allWorkers
+ : allWorkers.filter((w) => w.type === typeFilter);
+
+ const serverCount = serverWorkers.length;
+ const clientCount = clientWorkers.length;
+
+ if (allWorkers.length === 0) {
+ return (
+
+
⚙️
+
No workers detected.
+
+ Use "use worker" in your modules to offload work to
+ server or client workers.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {allWorkers.length} worker{allWorkers.length !== 1 ? "s" : ""}
+
+ {serverCount > 0 && (
+
+ {serverCount} server
+
+ )}
+ {clientCount > 0 && (
+
+ {clientCount} client
+
+ )}
+
+
+ {["all", "server", "client"].map((f) => (
+
+ ))}
+
+
+
+ {filtered.map((w) => {
+ const stateInfo = STATE_LABELS[w.state] || {
+ label: w.state,
+ tagColor: "gray",
+ };
+ return (
+
+
+
+ {w.type}
+
+
+ {stateInfo.label}
+
+
+ {shortId(w.id)}
+
+
+
+
+ Calls:{" "}
+ {w.invocations ?? 0}
+ {(w.activeInvocations ?? 0) > 0 && (
+
+ {" "}
+ ({w.activeInvocations} active)
+
+ )}
+
+ {(w.errors ?? 0) > 0 && (
+
+ Errors:{" "}
+ {w.errors}
+
+ )}
+ {(w.restarts ?? 0) > 0 && (
+
+ Restarts:{" "}
+ {w.restarts}
+
+ )}
+ {w.lastFn && (
+
+ Last fn:{" "}
+ {w.lastFn}
+
+ )}
+
+ Spawned:{" "}
+ {formatTime(w.spawnedAt)}
+
+ {w.lastInvokedAt && (
+
+ Last call:{" "}
+ {formatTime(w.lastInvokedAt)}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/react-server/devtools/context.mjs b/packages/react-server/devtools/context.mjs
new file mode 100644
index 00000000..b3beeb31
--- /dev/null
+++ b/packages/react-server/devtools/context.mjs
@@ -0,0 +1,316 @@
+import { getRuntime } from "../server/runtime.mjs";
+import { LIVE_IO } from "../server/symbols.mjs";
+
+export { DEVTOOLS_CONTEXT } from "@lazarv/react-server/server/symbols.mjs";
+
+/**
+ * Parse the keys array from useCache into structured display info.
+ * Keys format: [cacheName, ...tags?, [args], hash?, { __devtools__, file, line, col, fn }?]
+ */
+function parseCacheKeys(keys) {
+ if (!keys || !Array.isArray(keys)) return { fn: "unknown", args: [] };
+
+ let meta = null;
+ let args = [];
+
+ // Walk from the end looking for the devtools metadata object and args array
+ for (let i = keys.length - 1; i >= 0; i--) {
+ const k = keys[i];
+ if (k && typeof k === "object" && !Array.isArray(k) && k.__devtools__) {
+ meta = k;
+ } else if (Array.isArray(k)) {
+ args = k;
+ }
+ }
+
+ if (meta) {
+ // Shorten file path for display: show relative from /src/ or last 2 segments
+ const fullPath = (meta.file || "").split("?")[0];
+ let file = fullPath;
+ const srcIdx = file.lastIndexOf("/src/");
+ if (srcIdx !== -1) {
+ file = file.slice(srcIdx + 1);
+ } else {
+ const parts = file.split("/");
+ file = parts.slice(-2).join("/");
+ }
+
+ return {
+ fn: meta.fn || "anonymous",
+ file,
+ fullPath,
+ line: meta.line || 0,
+ col: meta.col || 0,
+ args: args.map(serializeArg),
+ };
+ }
+
+ // Fallback: extract what we can from the cache name string
+ const name = typeof keys[0] === "string" ? keys[0] : "";
+ return { fn: name, args: args.map(serializeArg) };
+}
+
+function serializeArg(arg) {
+ if (arg === null) return "null";
+ if (arg === undefined) return "undefined";
+ if (typeof arg === "string")
+ return arg.length > 50 ? arg.slice(0, 50) + "โฆ" : arg;
+ if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
+ if (Array.isArray(arg)) return `[${arg.length}]`;
+ if (typeof arg === "object") {
+ try {
+ const s = JSON.stringify(arg);
+ return s.length > 60 ? s.slice(0, 60) + "โฆ" : s;
+ } catch {
+ return "{โฆ}";
+ }
+ }
+ return String(arg);
+}
+
+let logIdCounter = 0;
+
+export function createDevToolsContext() {
+ const renders = [];
+ const liveComponents = new Map();
+ const remoteComponents = [];
+ let fileRouterManifest = null;
+ const cacheEvents = [];
+ let requestCacheGeneration = 0;
+ const workers = new Map();
+ const logEntries = [];
+
+ let devtoolsNsp = null;
+ let invalidateHandler = null;
+
+ function getLiveData() {
+ return [...liveComponents.entries()].map(([outlet, info]) => ({
+ outlet,
+ ...info,
+ }));
+ }
+
+ function getDevtoolsNamespace() {
+ if (devtoolsNsp) return devtoolsNsp;
+ try {
+ const { io } = getRuntime(LIVE_IO) ?? {};
+ if (io) {
+ devtoolsNsp = io.of("/__devtools__");
+ devtoolsNsp.on("connection", (socket) => {
+ socket.emit("live:components", getLiveData());
+ if (cacheEvents.length > 0) {
+ socket.emit("cache:events", cacheEvents);
+ }
+ if (workers.size > 0) {
+ socket.emit("worker:components", getWorkersData());
+ }
+ if (logEntries.length > 0) {
+ socket.emit("log:entries", logEntries);
+ }
+ socket.on("cache:invalidate", async ({ keys, provider }) => {
+ if (invalidateHandler) {
+ await invalidateHandler(keys, provider);
+ // Remove the event from the list and notify clients
+ let i = cacheEvents.length;
+ const keyStr = JSON.stringify(keys);
+ while (i--) {
+ if (JSON.stringify(cacheEvents[i]._keys) === keyStr) {
+ cacheEvents.splice(i, 1);
+ }
+ }
+ devtoolsNsp.emit("cache:invalidated", { keys, provider });
+ }
+ });
+ });
+ return devtoolsNsp;
+ }
+ } catch {
+ // io not ready yet
+ }
+ return null;
+ }
+
+ function emitLiveUpdate() {
+ const nsp = getDevtoolsNamespace();
+ if (nsp) {
+ nsp.emit("live:components", getLiveData());
+ }
+ }
+
+ function emitCacheEvent(event) {
+ const nsp = getDevtoolsNamespace();
+ if (nsp) {
+ nsp.emit("cache:event", event);
+ }
+ }
+
+ function getWorkersData() {
+ return [...workers.values()];
+ }
+
+ function emitWorkerUpdate() {
+ const nsp = getDevtoolsNamespace();
+ if (nsp) {
+ nsp.emit("worker:components", getWorkersData());
+ }
+ }
+
+ function emitLogEntry(entry) {
+ const nsp = getDevtoolsNamespace();
+ if (nsp) {
+ nsp.emit("log:entry", entry);
+ }
+ }
+
+ return {
+ // โโ Render tracking (called from render-rsc.jsx in dev mode) โโ
+ recordRender(info) {
+ renders.push({ ...info, timestamp: Date.now() });
+ if (renders.length > 50) renders.shift();
+ },
+ getRenders() {
+ return renders;
+ },
+
+ // โโ Live component tracking (called from live.jsx) โโ
+ recordLiveComponent(outlet, info) {
+ liveComponents.set(outlet, { ...info, startedAt: Date.now() });
+ emitLiveUpdate();
+ },
+ updateLiveComponent(outlet, update) {
+ const existing = liveComponents.get(outlet);
+ if (existing) {
+ Object.assign(existing, update);
+ emitLiveUpdate();
+ }
+ },
+ removeLiveComponent(outlet) {
+ liveComponents.delete(outlet);
+ emitLiveUpdate();
+ },
+ getLiveComponents() {
+ return getLiveData();
+ },
+
+ // โโ Remote component tracking (called from RemoteComponent.jsx) โโ
+ recordRemoteComponent(info) {
+ remoteComponents.push({ ...info, timestamp: Date.now() });
+ if (remoteComponents.length > 100) remoteComponents.shift();
+ },
+ getRemoteComponents() {
+ return remoteComponents;
+ },
+
+ // โโ File-router manifest (called from file-router plugin) โโ
+ setFileRouterManifest(manifest) {
+ fileRouterManifest = manifest;
+ },
+ getFileRouterManifest() {
+ return fileRouterManifest;
+ },
+
+ // โโ Cache events (called from cache/index.mjs) โโ
+ recordCacheEvent(event) {
+ // Parse the keys array into structured display info.
+ // Keys format: [cacheName, ...tags?, [arg1, arg2, ...], hash?]
+ // cacheName: "__react_server_cache__id{fileHash}_line{L}_col{C}_impl{implHash}__"
+ const parsed = parseCacheKeys(event.keys);
+ const { keys: rawKeys, ...rest } = event;
+ const base = {
+ ...rest,
+ ...parsed,
+ _keys: rawKeys,
+ timestamp: Date.now(),
+ };
+
+ // For request-scoped caches, tag with the current generation and
+ // drop events from older generations so only the latest request's
+ // entries survive.
+ if (event.provider === "request") {
+ const gen = requestCacheGeneration;
+ let i = cacheEvents.length;
+ while (i--) {
+ if (
+ cacheEvents[i].provider === "request" &&
+ cacheEvents[i]._gen !== gen
+ ) {
+ cacheEvents.splice(i, 1);
+ }
+ }
+ base._gen = gen;
+ }
+
+ cacheEvents.push(base);
+ if (cacheEvents.length > 200) cacheEvents.shift();
+ emitCacheEvent(base);
+ },
+ getCacheEvents() {
+ return cacheEvents;
+ },
+ // Register handler for cache invalidation from devtools
+ onCacheInvalidate(handler) {
+ invalidateHandler = handler;
+ },
+
+ // Called from dispose$("request") โ bumps the generation and tells
+ // the client to drop stale request-scoped entries.
+ disposeRequestCache() {
+ requestCacheGeneration++;
+ const nsp = getDevtoolsNamespace();
+ if (nsp) {
+ nsp.emit("cache:flush-request");
+ }
+ },
+
+ // โโ Worker tracking (called from server/worker-proxy.mjs) โโ
+ recordWorker(id, info) {
+ workers.set(id, {
+ id,
+ type: "server",
+ state: "spawning",
+ invocations: 0,
+ activeInvocations: 0,
+ errors: 0,
+ restarts: 0,
+ spawnedAt: Date.now(),
+ lastInvokedAt: null,
+ ...info,
+ });
+ emitWorkerUpdate();
+ },
+ updateWorker(id, update) {
+ const existing = workers.get(id);
+ if (existing) {
+ const patch = typeof update === "function" ? update(existing) : update;
+ Object.assign(existing, patch);
+ emitWorkerUpdate();
+ }
+ },
+ removeWorker(id) {
+ workers.delete(id);
+ emitWorkerUpdate();
+ },
+ getWorkers() {
+ return getWorkersData();
+ },
+
+ // โโ Server log tracking (raw terminal output) โโ
+ recordLog(stream, text) {
+ const entry = {
+ id: logIdCounter++,
+ stream,
+ text,
+ timestamp: Date.now(),
+ };
+ logEntries.push(entry);
+ if (logEntries.length > 1000) logEntries.shift();
+ emitLogEntry(entry);
+ },
+ getLogEntries() {
+ return logEntries;
+ },
+ clearLogEntries() {
+ logEntries.length = 0;
+ },
+ };
+}
diff --git a/packages/react-server/devtools/devtools.css b/packages/react-server/devtools/devtools.css
new file mode 100644
index 00000000..f87b4528
--- /dev/null
+++ b/packages/react-server/devtools/devtools.css
@@ -0,0 +1,1405 @@
+/* โโ Reset โโ */
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ Theme variables โ aligned with react-server.dev docs colors.
+
+ Light: white bg, gray text, indigo-500 accent
+ Dark: zinc-900 bg, gray-300 text, yellow-600 accent
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+:root {
+ --dt-bg: #ffffff;
+ --dt-bg-gradient: linear-gradient(to bottom, #ffffff, #e5e7eb);
+ --dt-fg: #111827; /* gray-900 */
+ --dt-muted: #4b5563; /* gray-600 */
+ --dt-dimmed: #9ca3af; /* gray-400 */
+ --dt-faint: #d1d5db; /* gray-300 */
+ --dt-border: #e5e7eb; /* gray-200 */
+ --dt-surface: #f9fafb; /* gray-50 */
+ --dt-row-border: #f3f4f6; /* gray-100 */
+ --dt-accent: #6366f1; /* indigo-500 */
+ --dt-accent-subtle: color-mix(in srgb, #6366f1 12%, transparent);
+ --dt-warn: #d97706; /* amber-600 */
+ --dt-link: #4338ca; /* indigo-700 */
+ --dt-link-underline: color-mix(in srgb, #4338ca 25%, transparent);
+ --dt-success: #16a34a; /* green-600 */
+ --dt-toolbar-bg: #f9fafb;
+ --dt-toolbar-border: #e5e7eb;
+ --dt-toolbar-fg: #4b5563;
+ --dt-toolbar-title: #111827;
+}
+
+.dark {
+ --dt-bg: #18181b; /* zinc-900 */
+ --dt-bg-gradient: linear-gradient(to bottom, #27272a, #18181b);
+ --dt-fg: #d1d5db; /* gray-300 */
+ --dt-muted: #9ca3af; /* gray-400 */
+ --dt-dimmed: #71717a; /* zinc-500 */
+ --dt-faint: #52525b; /* zinc-600 */
+ --dt-border: #3f3f46; /* zinc-700 */
+ --dt-surface: #27272a; /* zinc-800 */
+ --dt-row-border: #27272a; /* zinc-800 */
+ --dt-accent: #ca8a04; /* yellow-600 */
+ --dt-accent-subtle: color-mix(in srgb, #ca8a04 12%, transparent);
+ --dt-warn: #ca8a04; /* yellow-600 */
+ --dt-link: #a5b4fc; /* indigo-300 */
+ --dt-link-underline: color-mix(in srgb, #a5b4fc 25%, transparent);
+ --dt-success: #86efac; /* green-300 */
+ --dt-toolbar-bg: #27272a;
+ --dt-toolbar-border: #3f3f46;
+ --dt-toolbar-fg: #9ca3af;
+ --dt-toolbar-title: #e5e7eb;
+}
+
+/* โโ Shell โโ */
+.dt-shell {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 13px;
+ color: var(--dt-fg);
+ background: var(--dt-bg);
+}
+
+/* โโ Tab bar โโ */
+.dt-tabs {
+ display: flex;
+ gap: 1px;
+ border-bottom: 1px solid var(--dt-border);
+ background-color: var(--dt-surface);
+ flex-shrink: 0;
+ padding: 0 8px;
+}
+
+.dt-tab {
+ padding: 8px 14px;
+ font-size: 12px;
+ font-weight: 400;
+ background: none;
+ border: none;
+ color: var(--dt-muted);
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.dt-tab:hover {
+ color: var(--dt-fg);
+}
+
+.dt-tab[data-active="true"] {
+ font-weight: 600;
+ color: var(--dt-accent);
+ border-bottom-color: var(--dt-accent);
+}
+
+/* โโ Tab overflow dropdown โโ */
+.dt-tab-overflow-wrapper {
+ position: relative;
+ margin-left: auto;
+}
+
+.dt-tab-overflow-btn {
+ padding: 8px 10px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--dt-dimmed);
+ border-bottom-color: transparent;
+}
+
+.dt-tab-overflow-btn:hover {
+ color: var(--dt-fg);
+}
+
+.dt-tab-overflow-menu {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ z-index: 100;
+ min-width: 120px;
+ padding: 4px 0;
+ background: var(--dt-bg);
+ border: 1px solid var(--dt-border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+}
+
+.dark .dt-tab-overflow-menu {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+}
+
+.dt-tab-overflow-item {
+ display: block;
+ width: 100%;
+ padding: 6px 12px;
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--dt-muted);
+ background: none;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.dt-tab-overflow-item:hover {
+ background: var(--dt-surface);
+ color: var(--dt-fg);
+}
+
+.dt-tab-overflow-item[data-active="true"] {
+ font-weight: 600;
+ color: var(--dt-accent);
+}
+
+/* โโ Panel area โโ */
+.dt-main {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 12px;
+}
+
+.dt-panel {
+ display: none;
+ height: 100%;
+}
+
+.dt-panel::after {
+ content: "";
+ display: block;
+ height: 12px;
+ flex-shrink: 0;
+}
+
+.dt-panel[data-visible="true"] {
+ display: flex;
+ flex-direction: column;
+}
+
+/* โโ Empty state โโ */
+.dt-empty {
+ padding: 40px;
+ text-align: center;
+ color: var(--dt-dimmed);
+}
+
+.dt-empty-icon {
+ font-size: 32px;
+ margin-bottom: 8px;
+}
+
+.dt-empty-title {
+ font-size: 13px;
+}
+
+.dt-empty-subtitle {
+ font-size: 12px;
+ margin-top: 4px;
+}
+
+/* โโ Badge โโ */
+/* โโ Unified tag/badge โโ
+ Solid colored pill used across all panels.
+ Apply `.dt-tag` + a color modifier: `.dt-tag-indigo`, `.dt-tag-green`, etc.
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+.dt-tag {
+ display: inline-block;
+ font-weight: 700;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 2px 7px;
+ border-radius: 4px;
+ color: #fff;
+ line-height: 1.4;
+ text-align: center;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.dt-tag-indigo { background: #6366f1; }
+.dt-tag-violet { background: #8b5cf6; }
+.dt-tag-green { background: #22c55e; }
+.dt-tag-amber { background: #f59e0b; }
+.dt-tag-red { background: #ef4444; }
+.dt-tag-cyan { background: #06b6d4; }
+.dt-tag-teal { background: #14b8a6; }
+.dt-tag-pink { background: #ec4899; }
+.dt-tag-orange { background: #f97316; }
+.dt-tag-sky { background: #0ea5e9; }
+.dt-tag-gray { background: #6b7280; }
+.dt-tag-purple { background: #7c3aed; }
+
+/* โโ Mono text โโ */
+.dt-mono {
+ font-family: monospace;
+}
+
+/* โโ Route table โโ */
+.dt-route-table {
+ flex: 1;
+ overflow: auto;
+ border: 1px solid var(--dt-border);
+ border-radius: 6px;
+}
+
+.dt-route-header,
+.dt-route-row {
+ display: grid;
+ grid-template-columns: 2fr 80px 60px 1fr;
+ gap: 8px;
+ padding: 6px 8px;
+ align-items: center;
+}
+
+.dt-route-header {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--dt-muted);
+ border-bottom: 1px solid var(--dt-border);
+ background: var(--dt-surface);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.dt-route-row {
+ font-size: 12px;
+ border-bottom: 1px solid var(--dt-row-border);
+}
+
+.dt-route-row-active {
+ background: var(--dt-accent-subtle);
+ border-left: 3px solid var(--dt-success);
+}
+
+.dt-route-path {
+ font-family: monospace;
+ font-weight: 500;
+}
+
+.dt-route-params {
+ margin-left: 6px;
+ font-size: 0.85em;
+ font-weight: 400;
+ opacity: 0.7;
+}
+
+.dt-route-ext-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.dt-route-ext-icon-generic {
+ color: var(--dt-dimmed);
+}
+
+.dt-route-src {
+ font-family: monospace;
+ font-size: 10px;
+ color: var(--dt-dimmed);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dt-route-src-link {
+ color: var(--dt-link);
+ text-decoration: none;
+ text-underline-offset: 2px;
+}
+
+.dt-route-src-link:hover {
+ text-decoration: underline;
+ color: var(--dt-accent);
+}
+
+/* โโ Component routes table โโ */
+.dt-comp-routes-header,
+.dt-comp-routes-row {
+ display: grid;
+ grid-template-columns: 2fr 80px 1fr;
+ gap: 8px;
+ padding: 6px 8px;
+ align-items: center;
+}
+
+.dt-comp-routes-header {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--dt-muted);
+ border-bottom: 1px solid var(--dt-border);
+ background: var(--dt-surface);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.dt-comp-routes-row {
+ font-size: 12px;
+ border-bottom: 1px solid var(--dt-row-border);
+}
+
+.dt-comp-routes-row .dt-badges {
+ margin-top: 0;
+ align-items: center;
+}
+
+/* โโ Section heading โโ */
+.dt-section-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--dt-muted);
+ margin-bottom: 8px;
+}
+
+.dt-section {
+ margin-top: 12px;
+}
+
+/* โโ Filter bar โโ */
+.dt-filters {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.dt-input,
+.dt-select {
+ padding: 4px 8px;
+ font-size: 12px;
+ background: var(--dt-surface);
+ color: var(--dt-fg);
+ border: 1px solid var(--dt-border);
+ border-radius: 4px;
+}
+
+.dt-input:focus,
+.dt-select:focus {
+ outline: none;
+ border-color: var(--dt-accent);
+ box-shadow: 0 0 0 1px var(--dt-accent-subtle);
+}
+
+.dt-input {
+ flex: 1;
+ min-width: 150px;
+}
+
+.dt-filter-info {
+ font-size: 11px;
+ color: var(--dt-dimmed);
+}
+
+.dt-type-filters {
+ display: flex;
+ gap: 2px;
+ flex-wrap: wrap;
+}
+
+.dt-type-filter-btn {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 8px;
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--dt-muted);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.dt-type-filter-btn:hover {
+ background: var(--dt-surface);
+}
+
+.dt-type-filter-btn[data-active="true"] {
+ color: var(--dt-fg);
+ background: var(--dt-surface);
+ border-color: var(--dt-border);
+}
+
+.dt-type-filter-btn .dt-tag {
+ font-size: 9px;
+ padding: 1px 5px;
+ min-width: 16px;
+ text-align: center;
+}
+
+/* โโ Collapsible details โโ */
+.dt-details {
+ font-size: 12px;
+}
+
+.dt-details summary {
+ cursor: pointer;
+ font-weight: 600;
+ color: var(--dt-muted);
+}
+
+.dt-details-body {
+ padding: 4px 0 4px 12px;
+}
+
+.dt-details-row {
+ font-family: monospace;
+ font-size: 11px;
+ padding: 2px 0;
+}
+
+.dt-details-row-path {
+ color: var(--dt-warn);
+}
+
+.dt-details-row-src {
+ color: var(--dt-faint);
+ margin-left: 8px;
+}
+
+/* โโ Card โโ */
+.dt-card {
+ padding: 10px 12px;
+ background: var(--dt-surface);
+ border: 1px solid var(--dt-border);
+ border-radius: 6px;
+}
+
+.dt-card-hover {
+ cursor: pointer;
+ transition: border-color 0.15s;
+}
+
+.dt-card-hover:hover {
+ border-color: var(--dt-accent);
+}
+
+/* โโ Card grid โโ */
+.dt-card-grid {
+ display: grid;
+ gap: 8px;
+}
+
+/* โโ Card header row โโ */
+.dt-card-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* โโ Card title โโ */
+.dt-card-title {
+ font-family: monospace;
+ font-weight: 600;
+ font-size: 13px;
+}
+
+/* โโ Card subtitle / truncated mono text โโ */
+.dt-card-sub {
+ font-size: 11px;
+ color: var(--dt-dimmed);
+ font-family: monospace;
+ margin-top: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dt-card-sub:first-of-type {
+ margin-top: 4px;
+}
+
+/* โโ Card meta row โโ */
+.dt-card-meta {
+ display: flex;
+ gap: 12px;
+ margin-top: 6px;
+ font-size: 11px;
+ color: var(--dt-dimmed);
+}
+
+.dt-card-meta strong {
+ font-family: monospace;
+ color: var(--dt-fg);
+ font-weight: 600;
+}
+
+/* โโ Badge row โโ */
+.dt-badges {
+ display: flex;
+ gap: 4px;
+ margin-top: 6px;
+}
+
+/* โโ Error box โโ */
+.dt-error-box {
+ margin-top: 6px;
+ padding: 4px 8px;
+ background: color-mix(in srgb, #ef4444 10%, transparent);
+ border: 1px solid color-mix(in srgb, #ef4444 25%, transparent);
+ border-radius: 4px;
+ font-size: 11px;
+ font-family: monospace;
+ color: #dc2626;
+}
+
+.dark .dt-error-box {
+ color: #fca5a5;
+}
+
+/* โโ Outlet panel โโ */
+.dt-outlet-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.dt-outlet-info {
+ flex: 1;
+}
+
+.dt-outlet-name {
+ font-family: monospace;
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.dt-outlet-url {
+ font-size: 11px;
+ color: var(--dt-dimmed);
+ font-family: monospace;
+ margin-top: 2px;
+}
+
+/* โโ Remote panel title โโ */
+.dt-remote-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.dt-remote-url {
+ font-family: monospace;
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--dt-fg);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0;
+}
+
+.dt-remote-actions {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.dt-card:hover .dt-remote-actions {
+ opacity: 1;
+}
+
+.dt-remote-action {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 6px;
+ background: var(--dt-surface);
+ color: var(--dt-dimmed);
+ cursor: pointer;
+ text-decoration: none;
+ transition: color 0.15s, background 0.15s;
+}
+
+.dt-remote-action:hover {
+ color: var(--dt-accent);
+ background: var(--dt-accent-subtle);
+}
+
+/* โโ Card flash animation (used when navigating to an outlet) โโ */
+@keyframes dt-flash {
+ 0% { box-shadow: 0 0 0 2px var(--dt-accent); }
+ 100% { box-shadow: 0 0 0 2px transparent; }
+}
+
+.dt-card-flash {
+ animation: dt-flash 1.5s ease-out;
+}
+
+/* โโ Host URL โโ */
+.dt-host-url {
+ font-size: 12px;
+ color: var(--dt-dimmed);
+ margin-bottom: 12px;
+}
+
+/* โโ Flex layout โโ */
+.dt-flex-col {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ gap: 12px;
+}
+
+/* โโ Live state icon โโ */
+.dt-live-icon {
+ font-size: 14px;
+}
+
+/* (outlet type badges now use unified .dt-tag classes) */
+
+.dt-outlet-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.dt-outlet-actions .dt-badges {
+ margin-top: 0;
+}
+
+.dt-outlet-refresh {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--dt-dimmed);
+ font-size: 16px;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.15s, color 0.15s, background 0.15s;
+}
+
+.dt-outlet-card:hover .dt-outlet-refresh {
+ opacity: 1;
+}
+
+.dt-outlet-refresh:hover {
+ color: var(--dt-accent);
+ background: var(--dt-accent-subtle);
+}
+
+/* โโ Chunk table (PayloadPanel) โโ */
+.dt-chunk-header,
+.dt-chunk-row {
+ display: grid;
+ grid-template-columns: 48px 70px 60px 1fr;
+ gap: 8px;
+ padding: 4px 8px;
+ align-items: start;
+}
+
+.dt-chunk-header {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--dt-muted);
+ border-bottom: 1px solid var(--dt-border);
+ background: var(--dt-surface);
+ position: sticky;
+ top: 0;
+ padding: 6px 8px;
+}
+
+.dt-chunk-row {
+ font-size: 12px;
+ border-bottom: 1px solid var(--dt-row-border);
+ transition: background 0.3s;
+}
+
+.dt-chunk-row-highlight {
+ background: color-mix(in srgb, #22c55e 8%, transparent);
+}
+
+.dt-chunk-id {
+ font-family: monospace;
+ color: var(--dt-muted);
+}
+
+.dt-chunk-size {
+ font-family: monospace;
+ font-size: 11px;
+ color: var(--dt-dimmed);
+}
+
+.dt-data-preview {
+ font-size: 11px;
+ font-family: monospace;
+ color: var(--dt-link);
+ word-break: break-all;
+}
+
+/* โโ Client ref rows โโ */
+.dt-client-ref {
+ all: unset;
+ display: block;
+ width: 100%;
+ text-align: left;
+ font-family: monospace;
+ font-size: 11px;
+ padding: 2px 0;
+ cursor: pointer;
+}
+
+.dt-client-ref-id {
+ color: var(--dt-dimmed);
+}
+
+.dt-client-ref-module {
+ color: var(--dt-link);
+ text-decoration: underline;
+ text-decoration-color: var(--dt-link-underline);
+ text-underline-offset: 2px;
+}
+
+.dt-client-ref-arrow {
+ color: var(--dt-dimmed);
+}
+
+.dt-client-ref-name {
+ color: var(--dt-success);
+}
+
+/* โโ Status panel โโ */
+.dt-stat-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 16px;
+}
+
+.dt-stat-card {
+ background: var(--dt-surface);
+ border: 1px solid var(--dt-border);
+ border-radius: 8px;
+ padding: 16px;
+}
+
+.dt-stat-title {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 12px;
+ color: var(--dt-muted);
+}
+
+.dt-kv-row {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ padding: 3px 0;
+}
+
+.dt-kv-label {
+ color: var(--dt-muted);
+}
+
+.dt-kv-value {
+ font-family: monospace;
+ font-weight: 500;
+}
+
+.dt-gauge {
+ margin-bottom: 12px;
+}
+
+.dt-gauge-header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 11px;
+ margin-bottom: 4px;
+}
+
+.dt-gauge-track {
+ height: 6px;
+ border-radius: 3px;
+ background: var(--dt-border);
+ overflow: hidden;
+}
+
+.dt-gauge-fill {
+ height: 100%;
+ border-radius: 3px;
+ transition: width 0.3s ease;
+}
+
+.dt-gauge-secondary {
+ color: var(--dt-muted);
+}
+
+/* โโ Separator โโ */
+.dt-separator {
+ border-top: 1px solid var(--dt-border);
+ margin-top: 8px;
+ padding-top: 8px;
+}
+
+/* โโ Cache panel โโ */
+.dt-cache-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.dt-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 6px 8px;
+ background: var(--dt-toolbar-bg);
+ border-bottom: 1px solid var(--dt-toolbar-border);
+ flex-shrink: 0;
+}
+
+.dt-cache-stats {
+ display: flex;
+ gap: 10px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.dt-cache-stat-hit { color: #22c55e; }
+.dt-cache-stat-miss { color: #f59e0b; }
+.dt-cache-stat-revalidate { color: #6366f1; }
+
+.dt-cache-filters {
+ display: flex;
+ gap: 2px;
+}
+
+.dt-filter-btn {
+ padding: 2px 8px;
+ font-size: 11px;
+ border: 1px solid var(--dt-border);
+ border-radius: 4px;
+ background: transparent;
+ color: var(--dt-muted);
+ cursor: pointer;
+}
+
+.dt-filter-btn[data-active="true"] {
+ background: var(--dt-accent);
+ color: #fff;
+ border-color: var(--dt-accent);
+}
+
+.dt-filter-btn:hover:not([data-active="true"]) {
+ background: var(--dt-surface);
+}
+
+.dt-cache-list {
+ overflow-y: auto;
+ flex: 1;
+}
+
+.dt-cache-event {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 8px;
+ font-size: 12px;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ border-bottom: 1px solid var(--dt-row-border);
+}
+
+.dt-cache-event:hover {
+ background: var(--dt-surface);
+}
+
+.dt-cache-time {
+ color: var(--dt-dimmed);
+ font-size: 11px;
+ flex-shrink: 0;
+}
+
+/* (cache type badges now use unified .dt-tag classes) */
+
+.dt-cache-fn {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0;
+}
+
+.dt-cache-fn-name {
+ color: var(--dt-link);
+ font-weight: 600;
+}
+
+.dt-cache-fn-args {
+ color: var(--dt-muted);
+}
+
+a.dt-cache-loc {
+ color: var(--dt-link);
+ text-decoration: underline;
+ text-decoration-color: var(--dt-link-underline);
+ font-size: 11px;
+ flex-shrink: 0;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+a.dt-cache-loc:hover {
+ text-decoration-color: var(--dt-link);
+}
+
+/* (cache provider badges now use unified .dt-tag classes) */
+
+.dt-cache-ttl {
+ color: var(--dt-dimmed);
+ font-size: 11px;
+ flex-shrink: 0;
+}
+
+.dt-cache-invalidate {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--dt-dimmed);
+ font-size: 12px;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.15s, background 0.15s, color 0.15s;
+}
+
+.dt-cache-event:hover .dt-cache-invalidate {
+ opacity: 1;
+}
+
+.dt-cache-invalidate:hover {
+ background: #ef4444;
+ color: #fff;
+}
+
+/* โโ Cache hydration section โโ */
+.dt-cache-hydration {
+ border-bottom: 1px solid var(--dt-border);
+ background: var(--dt-surface);
+}
+
+.dt-cache-hydration-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 10px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--dt-fg);
+ text-align: left;
+}
+
+.dt-cache-hydration-toggle:hover {
+ background: var(--dt-row-border);
+}
+
+.dt-cache-hydration-arrow {
+ font-size: 10px;
+ color: var(--dt-dimmed);
+ width: 10px;
+ flex-shrink: 0;
+}
+
+.dt-cache-hydration-summary {
+ color: var(--dt-muted);
+ font-size: 11px;
+}
+
+.dt-cache-hydration-list {
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid var(--dt-border);
+}
+
+.dt-cache-hydration-entry {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 10px 4px 28px;
+ font-size: 11px;
+ border-bottom: 1px solid var(--dt-row-border);
+}
+
+.dt-cache-hydration-entry:last-child {
+ border-bottom: none;
+}
+
+.dt-cache-hydration-key {
+ font-family: var(--dt-font-mono, monospace);
+ font-size: 10px;
+ color: var(--dt-accent);
+ flex-shrink: 0;
+ max-width: 140px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dt-cache-hydration-size {
+ font-size: 10px;
+ color: var(--dt-dimmed);
+ flex-shrink: 0;
+ min-width: 48px;
+ text-align: right;
+}
+
+.dt-cache-hydration-preview {
+ font-family: var(--dt-font-mono, monospace);
+ font-size: 10px;
+ color: var(--dt-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0;
+}
+
+/* โโ Page size bar (iOS-style) โโ */
+.dt-size-bar-container {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.dt-size-bar {
+ display: flex;
+ height: 8px;
+ border-radius: 4px;
+ overflow: hidden;
+ background: var(--dt-bg-subtle);
+}
+
+.dt-size-bar-segment {
+ min-width: 2px;
+ transition: width 0.3s ease;
+}
+
+.dt-size-bar-segment:first-child {
+ border-radius: 4px 0 0 4px;
+}
+
+.dt-size-bar-segment:last-child {
+ border-radius: 0 4px 4px 0;
+}
+
+.dt-size-bar-segment:only-child {
+ border-radius: 4px;
+}
+
+.dt-size-bar-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ font-size: 11px;
+ color: var(--dt-fg-muted);
+}
+
+.dt-size-bar-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ white-space: nowrap;
+}
+
+.dt-size-bar-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.dt-size-bar-label {
+ color: var(--dt-fg-muted);
+}
+
+.dt-size-bar-total {
+ font-weight: 600;
+ color: var(--dt-fg);
+}
+
+.dt-size-bar-value {
+ font-family: var(--dt-font-mono);
+ color: var(--dt-fg);
+}
+
+/* โโ Payload summary badges โโ */
+.dt-payload-summary {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ Workers panel
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+
+.dt-worker-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.dt-worker-stats {
+ display: flex;
+ gap: 12px;
+ font-size: 12px;
+ color: var(--dt-muted);
+}
+
+.dt-worker-stat-server { color: #8b5cf6; }
+.dt-worker-stat-client { color: #0ea5e9; }
+
+.dt-worker-filters {
+ display: flex;
+ gap: 4px;
+}
+
+.dt-worker-list {
+ flex: 1;
+ overflow-y: auto;
+ padding-top: 4px;
+}
+
+.dt-worker-entry {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--dt-row-border);
+ transition: background 0.1s;
+}
+
+.dt-worker-entry:hover {
+ background: var(--dt-surface);
+}
+
+.dt-worker-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+/* (worker type/state badges now use unified .dt-tag classes) */
+
+.dt-worker-id {
+ font-family: monospace;
+ font-size: 12px;
+ color: var(--dt-link);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0;
+}
+
+.dt-worker-details {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ font-size: 11px;
+ color: var(--dt-muted);
+}
+
+.dt-worker-detail {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+
+.dt-worker-detail-label {
+ font-weight: 600;
+ color: var(--dt-dimmed);
+}
+
+.dt-worker-detail-error {
+ color: #ef4444;
+}
+
+.dt-worker-detail-restart {
+ color: #f59e0b;
+}
+
+.dt-worker-active {
+ color: var(--dt-accent);
+ font-weight: 600;
+}
+
+.dt-worker-details code {
+ font-size: 11px;
+ padding: 0 3px;
+ border-radius: 3px;
+ background: var(--dt-surface);
+ color: var(--dt-link);
+}
+
+/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ Logs panel
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+.dt-logs-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+}
+
+.dt-logs-filters {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.dt-logs-actions {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ margin-left: auto;
+}
+
+.dt-logs-search {
+ font-size: 11px;
+ padding: 3px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--dt-border);
+ background: var(--dt-bg);
+ color: var(--dt-fg);
+ width: 160px;
+}
+
+.dt-logs-search::placeholder {
+ color: var(--dt-dimmed);
+}
+
+.dt-logs-clear-btn {
+ font-size: 11px;
+ padding: 3px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--dt-border);
+ background: var(--dt-surface);
+ color: var(--dt-muted);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.dt-logs-clear-btn:hover {
+ background: var(--dt-border);
+ color: var(--dt-fg);
+}
+
+.dt-logs-list {
+ flex: 1;
+ overflow-y: auto;
+ font-family: "SF Mono", "Cascadia Code", "Fira Code", Menlo, monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ background: #1e1e1e;
+}
+
+.dark .dt-logs-list {
+ background: #0d0d0d;
+}
+
+.dt-logs-sentinel {
+ /* Provides the full scrollable height for the virtualizer.
+ Rows are absolutely positioned inside this container. */
+ width: 100%;
+}
+
+.dt-logs-entry {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ padding: 1px 10px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ min-height: 20px;
+ box-sizing: border-box;
+ /* will-change for rows that are absolutely positioned + translated */
+ will-change: transform;
+}
+
+.dt-logs-entry:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.dt-logs-entry[data-stream="stderr"] {
+ background: rgba(239, 68, 68, 0.06);
+}
+
+.dt-logs-entry[data-stream="stderr"]:hover {
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.dt-logs-time {
+ color: #666;
+ font-size: 10px;
+ flex-shrink: 0;
+ min-width: 80px;
+ user-select: none;
+}
+
+.dt-logs-entry .dt-tag {
+ flex-shrink: 0;
+ font-size: 9px;
+ padding: 0 4px;
+ line-height: 16px;
+}
+
+.dt-logs-message {
+ flex: 1;
+ min-width: 0;
+ overflow-wrap: break-word;
+ white-space: pre-wrap;
+ color: #cccccc;
+}
+
+.dt-logs-scroll-btn {
+ position: absolute;
+ bottom: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 11px;
+ padding: 4px 12px;
+ border-radius: 12px;
+ border: 1px solid var(--dt-border);
+ background: var(--dt-surface);
+ color: var(--dt-accent);
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
+ z-index: 10;
+}
+
+.dt-logs-scroll-btn:hover {
+ background: var(--dt-bg);
+}
diff --git a/packages/react-server/devtools/index.d.ts b/packages/react-server/devtools/index.d.ts
new file mode 100644
index 00000000..293f0c6e
--- /dev/null
+++ b/packages/react-server/devtools/index.d.ts
@@ -0,0 +1,17 @@
+/**
+ * React Server DevTools configuration.
+ *
+ * Activated via:
+ * - CLI: `react-server dev --devtools`
+ * - Config: `{ devtools: true }` in react-server.config.mjs
+ *
+ * When active, the devtools panel is injected automatically โ
+ * no component import needed.
+ */
+export interface DevToolsConfig {
+ /**
+ * Position of the floating devtools button.
+ * @default "bottom-right"
+ */
+ position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
+}
diff --git a/packages/react-server/devtools/index.jsx b/packages/react-server/devtools/index.jsx
new file mode 100644
index 00000000..22bac461
--- /dev/null
+++ b/packages/react-server/devtools/index.jsx
@@ -0,0 +1,6 @@
+// DevTools is now activated via the --devtools CLI flag or config.devtools.
+// Host-page components (button, overlay, collector) are injected by render-rsc.jsx.
+// Iframe routes (/__react_server_devtools__/*) are intercepted by ssr-handler.mjs.
+//
+// This module is kept as the package export entry point for the type definition.
+export { default } from "./app/index.jsx";
diff --git a/packages/react-server/lib/dev/action.mjs b/packages/react-server/lib/dev/action.mjs
index 44864d92..9dc05357 100644
--- a/packages/react-server/lib/dev/action.mjs
+++ b/packages/react-server/lib/dev/action.mjs
@@ -1,8 +1,15 @@
+import { fstatSync } from "node:fs";
import { isIPv6 } from "node:net";
import open from "open";
import colors from "picocolors";
+import { installOutputCapture } from "./devtools-output.mjs";
+
+// Install stdout/stderr capture at module load time so that even the
+// earliest output (logo, banner, config validation) is captured.
+installOutputCapture();
+
import logo from "../../bin/logo.mjs";
import { loadConfig } from "../../config/index.mjs";
import {
@@ -112,6 +119,11 @@ export default async function dev(root, options) {
}
}
+ // Merge CLI --devtools flag into config (CLI wins over config file)
+ if (options.devtools) {
+ configRoot.devtools = true;
+ }
+
runtime$(CONFIG_CONTEXT, config);
// Resolve the action encryption secret once at startup
@@ -120,13 +132,25 @@ export default async function dev(root, options) {
await import("../../server/action-crypto.mjs");
await initSecretFromConfig(configRoot);
- const isNonInteractiveEnvironment =
- !process.stdin.isTTY ||
- process.env.CI === "true" ||
- process.env.DOCKER_CONTAINER === "true";
+ // Detect whether the user is piping code via stdin.
+ // Use fstat on fd 0 to distinguish a real pipe/file redirect
+ // from a non-TTY background process, Docker, or CI runner
+ // where stdin is /dev/null or closed.
+ // Only relevant when no explicit root module was provided โ
+ // if the user passed `react-server ./app.jsx`, use that even
+ // when stdin happens to be piped (e.g. via npm-run-all/run-p).
+ let isStdinPiped = false;
+ if (!root) {
+ try {
+ const stat = fstatSync(0);
+ isStdinPiped = stat.isFIFO() || stat.isFile();
+ } catch {
+ // fd 0 not available โ not piped
+ }
+ }
server = await createServer(
- options.eval || isNonInteractiveEnvironment
+ options.eval || isStdinPiped
? "virtual:react-server-eval.jsx"
: root,
options
diff --git a/packages/react-server/lib/dev/create-server.mjs b/packages/react-server/lib/dev/create-server.mjs
index a2324d08..348a7e4e 100644
--- a/packages/react-server/lib/dev/create-server.mjs
+++ b/packages/react-server/lib/dev/create-server.mjs
@@ -27,6 +27,7 @@ import {
CONFIG_CONTEXT,
CONFIG_ROOT,
IMPORT_MAP,
+ DEVTOOLS_CONTEXT,
LOGGER_CONTEXT,
MEMORY_CACHE_CONTEXT,
MODULE_LOADER,
@@ -169,8 +170,12 @@ export default async function createServer(root, options) {
? config.cors
: false;
+ // Strip react-server-specific keys that would confuse Vite
+ // (e.g. `devtools: true` triggers Vite's @vitejs/devtools loader)
+ // oxlint-disable-next-line no-unused-vars
+ const { devtools: _devtools, ...viteCompatConfig } = config;
const devServerConfig = {
- ...config,
+ ...viteCompatConfig,
json: {
namedExports: true,
},
@@ -584,6 +589,20 @@ export default async function createServer(root, options) {
}
}
+ // Initialize devtools context before Vite so file-router plugin can
+ // push its manifest during createViteDevServer().
+ if (config.devtools) {
+ const { createDevToolsContext } =
+ await import("../../devtools/context.mjs");
+ const devtoolsCtx = createDevToolsContext();
+ runtime$(DEVTOOLS_CONTEXT, devtoolsCtx);
+
+ // Flush any output buffered since installOutputCapture() and switch
+ // to direct recording for all future stdout/stderr writes.
+ const { connectDevToolsOutput } = await import("./devtools-output.mjs");
+ connectDevToolsOutput(devtoolsCtx);
+ }
+
// โโ Telemetry: Vite dev server creation span โโ
const viteCreateSpan = startupTracer.startSpan("Vite Dev Server Init", {
attributes: {
diff --git a/packages/react-server/lib/dev/devtools-output.mjs b/packages/react-server/lib/dev/devtools-output.mjs
new file mode 100644
index 00000000..49c30f2d
--- /dev/null
+++ b/packages/react-server/lib/dev/devtools-output.mjs
@@ -0,0 +1,86 @@
+/**
+ * Intercept process.stdout / process.stderr as early as possible so that
+ * every line that appears in the terminal is also forwarded to the devtools
+ * log panel.
+ *
+ * Call `installOutputCapture()` *before* any meaningful work starts โ it
+ * buffers entries until `connectDevToolsOutput(ctx)` is called with the
+ * devtools context, at which point buffered entries are flushed and all
+ * future writes go directly to `ctx.recordLog()`.
+ */
+
+let installed = false;
+let buffer = [];
+let devtoolsCtx = null;
+
+// Strip non-SGR CSI escape sequences (cursor movement, line erasing, etc.)
+// while preserving SGR color/style sequences (\x1b[...m).
+// Also strips OSC sequences (\x1b]...\x07 or \x1b]...\x1b\\) used for
+// terminal titles and hyperlinks.
+/* oxlint-disable no-control-regex */
+const CSI_NON_SGR_RE = new RegExp(
+ "\\x1b\\[[0-9;]*[A-HJKSTfhlnr]|\\x1b\\][\\s\\S]*?(?:\\x07|\\x1b\\\\)|\\r",
+ "g"
+);
+/* oxlint-enable no-control-regex */
+
+function sanitize(raw) {
+ return raw.replace(CSI_NON_SGR_RE, "");
+}
+
+function record(stream, chunk, encoding) {
+ try {
+ const raw =
+ typeof chunk === "string"
+ ? chunk
+ : chunk.toString(typeof encoding === "string" ? encoding : "utf-8");
+ const text = sanitize(raw);
+ if (!text.trim()) return;
+
+ if (devtoolsCtx) {
+ devtoolsCtx.recordLog(stream, text);
+ } else {
+ buffer.push({ stream, text, timestamp: Date.now() });
+ // Safety cap โ drop oldest if we buffer too much before context is ready
+ if (buffer.length > 2000) buffer.shift();
+ }
+ } catch {
+ // never let devtools capture crash the server
+ }
+}
+
+/**
+ * Monkey-patch stdout/stderr.write. Safe to call multiple times โ only
+ * the first call installs the patches.
+ */
+export function installOutputCapture() {
+ if (installed) return;
+ installed = true;
+
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
+
+ process.stdout.write = function (chunk, encoding, cb) {
+ record("stdout", chunk, encoding);
+ return origStdoutWrite(chunk, encoding, cb);
+ };
+
+ process.stderr.write = function (chunk, encoding, cb) {
+ record("stderr", chunk, encoding);
+ return origStderrWrite(chunk, encoding, cb);
+ };
+}
+
+/**
+ * Flush buffered entries into the devtools context and switch to direct
+ * recording for all future writes.
+ */
+export function connectDevToolsOutput(ctx) {
+ devtoolsCtx = ctx;
+
+ // Replay buffered entries
+ for (const entry of buffer) {
+ ctx.recordLog(entry.stream, entry.text);
+ }
+ buffer = [];
+}
diff --git a/packages/react-server/lib/dev/index.mjs b/packages/react-server/lib/dev/index.mjs
index af8149c8..925c8925 100644
--- a/packages/react-server/lib/dev/index.mjs
+++ b/packages/react-server/lib/dev/index.mjs
@@ -1,6 +1,6 @@
import { loadConfig } from "../../config/index.mjs";
import { init$ as runtime_init$, runtime$ } from "../../server/runtime.mjs";
-import { CONFIG_CONTEXT } from "../../server/symbols.mjs";
+import { CONFIG_CONTEXT, CONFIG_ROOT } from "../../server/symbols.mjs";
import { experimentalWarningSilence } from "../sys.mjs";
export function reactServer(root, options = {}, initialConfig = {}) {
@@ -13,13 +13,18 @@ export function reactServer(root, options = {}, initialConfig = {}) {
const { default: createServer } = await import("./create-server.mjs");
const config = await loadConfig(initialConfig, options);
+ // Install stdout/stderr capture only when devtools is enabled
+ if (config[CONFIG_ROOT]?.devtools) {
+ const { installOutputCapture } = await import("./devtools-output.mjs");
+ installOutputCapture();
+ }
+
await runtime_init$(async () => {
runtime$(CONFIG_CONTEXT, config);
// Resolve the action encryption secret once at startup.
const { initSecretFromConfig } =
await import("../../server/action-crypto.mjs");
- const { CONFIG_ROOT } = await import("../../server/symbols.mjs");
await initSecretFromConfig(config[CONFIG_ROOT]);
const server = await createServer(root, options);
diff --git a/packages/react-server/lib/dev/ssr-handler.mjs b/packages/react-server/lib/dev/ssr-handler.mjs
index 15b6d524..c2e95099 100644
--- a/packages/react-server/lib/dev/ssr-handler.mjs
+++ b/packages/react-server/lib/dev/ssr-handler.mjs
@@ -158,6 +158,38 @@ export default async function ssrHandler(root) {
}
const handler = async () => {
+ // Dev-only: intercept devtools routes and render the devtools
+ // app directly, bypassing the user's component tree and middleware.
+ if (
+ configRoot.devtools &&
+ httpContext.url?.pathname?.startsWith(
+ "/__react_server_devtools__"
+ )
+ ) {
+ try {
+ const { default: DevToolsApp } = await ssrLoadModule(
+ "@lazarv/react-server/devtools/app/index.jsx"
+ );
+ if (DevToolsApp) {
+ // Start with empty arrays โ devtools client components
+ // are discovered during RSC rendering, not from the user's module graph
+ context$(CLIENT_MODULES_CONTEXT, []);
+ context$(STYLES_CONTEXT, []);
+ await module_loader_init$?.(
+ ssrLoadModule,
+ moduleCacheStorage,
+ null,
+ "rsc"
+ );
+ return moduleCacheStorage.run(new Map(), async () => {
+ return render(DevToolsApp, {});
+ });
+ }
+ } catch (e) {
+ logger.error("DevTools render error:", e);
+ }
+ }
+
let middlewareError = null;
try {
const middlewareHandler = await root_init$?.();
diff --git a/packages/react-server/lib/http/middleware.mjs b/packages/react-server/lib/http/middleware.mjs
index a3f24682..3c8ed2f3 100644
--- a/packages/react-server/lib/http/middleware.mjs
+++ b/packages/react-server/lib/http/middleware.mjs
@@ -189,7 +189,7 @@ export function createMiddleware(handler, options = {}) {
// DOMException is constructed on the happy path.
const onClose = () => {
if (!res.writableFinished) {
- abortController.abort();
+ abortController.abort("client disconnected");
try {
nodeReadable.destroy(new Error("aborted"));
} catch {
diff --git a/packages/react-server/lib/plugins/file-router/plugin.mjs b/packages/react-server/lib/plugins/file-router/plugin.mjs
index 9cfb4e2f..c38f8771 100644
--- a/packages/react-server/lib/plugins/file-router/plugin.mjs
+++ b/packages/react-server/lib/plugins/file-router/plugin.mjs
@@ -12,7 +12,11 @@ import * as sys from "@lazarv/react-server/lib/sys.mjs";
import merge from "@lazarv/react-server/lib/utils/merge.mjs";
import { getContext } from "@lazarv/react-server/server/context.mjs";
import { applyParamsToPath } from "@lazarv/react-server/server/route-match.mjs";
-import { BUILD_OPTIONS } from "@lazarv/react-server/server/symbols.mjs";
+import {
+ BUILD_OPTIONS,
+ DEVTOOLS_CONTEXT,
+} from "@lazarv/react-server/server/symbols.mjs";
+import { getRuntime } from "@lazarv/react-server/server/runtime.mjs";
import { initStoreEntry, setVirtualModuleContent } from "../resources.mjs";
import { watch } from "chokidar";
import glob from "fast-glob";
@@ -920,6 +924,38 @@ export default function viteReactServerRouter(options = {}) {
}
}
+ // Push manifest to devtools context for the route inspector panel
+ const devtools = getRuntime(DEVTOOLS_CONTEXT);
+ if (devtools) {
+ const apiRoutes = entry.api.map(
+ ({ directory, filename, src, _virtualPath, _virtualMethod }) => {
+ if (_virtualPath !== undefined) {
+ return [_virtualMethod ?? "*", _virtualPath, src];
+ }
+ const normalized = filename
+ .replace(/^\+*/g, "")
+ .replace(/\.\.\./g, "_dot_dot_dot_")
+ .replace(/(\{)[^}]*(\})/g, (m) => m.replace(/\./g, "_dot_"))
+ .split(".");
+ const [method, name, ext] = apiEndpointRegExp.test(filename)
+ ? normalized
+ : ["*", normalized[0] === "server" ? "" : normalized[0], ""];
+ const path = `/${directory}/${ext ? name : ""}`
+ .replace(/\/+$/g, "")
+ .replace(/_dot_dot_dot_/g, "...")
+ .replace(/_dot_/g, ".")
+ .replace(/(\{)([^}]*)(\})/g, "$2")
+ .replace(/^\/+/, "/");
+ return [method, path, src];
+ }
+ );
+ devtools.setFileRouterManifest({
+ pages: manifest.pages,
+ middlewares: manifest.middlewares,
+ routes: apiRoutes,
+ });
+ }
+
const dynamicRouteGenericTypes = Array.from({
length: manifest.pages.reduce((acc, [, path, , type]) => {
if (type === "page") {
@@ -2470,10 +2506,15 @@ ${lazyValidateLines.join("\n")}
return props;
}, {})
)
- .map(
- ([outlet, components]) =>
- `${outlet}={${Array.isArray(components) ? `[${components.join(", ")}]` : components}}`
- )
+ .map(([outlet, components]) => {
+ const content = Array.isArray(components)
+ ? `[${components.join(", ")}]`
+ : components;
+ if (getRuntime(DEVTOOLS_CONTEXT)) {
+ return `${outlet}={(() => { const __o = ${content}; return __o ? <>{__o}> : null; })()}`;
+ }
+ return `${outlet}={${content}}`;
+ })
.join(" ")}>${
loading && !errorBoundary
? `}>`
diff --git a/packages/react-server/lib/plugins/react-server-eval.mjs b/packages/react-server/lib/plugins/react-server-eval.mjs
index 7a8314f9..5d5f566d 100644
--- a/packages/react-server/lib/plugins/react-server-eval.mjs
+++ b/packages/react-server/lib/plugins/react-server-eval.mjs
@@ -1,3 +1,19 @@
+import { fstatSync } from "node:fs";
+
+// Check whether stdin is an actual pipe or file redirect (i.e. the user
+// is piping code into react-server), as opposed to a TTY, /dev/null
+// (background process), or a closed fd (spawned subprocess).
+// `isFIFO()` catches `echo "code" | react-server`
+// `isFile()` catches `react-server < file.jsx`
+function isStdinPiped() {
+ try {
+ const stat = fstatSync(0);
+ return stat.isFIFO() || stat.isFile();
+ } catch {
+ return false;
+ }
+}
+
export default function reactServerEval(options) {
return {
name: "react-server:eval",
@@ -16,7 +32,7 @@ export default function reactServerEval(options) {
async handler() {
if (options.eval) {
return options.eval;
- } else if (!process.env.CI && !process.stdin.isTTY) {
+ } else if (isStdinPiped()) {
let code = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
diff --git a/packages/react-server/lib/plugins/use-cache-inline.mjs b/packages/react-server/lib/plugins/use-cache-inline.mjs
index d10bc726..87756ded 100644
--- a/packages/react-server/lib/plugins/use-cache-inline.mjs
+++ b/packages/react-server/lib/plugins/use-cache-inline.mjs
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
+import { originalPositionFor, TraceMap } from "@jridgewell/trace-mapping";
import colors from "picocolors";
import * as sys from "../sys.mjs";
@@ -439,6 +440,51 @@ export default function useCacheInline(profiles, providers = {}, type) {
}
}
+ // Resolve original source positions via combined source map
+ if (this.environment?.mode !== "build" && caches.length > 0) {
+ try {
+ const map = this.getCombinedSourcemap?.();
+ if (map) {
+ const traced = new TraceMap(map);
+ for (const cache of caches) {
+ const loc = cache.node.loc?.start;
+ if (loc) {
+ const orig = originalPositionFor(traced, {
+ line: loc.line,
+ column: loc.column,
+ });
+ if (orig.source) {
+ cache._origFile = orig.source;
+ cache._origLine = orig.line;
+ cache._origCol = orig.column;
+ } else {
+ cache._origLine = loc.line;
+ cache._origCol = loc.column;
+ }
+ }
+ }
+ } else {
+ // No prior transforms โ positions are already original
+ for (const cache of caches) {
+ const loc = cache.node.loc?.start;
+ if (loc) {
+ cache._origLine = loc.line;
+ cache._origCol = loc.column;
+ }
+ }
+ }
+ } catch {
+ // Source map resolution failed โ use raw AST positions
+ for (const cache of caches) {
+ const loc = cache.node.loc?.start;
+ if (loc) {
+ cache._origLine = loc.line;
+ cache._origCol = loc.column;
+ }
+ }
+ }
+ }
+
for (const cache of caches) {
if (
cache.provider &&
@@ -543,6 +589,67 @@ export default function useCacheInline(profiles, providers = {}, type) {
type: "Literal",
value: hash,
},
+ {
+ type: "ObjectExpression",
+ properties: [
+ {
+ type: "Property",
+ kind: "init",
+ key: {
+ type: "Identifier",
+ name: "__devtools__",
+ },
+ value: { type: "Literal", value: true },
+ },
+ {
+ type: "Property",
+ kind: "init",
+ key: { type: "Identifier", name: "file" },
+ value: {
+ type: "Literal",
+ value: cache._origFile ?? id,
+ },
+ },
+ {
+ type: "Property",
+ kind: "init",
+ key: { type: "Identifier", name: "line" },
+ value: {
+ type: "Literal",
+ value: cache._origLine ?? 0,
+ },
+ },
+ {
+ type: "Property",
+ kind: "init",
+ key: { type: "Identifier", name: "col" },
+ value: {
+ type: "Literal",
+ value: cache._origCol ?? 0,
+ },
+ },
+ {
+ type: "Property",
+ kind: "init",
+ key: { type: "Identifier", name: "fn" },
+ value: {
+ type: "Literal",
+ value:
+ cache.identifier ||
+ cache.node.id?.name ||
+ (cache.node.parent?.type ===
+ "VariableDeclarator"
+ ? cache.node.parent.id?.name
+ : null) ||
+ (cache.node.parent?.type ===
+ "ExportDefaultDeclaration"
+ ? "default"
+ : null) ||
+ "anonymous",
+ },
+ },
+ ],
+ },
]
: []),
],
diff --git a/packages/react-server/package.json b/packages/react-server/package.json
index 0be49d25..74ddd39e 100644
--- a/packages/react-server/package.json
+++ b/packages/react-server/package.json
@@ -101,6 +101,10 @@
"types": "./lib/dev/index.d.ts",
"default": "./lib/dev/index.mjs"
},
+ "./devtools": {
+ "types": "./devtools/index.d.ts",
+ "default": "./devtools/index.jsx"
+ },
"./node": {
"types": "./lib/start/node.d.ts",
"default": "./lib/start/node.mjs"
diff --git a/packages/react-server/server/RemoteComponent.jsx b/packages/react-server/server/RemoteComponent.jsx
index 358f3f62..ec5aeddf 100644
--- a/packages/react-server/server/RemoteComponent.jsx
+++ b/packages/react-server/server/RemoteComponent.jsx
@@ -189,6 +189,7 @@ export default async function RemoteComponent({
outlet={`__react_server_remote__${remoteUrlString.replace(/[^a-zA-Z0-9_]/g, "_")}`}
request={request}
remoteProps={props}
+ ttl={ttl}
>
import.meta.env.DEV
? {
starting(specifier) {
+ if (isInternalSpecifier(specifier)) return;
logger?.info(
`${colors.green("Starting")} Live Component worker ${colors.gray(colors.italic(normalizeSpecifier(specifier)))} ๐`
);
},
disconnect(socket, specifier) {
+ if (isInternalSpecifier(specifier)) return;
logger?.warn(
`Live client ${colors.gray(colors.italic(socket.id))} disconnected ${colors.gray(colors.italic(normalizeSpecifier(specifier)))} โ`
);
},
finished(specifier) {
+ if (isInternalSpecifier(specifier)) return;
logger?.info(
`Live Component worker ${colors.green("finished")} ${colors.gray(
colors.italic(normalizeSpecifier(specifier))
@@ -51,6 +60,7 @@ const createLogger = (logger) =>
);
},
aborted(specifier) {
+ if (isInternalSpecifier(specifier)) return;
logger?.warn(
`Live Component worker ${colors.gray(colors.italic(normalizeSpecifier(specifier)))} aborted ๐ซ`
);
@@ -61,21 +71,25 @@ const createLogger = (logger) =>
}
: {
starting(specifier) {
+ if (isInternalSpecifier(specifier)) return;
logger?.info(
`Starting Live Component worker ${normalizeSpecifier(specifier)}`
);
},
disconnect(socket, specifier) {
+ if (isInternalSpecifier(specifier)) return;
logger?.warn(
`Live client ${socket.id} disconnected from ${normalizeSpecifier(specifier)}`
);
},
finished(specifier) {
+ if (isInternalSpecifier(specifier)) return;
logger?.info(
`Live Component worker finished ${normalizeSpecifier(specifier)}`
);
},
aborted(specifier) {
+ if (isInternalSpecifier(specifier)) return;
logger?.warn(
`Live Component worker ${normalizeSpecifier(specifier)} aborted`
);
@@ -116,9 +130,22 @@ export async function runLiveComponent(
once: true,
});
+ const devtools = import.meta.env.DEV
+ ? getRuntime(DEVTOOLS_CONTEXT)
+ : null;
+
return AbortControllerStorage.run(abortController, async () => {
try {
logger.starting(specifier);
+
+ devtools?.recordLiveComponent(outlet, {
+ specifier,
+ displayName,
+ streaming,
+ state: "starting",
+ yields: 0,
+ });
+
const temporaryReferences = getContext(RENDER_TEMPORARY_REFERENCES);
const worker = Component(props);
const { done, value: component } = await worker.next();
@@ -132,6 +159,10 @@ export async function runLiveComponent(
);
}
+ devtools?.updateLiveComponent(outlet, {
+ state: "waiting",
+ });
+
const namespace = io.of(`/${outlet}`);
const process = async (socket) => {
@@ -141,6 +172,11 @@ export async function runLiveComponent(
namespace.off("connection", process);
});
+ devtools?.updateLiveComponent(outlet, {
+ state: "running",
+ });
+
+ let yields = 0;
const cleanupController = new AbortController();
try {
while (true) {
@@ -149,6 +185,12 @@ export async function runLiveComponent(
throw new Error("LIVE_COMPONENT_ABORTED");
}
if (value) {
+ yields++;
+ devtools?.updateLiveComponent(outlet, {
+ yields,
+ lastYieldAt: Date.now(),
+ });
+
if (streaming) {
const stream = await toStream(value, {
temporaryReferences,
@@ -173,6 +215,9 @@ export async function runLiveComponent(
}
if (done) {
logger.finished(specifier);
+ devtools?.updateLiveComponent(outlet, {
+ state: "finished",
+ });
cleanupController.abort();
break;
}
@@ -183,8 +228,15 @@ export async function runLiveComponent(
error.message === "LIVE_COMPONENT_ABORTED"
) {
logger.aborted(specifier);
+ devtools?.updateLiveComponent(outlet, {
+ state: "aborted",
+ });
} else {
logger.error(error);
+ devtools?.updateLiveComponent(outlet, {
+ state: "error",
+ error: error.message,
+ });
}
}
@@ -193,6 +245,11 @@ export async function runLiveComponent(
};
namespace.on("connection", process);
+ } else {
+ devtools?.updateLiveComponent(outlet, {
+ state: "finished",
+ yields: 1,
+ });
}
resolve(component ?? null);
diff --git a/packages/react-server/server/render-dom.mjs b/packages/react-server/server/render-dom.mjs
index 110c6809..e6816ae8 100644
--- a/packages/react-server/server/render-dom.mjs
+++ b/packages/react-server/server/render-dom.mjs
@@ -199,6 +199,7 @@ export const createRenderer = ({
body,
requestCacheBuffer,
httpContext,
+ devtools,
}) => {
if (!flight && !streamMap.has(id)) {
flight = new ReadableStream({
@@ -546,8 +547,17 @@ export const createRenderer = ({
force = value[value.length - 1] !== 0x0a;
if (!bootstrapped && bytesHasLine0Colon(value)) {
+ const flightInit = `self.__flightStream__${outlet}__=new TransformStream();self.__flightWriter__${outlet}__=self.__flightStream__${outlet}__.writable.getWriter();self.__flightEncoder__${outlet}__=new TextEncoder();`;
+ // Dev mode: wrap the flight writer to buffer raw text for devtools.
+ // Only active when --devtools flag is set (passed from render-rsc.jsx).
+ const devtoolsHook = devtools
+ ? `self.__react_server_devtools_flight__=[];` +
+ `self.__react_server_pathname__=${JSON.stringify(new URL(httpContext.url).pathname)};` +
+ `(function(){var w=self.__flightWriter__${outlet}__,_w=w.write.bind(w),d=new TextDecoder();` +
+ `w.write=function(c){self.__react_server_devtools_flight__.push(d.decode(c,{stream:true}));return _w(c)};})();`
+ : "";
bootstrapScripts.unshift(
- `self.__flightStream__${outlet}__=new TransformStream();self.__flightWriter__${outlet}__=self.__flightStream__${outlet}__.writable.getWriter();self.__flightEncoder__${outlet}__=new TextEncoder();`
+ flightInit + devtoolsHook
);
bootstrapped = true;
}
diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx
index a5130a90..7a87e6e1 100644
--- a/packages/react-server/server/render-rsc.jsx
+++ b/packages/react-server/server/render-rsc.jsx
@@ -64,12 +64,42 @@ import { serverReferenceMap as _serverReferenceMap } from "@lazarv/react-server/
import { decryptActionId, wrapServerReferenceMap } from "./action-crypto.mjs";
import { ScrollRestoration } from "../client/ScrollRestoration.jsx";
+let DevToolsHost;
+
const serverReferenceMap = wrapServerReferenceMap(_serverReferenceMap);
export async function render(Component, props = {}, options = {}) {
const logger = getContext(LOGGER_CONTEXT);
const renderStream = getContext(RENDER_STREAM);
const config = getContext(CONFIG_CONTEXT)?.[CONFIG_ROOT];
+
+ if (import.meta.env.DEV && config?.devtools && !DevToolsHost) {
+ const [
+ { default: DevToolsButton },
+ { default: HighlightOverlay },
+ { default: PayloadCollector },
+ { version: _runtimeVersion },
+ ] = await Promise.all([
+ import("../devtools/client/DevToolsButton.jsx"),
+ import("../devtools/client/HighlightOverlay.jsx"),
+ import("../devtools/client/PayloadCollector.jsx"),
+ import("./version.mjs"),
+ ]);
+
+ DevToolsHost = function DevToolsHost({ position }) {
+ return (
+ <>
+
+
+
+ >
+ );
+ };
+ }
+
try {
const streaming = new Promise(async (resolve, reject) => {
const context = getContext(HTTP_CONTEXT);
@@ -435,7 +465,7 @@ export async function render(Component, props = {}, options = {}) {
);
}
: () => null;
- const ComponentWithStyles = (
+ const additionalComponents = (
<>
{remoteRSC ? null : (
)}
+ {import.meta.env.DEV &&
+ config.devtools &&
+ !renderContext.flags.isRSC &&
+ !remote &&
+ !remoteRSC &&
+ !context.url?.pathname?.startsWith(
+ "/__react_server_devtools__"
+ ) && }
+ >
+ );
+ const ComponentWithStyles = (
+ <>
+ {additionalComponents}
>
);
@@ -528,8 +571,7 @@ export async function render(Component, props = {}, options = {}) {
if (ErrorBoundary) {
app = (
<>
-
-
+ {additionalComponents}
@@ -585,6 +627,12 @@ export async function render(Component, props = {}, options = {}) {
? "no-cache"
: "must-revalidate",
"last-modified": lastModified,
+ ...(config.devtools &&
+ !context.url?.pathname?.startsWith(
+ "/__react_server_devtools__"
+ )
+ ? { "x-react-server-pathname": context.url.pathname }
+ : {}),
...callServerHeaders,
...(prevHeaders
? Object.fromEntries(prevHeaders.entries())
@@ -928,6 +976,12 @@ export async function render(Component, props = {}, options = {}) {
getContext(REQUEST_CACHE_SHARED)?.buffer ??
getContext(REQUEST_CACHE_SHARED) ??
null,
+ // Pass devtools flag to render-dom worker for flight writer hook.
+ // Skip for devtools iframe routes โ they don't need payload capture.
+ devtools:
+ import.meta.env.DEV &&
+ !!config.devtools &&
+ !context.url?.pathname?.startsWith("/__react_server_devtools__"),
httpContext: {
request: {
method: context.request.method,
diff --git a/packages/react-server/server/symbols.mjs b/packages/react-server/server/symbols.mjs
index aefed000..4f24670a 100644
--- a/packages/react-server/server/symbols.mjs
+++ b/packages/react-server/server/symbols.mjs
@@ -72,3 +72,4 @@ export const SCROLL_RESTORATION_MODULE = Symbol.for(
export const REQUEST_CACHE_CONTEXT = Symbol.for("REQUEST_CACHE_CONTEXT");
export const REQUEST_CACHE_SHARED = Symbol.for("REQUEST_CACHE_SHARED");
export const RESPONSE_BUFFER = Symbol.for("RESPONSE_BUFFER");
+export const DEVTOOLS_CONTEXT = Symbol.for("DEVTOOLS_CONTEXT");
diff --git a/packages/react-server/server/worker-proxy.mjs b/packages/react-server/server/worker-proxy.mjs
index a236b858..aa38b63f 100644
--- a/packages/react-server/server/worker-proxy.mjs
+++ b/packages/react-server/server/worker-proxy.mjs
@@ -10,6 +10,7 @@ import { getRuntime } from "@lazarv/react-server/server/runtime.mjs";
import {
CONSOLE_PROXY,
DEV_SERVER_CONTEXT,
+ DEVTOOLS_CONTEXT,
EXEC_OPTIONS,
LOGGER_CONTEXT,
RSC_MODULE_RUNNER,
@@ -36,6 +37,9 @@ export default function createWorkerProxy(id, env = "dev") {
getContext(LOGGER_CONTEXT) ?? getRuntime(LOGGER_CONTEXT) ?? console;
logger.info(`Spawning worker proxy for ${id} in ${env} environment.`);
+ const devtools = import.meta.env?.DEV ? getRuntime(DEVTOOLS_CONTEXT) : null;
+ devtools?.recordWorker(id, { env });
+
const options = getRuntime(EXEC_OPTIONS) || {};
const moduleRunner = getRuntime(RSC_MODULE_RUNNER);
const viteDevServer = getRuntime(DEV_SERVER_CONTEXT);
@@ -52,6 +56,7 @@ export default function createWorkerProxy(id, env = "dev") {
workerReady = new Promise((resolve) => {
worker.once("message", (payload) => {
if (payload.type === "react-server:worker:ready") {
+ devtools?.updateWorker(id, { state: "ready" });
resolve();
}
});
@@ -136,6 +141,12 @@ export default function createWorkerProxy(id, env = "dev") {
new Error(`Worker error in worker proxy for ${id}.`, { cause: error })
);
+ devtools?.updateWorker(id, (prev) => ({
+ state: "error",
+ errors: (prev?.errors ?? 0) + 1,
+ lastError: error.message,
+ }));
+
workerPromise.forEach(({ reject }, key) => {
reject(
new Error(`Worker encountered an error and has been terminated.`)
@@ -158,6 +169,11 @@ export default function createWorkerProxy(id, env = "dev") {
logger.info(`Worker exited, restarting worker proxy for ${id}.`);
}
+ devtools?.updateWorker(id, (prev) => ({
+ state: "restarting",
+ restarts: (prev?.restarts ?? 0) + 1,
+ }));
+
workerPromise.forEach(({ reject }, key) => {
reject(new Error(`Worker has exited and has been terminated.`));
workerPromise.delete(key);
@@ -184,19 +200,48 @@ export default function createWorkerProxy(id, env = "dev") {
worker = spawn();
}
+ const devtools = import.meta.env?.DEV
+ ? getRuntime(DEVTOOLS_CONTEXT)
+ : null;
+ devtools?.updateWorker(id, (prev) => ({
+ invocations: (prev?.invocations ?? 0) + 1,
+ activeInvocations: (prev?.activeInvocations ?? 0) + 1,
+ lastInvokedAt: Date.now(),
+ lastFn: fn,
+ }));
+
return new Promise(async (resolve, reject) => {
- const id = randomUUID();
+ const invocationId = randomUUID();
const signal = getContext(HTTP_CONTEXT)?.signal;
- workerPromise.set(id, { resolve, reject });
+ workerPromise.set(invocationId, {
+ resolve: (val) => {
+ devtools?.updateWorker(id, (prev) => ({
+ activeInvocations: Math.max(
+ 0,
+ (prev?.activeInvocations ?? 1) - 1
+ ),
+ }));
+ resolve(val);
+ },
+ reject: (err) => {
+ devtools?.updateWorker(id, (prev) => ({
+ activeInvocations: Math.max(
+ 0,
+ (prev?.activeInvocations ?? 1) - 1
+ ),
+ }));
+ reject(err);
+ },
+ });
if (signal) {
const onAbort = () => {
worker.postMessage({
type: "react-server:worker:abort",
- id,
+ id: invocationId,
});
- if (workerPromise.has(id)) {
- workerPromise.delete(id);
+ if (workerPromise.has(invocationId)) {
+ workerPromise.delete(invocationId);
reject(
new DOMException("The operation was aborted", "AbortError")
);
@@ -214,7 +259,7 @@ export default function createWorkerProxy(id, env = "dev") {
worker.postMessage(
{
type: "react-server:worker",
- id,
+ id: invocationId,
fn,
args: argsStream,
},
diff --git a/test/__test__/apps/chakra-ui.spec.mjs b/test/__test__/apps/chakra-ui.spec.mjs
index f80050d7..8a6e66f9 100644
--- a/test/__test__/apps/chakra-ui.spec.mjs
+++ b/test/__test__/apps/chakra-ui.spec.mjs
@@ -1,12 +1,14 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/chakra-ui"));
-
test("chakra-ui load", async () => {
- await server(null);
+ await server(null, { cwd: appDir("examples/chakra-ui") });
await page.goto(hostname);
await page.waitForLoadState("networkidle");
diff --git a/test/__test__/apps/docs.spec.mjs b/test/__test__/apps/docs.spec.mjs
index 2bc52704..c354061d 100644
--- a/test/__test__/apps/docs.spec.mjs
+++ b/test/__test__/apps/docs.spec.mjs
@@ -1,14 +1,16 @@
-import { join } from "node:path";
-
-import { hostname, page, server } from "playground/utils";
+import { appDir, hostname, page, server } from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../docs"));
-
-test("docs load", async () => {
- await server(null);
- await page.goto(hostname);
- await page.waitForLoadState("networkidle");
+test(
+ "docs load",
+ {
+ timeout: 120000,
+ },
+ async () => {
+ await server(null, { timeout: 120000, cwd: appDir("docs") });
+ await page.goto(hostname);
+ await page.waitForLoadState("networkidle");
- expect(await page.textContent("body")).toContain("react-server");
-});
+ expect(await page.textContent("body")).toContain("react-server");
+ }
+);
diff --git a/test/__test__/apps/env.spec.mjs b/test/__test__/apps/env.spec.mjs
index b8dad299..aeb2898e 100644
--- a/test/__test__/apps/env.spec.mjs
+++ b/test/__test__/apps/env.spec.mjs
@@ -1,13 +1,10 @@
-import { join } from "node:path";
-
-import { hostname, page, server } from "playground/utils";
+import { appDir, hostname, page, server } from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/env"));
process.env.REACT_SERVER_VALUE = "1";
test("env load", async () => {
- await server("./App.jsx");
+ await server("./App.jsx", { cwd: appDir("examples/env") });
await page.goto(hostname);
const bodyText = await page.textContent("body");
diff --git a/test/__test__/apps/express.spec.mjs b/test/__test__/apps/express.spec.mjs
index 5eec23db..3b67fc07 100644
--- a/test/__test__/apps/express.spec.mjs
+++ b/test/__test__/apps/express.spec.mjs
@@ -1,6 +1,5 @@
-import { join } from "node:path";
-
import {
+ appDir,
hostname,
page,
server,
@@ -9,10 +8,11 @@ import {
} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/express"));
-
test("express load", async () => {
- await server("./src/app/index.jsx", undefined, "/react-server/");
+ await server("./src/app/index.jsx", {
+ base: "/react-server/",
+ cwd: appDir("examples/express"),
+ });
await page.goto(hostname + "/react-server/");
await waitForHydration();
expect(await page.textContent("body")).toContain("Hello World!");
diff --git a/test/__test__/apps/file-router.spec.mjs b/test/__test__/apps/file-router.spec.mjs
index 6b29caf1..df5f1dfb 100644
--- a/test/__test__/apps/file-router.spec.mjs
+++ b/test/__test__/apps/file-router.spec.mjs
@@ -1,6 +1,5 @@
-import { join } from "node:path";
-
import {
+ appDir,
hostname,
page,
server,
@@ -11,10 +10,8 @@ import {
import { beforeAll } from "vitest";
import { describe, expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/file-router"));
-
beforeAll(async () => {
- await server(null);
+ await server(null, { cwd: appDir("examples/file-router") });
await page.route("https://react-server.dev/**", (route) => {
route.fulfill({
status: 200,
diff --git a/test/__test__/apps/mantine.spec.mjs b/test/__test__/apps/mantine.spec.mjs
index 34f0c77d..5842d142 100644
--- a/test/__test__/apps/mantine.spec.mjs
+++ b/test/__test__/apps/mantine.spec.mjs
@@ -1,6 +1,5 @@
-import { join } from "node:path";
-
import {
+ appDir,
hostname,
nextAnimationFrame,
page,
@@ -11,22 +10,20 @@ import {
import { beforeAll } from "vitest";
import { describe, expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/mantine"));
-
beforeAll(async () => {
- await server(null);
+ await server(null, { timeout: 240000, cwd: appDir("examples/mantine") });
// Workaround for an async dependency optimization issue in development mode
- let res = await page.goto(hostname, { timeout: 60000 });
+ let res = await page.goto(hostname, { timeout: 240000 });
let attempts = 0;
while (res.status() === 500 && attempts < 5) {
- res = await page.goto(hostname, { timeout: 60000 });
+ res = await page.goto(hostname, { timeout: 240000 });
attempts++;
}
if (!res.ok) {
throw new Error("Failed to load page");
}
-});
+}, 240000);
// โโ Home page โโ
diff --git a/test/__test__/apps/module-resolution.spec.mjs b/test/__test__/apps/module-resolution.spec.mjs
index 01888587..4d2c7013 100644
--- a/test/__test__/apps/module-resolution.spec.mjs
+++ b/test/__test__/apps/module-resolution.spec.mjs
@@ -1,14 +1,16 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { describe } from "vitest";
import { beforeAll, expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/module-resolution"));
-
describe("module-resolution example", {}, () => {
beforeAll(async () => {
- await server("./App.jsx");
+ await server("./App.jsx", { cwd: appDir("examples/module-resolution") });
});
test("iron-session loads", async () => {
diff --git a/test/__test__/apps/mui.spec.mjs b/test/__test__/apps/mui.spec.mjs
index 12cbfcb6..e55edbb8 100644
--- a/test/__test__/apps/mui.spec.mjs
+++ b/test/__test__/apps/mui.spec.mjs
@@ -1,12 +1,14 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/mui"));
-
test("mui load", async () => {
- await server(null);
+ await server(null, { cwd: appDir("examples/mui") });
await page.goto(hostname);
await page.waitForLoadState("networkidle");
await page.waitForSelector("text=/Material UI/", { state: "visible" });
diff --git a/test/__test__/apps/photos.spec.mjs b/test/__test__/apps/photos.spec.mjs
index d136d227..b513881c 100644
--- a/test/__test__/apps/photos.spec.mjs
+++ b/test/__test__/apps/photos.spec.mjs
@@ -1,12 +1,8 @@
-import { join } from "node:path";
-
-import { hostname, page, server } from "playground/utils";
+import { appDir, hostname, page, server } from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/photos"));
-
test("photos load", async () => {
- await server(null);
+ await server(null, { cwd: appDir("examples/photos") });
await page.goto(hostname);
expect(await page.textContent("body")).toContain("Photos");
});
diff --git a/test/__test__/apps/pokemon.spec.mjs b/test/__test__/apps/pokemon.spec.mjs
index 199c667e..87c43865 100644
--- a/test/__test__/apps/pokemon.spec.mjs
+++ b/test/__test__/apps/pokemon.spec.mjs
@@ -1,13 +1,10 @@
-import { join } from "node:path";
-
-import { hostname, page, server } from "playground/utils";
+import { appDir, hostname, page, server } from "playground/utils";
import { expect, test } from "vitest";
process.env.POKEMON_LIMIT = "20";
-process.chdir(join(process.cwd(), "../examples/pokemon"));
test("pokemon load", async () => {
- await server(null);
+ await server(null, { cwd: appDir("examples/pokemon") });
await page.goto(hostname);
await page.waitForLoadState("networkidle");
expect(await page.textContent("body")).toContain("Pokรฉmon Catalog");
diff --git a/test/__test__/apps/react-markdown.spec.mjs b/test/__test__/apps/react-markdown.spec.mjs
index 1357ba16..b19ddfda 100644
--- a/test/__test__/apps/react-markdown.spec.mjs
+++ b/test/__test__/apps/react-markdown.spec.mjs
@@ -1,6 +1,5 @@
-import { join } from "node:path";
-
import {
+ appDir,
expectNoErrors,
hostname,
page,
@@ -9,10 +8,8 @@ import {
} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/react-markdown"));
-
test("react-markdown load", async () => {
- await server("./App.jsx");
+ await server("./App.jsx", { cwd: appDir("examples/react-markdown") });
await page.goto(hostname);
await expectNoErrors();
await page.waitForLoadState("networkidle", { timeout: 5000 });
diff --git a/test/__test__/apps/react-modal.spec.mjs b/test/__test__/apps/react-modal.spec.mjs
index 7ecec7a5..c4d7bd64 100644
--- a/test/__test__/apps/react-modal.spec.mjs
+++ b/test/__test__/apps/react-modal.spec.mjs
@@ -1,12 +1,14 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/react-modal"));
-
test("react-modal load", async () => {
- await server("./App.jsx");
+ await server("./App.jsx", { cwd: appDir("examples/react-modal") });
await page.goto(hostname);
await page.waitForLoadState("networkidle");
await waitForHydration();
diff --git a/test/__test__/apps/react-query.spec.mjs b/test/__test__/apps/react-query.spec.mjs
index 98e64edc..cf09400f 100644
--- a/test/__test__/apps/react-query.spec.mjs
+++ b/test/__test__/apps/react-query.spec.mjs
@@ -1,13 +1,15 @@
-import { join } from "node:path";
-
import { expect } from "@playwright/test";
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/react-query"));
-
test("react-query load", async () => {
- await server(null);
+ await server(null, { cwd: appDir("examples/react-query") });
await page.goto(hostname);
await page.waitForLoadState("networkidle");
await waitForHydration();
diff --git a/test/__test__/apps/react-router.spec.mjs b/test/__test__/apps/react-router.spec.mjs
index 11c3106b..1e727820 100644
--- a/test/__test__/apps/react-router.spec.mjs
+++ b/test/__test__/apps/react-router.spec.mjs
@@ -1,12 +1,14 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/react-router"));
-
test("react-router load", async () => {
- await server("./src/index.jsx");
+ await server("./src/index.jsx", { cwd: appDir("examples/react-router") });
await page.goto(hostname);
await page.waitForLoadState("networkidle", { timeout: 5000 });
await waitForHydration();
diff --git a/test/__test__/apps/react-syntax-highlighter.spec.mjs b/test/__test__/apps/react-syntax-highlighter.spec.mjs
index afc6b51f..ea2cda3c 100644
--- a/test/__test__/apps/react-syntax-highlighter.spec.mjs
+++ b/test/__test__/apps/react-syntax-highlighter.spec.mjs
@@ -1,12 +1,16 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/react-syntax-highlighter"));
-
test("react-syntax-highlighter load", async () => {
- await server("./App.jsx");
+ await server("./App.jsx", {
+ cwd: appDir("examples/react-syntax-highlighter"),
+ });
await page.goto(hostname);
await page.waitForLoadState("networkidle");
await waitForHydration();
diff --git a/test/__test__/apps/react-three.spec.mjs b/test/__test__/apps/react-three.spec.mjs
index f7d6b017..05f04db8 100644
--- a/test/__test__/apps/react-three.spec.mjs
+++ b/test/__test__/apps/react-three.spec.mjs
@@ -1,12 +1,14 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/react-three"));
-
test("react-three load", async () => {
- await server("./App.jsx");
+ await server("./App.jsx", { cwd: appDir("examples/react-three") });
await page.goto(hostname);
await page.waitForLoadState("networkidle");
await waitForHydration();
diff --git a/test/__test__/apps/session-cookie.spec.mjs b/test/__test__/apps/session-cookie.spec.mjs
index d22e52c6..cda5d8f8 100644
--- a/test/__test__/apps/session-cookie.spec.mjs
+++ b/test/__test__/apps/session-cookie.spec.mjs
@@ -1,15 +1,20 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/session-cookie"));
-
test("session cookie", async () => {
await server("./App.jsx", {
- resolve: {
- external: ["iron-session"],
+ initialConfig: {
+ resolve: {
+ external: ["iron-session"],
+ },
},
+ cwd: appDir("examples/session-cookie"),
});
await page.goto(hostname);
diff --git a/test/__test__/apps/spa.spec.mjs b/test/__test__/apps/spa.spec.mjs
index 067dcec2..e3e4d6d0 100644
--- a/test/__test__/apps/spa.spec.mjs
+++ b/test/__test__/apps/spa.spec.mjs
@@ -1,12 +1,14 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/spa"));
-
test("single-page application load", async () => {
- await server("./src/index.jsx");
+ await server("./src/index.jsx", { cwd: appDir("examples/spa") });
await page.goto(hostname);
await waitForHydration();
diff --git a/test/__test__/apps/tanstack-router.spec.mjs b/test/__test__/apps/tanstack-router.spec.mjs
index 3479261c..47515f2b 100644
--- a/test/__test__/apps/tanstack-router.spec.mjs
+++ b/test/__test__/apps/tanstack-router.spec.mjs
@@ -1,12 +1,14 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/tanstack-router"));
-
test("tanstack-router load", async () => {
- await server(null);
+ await server(null, { cwd: appDir("examples/tanstack-router") });
await page.goto(hostname);
await page.waitForLoadState("networkidle");
await waitForHydration();
diff --git a/test/__test__/apps/todo.spec.mjs b/test/__test__/apps/todo.spec.mjs
index 0682de01..e650fec3 100644
--- a/test/__test__/apps/todo.spec.mjs
+++ b/test/__test__/apps/todo.spec.mjs
@@ -1,15 +1,11 @@
-import { join } from "node:path";
-
-import { hostname, page, server } from "playground/utils";
+import { appDir, hostname, page, server } from "playground/utils";
import { expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/todo"));
-
// better-sqlite3 is a native Node.js addon incompatible with edge builds
test.skipIf(process.env.EDGE || process.env.EDGE_ENTRY)(
"todo load",
async () => {
- await server("./src/index.tsx");
+ await server("./src/index.tsx", { cwd: appDir("examples/todo") });
await page.goto(hostname);
expect(await page.textContent("body")).toContain("Todo");
}
diff --git a/test/__test__/apps/typed-file-router.spec.mjs b/test/__test__/apps/typed-file-router.spec.mjs
index 3fa8c17b..9d477557 100644
--- a/test/__test__/apps/typed-file-router.spec.mjs
+++ b/test/__test__/apps/typed-file-router.spec.mjs
@@ -1,6 +1,5 @@
-import { join } from "node:path";
-
import {
+ appDir,
hostname,
page,
server,
@@ -10,10 +9,8 @@ import {
import { beforeAll } from "vitest";
import { describe, expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/typed-file-router"));
-
beforeAll(async () => {
- await server(null);
+ await server(null, { cwd: appDir("examples/typed-file-router") });
});
// โโ Basic route rendering โโ
diff --git a/test/__test__/apps/typed-router.spec.mjs b/test/__test__/apps/typed-router.spec.mjs
index fd90589e..0bddb4d7 100644
--- a/test/__test__/apps/typed-router.spec.mjs
+++ b/test/__test__/apps/typed-router.spec.mjs
@@ -1,6 +1,5 @@
-import { join } from "node:path";
-
import {
+ appDir,
hostname,
page,
server,
@@ -10,10 +9,8 @@ import {
import { beforeAll } from "vitest";
import { describe, expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/typed-router"));
-
beforeAll(async () => {
- await server("./App.tsx");
+ await server("./App.tsx", { cwd: appDir("examples/typed-router") });
});
// โโ Basic route rendering โโ
@@ -187,8 +184,10 @@ describe("typed-router โ client-side navigation", () => {
await waitForHydration();
const prevUrl = page.url();
+ const prevBody = await page.textContent("body");
await page.click('nav a:has-text("User 42")');
await waitForChange(null, () => page.url(), prevUrl);
+ await waitForChange(null, () => page.textContent("body"), prevBody);
expect(page.url()).toContain("/user/42");
expect(await page.textContent("body")).toContain("User ID:");
@@ -787,8 +786,9 @@ describe("typed-router โ browser history", () => {
expect(page.url()).toContain("/about");
// Go back
+ const aboutUrl = page.url();
await page.goBack();
- await page.waitForLoadState("load");
+ await waitForChange(null, () => page.url(), aboutUrl);
expect(page.url()).toBe(`${hostname}/`);
expect(await page.textContent("body")).toContain("Home");
});
diff --git a/test/__test__/apps/use-worker.spec.mjs b/test/__test__/apps/use-worker.spec.mjs
index f8e1ccc4..10bbed87 100644
--- a/test/__test__/apps/use-worker.spec.mjs
+++ b/test/__test__/apps/use-worker.spec.mjs
@@ -1,13 +1,15 @@
-import { join } from "node:path";
-
-import { hostname, page, server, waitForHydration } from "playground/utils";
+import {
+ appDir,
+ hostname,
+ page,
+ server,
+ waitForHydration,
+} from "playground/utils";
import { beforeAll } from "vitest";
import { describe, expect, test } from "vitest";
-process.chdir(join(process.cwd(), "../examples/use-worker"));
-
beforeAll(async () => {
- await server("./App.jsx");
+ await server("./App.jsx", { cwd: appDir("examples/use-worker") });
});
describe("use worker", () => {
diff --git a/test/__test__/basic.spec.mjs b/test/__test__/basic.spec.mjs
index b0b8d128..dbc56eab 100644
--- a/test/__test__/basic.spec.mjs
+++ b/test/__test__/basic.spec.mjs
@@ -148,7 +148,7 @@ test("style assets", async () => {
});
test("style assets with base url", async () => {
- await server("fixtures/styles.jsx", undefined, "/react-server/");
+ await server("fixtures/styles.jsx", { base: "/react-server/" });
await page.goto(hostname + "/react-server");
const h1 = await page.getByText("This text should be yellow");
await expect
diff --git a/test/__test__/scroll-restoration.spec.mjs b/test/__test__/scroll-restoration.spec.mjs
index e1bd9ab8..72f23553 100644
--- a/test/__test__/scroll-restoration.spec.mjs
+++ b/test/__test__/scroll-restoration.spec.mjs
@@ -54,7 +54,7 @@ async function waitForPageTitle(expected, timeout = 5000) {
test("scroll restoration: forward navigation scrolls to top", async () => {
await server("fixtures/scroll-restoration.jsx", {
- scrollRestoration: true,
+ initialConfig: { scrollRestoration: true },
});
await page.goto(hostname);
await waitForHydration();
@@ -73,7 +73,7 @@ test("scroll restoration: forward navigation scrolls to top", async () => {
test("scroll restoration: back navigation restores scroll position", async () => {
await server("fixtures/scroll-restoration.jsx", {
- scrollRestoration: true,
+ initialConfig: { scrollRestoration: true },
});
await page.goto(hostname);
await waitForHydration();
@@ -98,7 +98,7 @@ test("scroll restoration: back navigation restores scroll position", async () =>
test("scroll restoration: multiple back/forward preserves positions", async () => {
await server("fixtures/scroll-restoration.jsx", {
- scrollRestoration: true,
+ initialConfig: { scrollRestoration: true },
});
await page.goto(hostname);
await waitForHydration();
@@ -133,13 +133,13 @@ test("scroll restoration: multiple back/forward preserves positions", async () =
// Go forward to Page A โ should restore scroll
await page.goForward();
await waitForPageTitle("Page A");
- const forwardPageAY = await waitForScrollY(pageAY, 50);
+ const forwardPageAY = await waitForScrollY(pageAY, 50, 15000);
expect(forwardPageAY).toBeGreaterThan(1100);
});
test("scroll restoration: query-param-only change preserves scroll", async () => {
await server("fixtures/scroll-restoration.jsx", {
- scrollRestoration: true,
+ initialConfig: { scrollRestoration: true },
});
await page.goto(hostname + "/page-c?filter=1");
await waitForHydration();
@@ -160,7 +160,7 @@ test("scroll restoration: query-param-only change preserves scroll", async () =>
test("scroll restoration: hash navigation scrolls to anchor", async () => {
await server("fixtures/scroll-restoration.jsx", {
- scrollRestoration: true,
+ initialConfig: { scrollRestoration: true },
});
await page.goto(hostname);
await waitForHydration();
@@ -178,7 +178,7 @@ test("scroll restoration: hash navigation scrolls to anchor", async () => {
test("scroll restoration: useScrollPosition handler can skip scrolling", async () => {
await server("fixtures/scroll-restoration.jsx", {
- scrollRestoration: true,
+ initialConfig: { scrollRestoration: true },
});
await page.goto(hostname);
await waitForHydration();
@@ -200,7 +200,7 @@ test("scroll restoration: useScrollPosition handler can skip scrolling", async (
test("scroll restoration: scroll container position is saved and restored", async () => {
await server("fixtures/scroll-restoration.jsx", {
- scrollRestoration: true,
+ initialConfig: { scrollRestoration: true },
});
await page.goto(hostname + "/page-e");
await waitForHydration();
@@ -229,7 +229,7 @@ test("scroll restoration: scroll container position is saved and restored", asyn
test("scroll restoration: browser history.scrollRestoration is set to manual", async () => {
await server("fixtures/scroll-restoration.jsx", {
- scrollRestoration: true,
+ initialConfig: { scrollRestoration: true },
});
await page.goto(hostname);
await waitForHydration();
@@ -242,7 +242,7 @@ test("scroll restoration: browser history.scrollRestoration is set to manual", a
test("scroll restoration: page refresh restores scroll position", async () => {
await server("fixtures/scroll-restoration.jsx", {
- scrollRestoration: true,
+ initialConfig: { scrollRestoration: true },
});
await page.goto(hostname);
await waitForHydration();
diff --git a/test/build-worker.mjs b/test/build-worker.mjs
new file mode 100644
index 00000000..c4407588
--- /dev/null
+++ b/test/build-worker.mjs
@@ -0,0 +1,25 @@
+// Build worker โ runs react-server build in an isolated child process.
+// This prevents Rolldown's native stdout writes from blocking the
+// test process event loop when the OS pipe buffer fills up.
+
+const root = process.env.BUILD_ROOT || undefined;
+const options = JSON.parse(process.env.BUILD_OPTIONS);
+
+try {
+ const { build } = await import("@lazarv/react-server/build");
+ const result = await build(root || undefined, {
+ ...options,
+ silent:
+ typeof process.env.REACT_SERVER_VERBOSE === "undefined" ||
+ typeof process.env.REACT_SERVER_BUILD_SILENT !== "undefined",
+ });
+ if (result === 1) {
+ process.send({ type: "error", error: "Build failed" });
+ process.exit(1);
+ }
+ process.send({ type: "done" });
+ process.exit(0);
+} catch (e) {
+ process.send({ type: "error", error: e.stack || e.message || String(e) });
+ process.exit(1);
+}
diff --git a/test/server.edge.mjs b/test/server.edge.mjs
index 9370b025..3da709fa 100644
--- a/test/server.edge.mjs
+++ b/test/server.edge.mjs
@@ -2,15 +2,32 @@ import { createServer } from "node:http";
import { createReadStream, existsSync, statSync } from "node:fs";
import { extname, join } from "node:path";
import { pathToFileURL } from "node:url";
-import { parentPort, workerData } from "node:worker_threads";
-const originalConsoleLog = console.log;
-console.log = (...args) => {
- try {
- parentPort.postMessage({ console: args });
- } catch {
- originalConsoleLog("Failed to send log to parent port:", ...args);
+const workerData = JSON.parse(process.env.WORKER_DATA);
+
+process.on("error", (e) => {
+ if (e.code === "ERR_IPC_CHANNEL_CLOSED") return;
+ throw e;
+});
+
+let _httpServer;
+process.on("disconnect", () => {
+ if (_httpServer) {
+ _httpServer.closeAllConnections();
+ _httpServer.close();
}
+});
+
+function safeSend(msg) {
+ if (process.connected) {
+ try {
+ process.send(msg);
+ } catch {}
+ }
+}
+
+console.log = (...args) => {
+ safeSend({ console: args });
};
const MIME_TYPES = {
@@ -101,7 +118,7 @@ try {
return false;
}
- const httpServer = createServer(async (req, res) => {
+ _httpServer = createServer(async (req, res) => {
try {
let url = req.url;
if (workerData.base !== "/" && url.startsWith(workerData.base)) {
@@ -111,10 +128,12 @@ try {
// Try to serve static files first (CSS, JS, images, etc.)
// The edge handler only handles SSR/RSC; in production a CDN serves static files
if (req.method === "GET" || req.method === "HEAD") {
- if (tryServeStatic(url, res)) return;
+ if (tryServeStatic(url, res)) {
+ return;
+ }
}
- const origin = `http://localhost:${workerData.port}`;
+ const origin = process.env.ORIGIN;
const fullUrl = new URL(url, origin);
// Convert Node.js IncomingMessage headers to Web Headers
@@ -162,7 +181,6 @@ try {
res.end();
} catch (e) {
- originalConsoleLog("Edge server error:", e);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "text/plain" });
}
@@ -170,23 +188,24 @@ try {
}
});
- httpServer.once("listening", () => {
- process.env.ORIGIN = `http://localhost:${workerData.port}`;
- parentPort.postMessage({ port: workerData.port });
+ _httpServer.once("listening", () => {
+ const actualPort = _httpServer.address().port;
+ process.env.ORIGIN = `http://localhost:${actualPort}`;
+ safeSend({ port: actualPort });
});
- httpServer.on("error", (e) => {
- parentPort.postMessage({ error: e.message, stack: e.stack });
+ _httpServer.on("error", (e) => {
+ safeSend({ error: e.message, stack: e.stack });
});
- parentPort.on("message", (msg) => {
+ process.on("message", (msg) => {
if (msg?.type === "shutdown") {
- httpServer.closeAllConnections();
- httpServer.close(() => {
- parentPort.close();
+ _httpServer.closeAllConnections();
+ _httpServer.close(() => {
+ process.disconnect();
});
}
});
- httpServer.listen(workerData.port);
+ _httpServer.listen(0);
} catch (e) {
- parentPort.postMessage({ error: e.message, stack: e.stack });
+ safeSend({ error: e.message, stack: e.stack });
throw e;
}
diff --git a/test/server.mjs b/test/server.mjs
index 37a7a9e8..e6612694 100644
--- a/test/server.mjs
+++ b/test/server.mjs
@@ -1,16 +1,43 @@
import { createServer } from "node:http";
-import { parentPort, workerData } from "node:worker_threads";
-const originalConsoleLog = console.log;
-console.log = (...args) => {
- try {
- parentPort.postMessage({ console: args });
- } catch {
- originalConsoleLog("Failed to send log to parent port:", ...args);
+// Suppress IPC channel closed errors during teardown.
+// When the parent kills this child process (e.g. Ctrl+C), the IPC channel
+// closes but async callbacks (listening, server actions) may still fire
+// and attempt process.send(). Node emits an unhandled 'error' event on
+// process when send() fails โ without this handler, the process crashes.
+process.on("error", (e) => {
+ if (e.code === "ERR_IPC_CHANNEL_CLOSED") return;
+ throw e;
+});
+
+// Self-terminate when parent dies. With child processes (unlike Worker
+// threads), the child survives if the parent exits. Monitoring the IPC
+// channel is the most reliable signal โ it fires even on SIGKILL of the parent.
+// We close the HTTP server and let the process exit naturally when no handles
+// remain โ process.exit() and SIGTERM both race with libuv handle teardown
+// and cause native assertion failures / access violations on Windows.
+let _httpServer;
+process.on("disconnect", () => {
+ if (_httpServer) {
+ _httpServer.closeAllConnections();
+ _httpServer.close();
+ }
+});
+
+function safeSend(msg) {
+ if (process.connected) {
+ try {
+ process.send(msg);
+ } catch {}
}
+}
+
+console.log = (...args) => {
+ safeSend({ console: args });
};
-export function createReactServer(reactServer, useRoot = false) {
+export async function createReactServer(reactServer, useRoot = false) {
+ const workerData = JSON.parse(process.env.WORKER_DATA);
try {
const params = [
workerData.options,
@@ -26,33 +53,33 @@ export function createReactServer(reactServer, useRoot = false) {
if (useRoot) {
params.unshift(workerData.root);
}
- const server = reactServer(...params);
+ const { middlewares } = await reactServer(...params);
- const httpServer = createServer(async (req, res) => {
- const { middlewares } = await server;
+ _httpServer = createServer((req, res) => {
if (workerData.base !== "/" && req.url.startsWith(workerData.base)) {
req.url = req.url.slice(workerData.base.length - 1) || "/";
}
middlewares(req, res);
});
- httpServer.once("listening", () => {
- process.env.ORIGIN = `http://localhost:${workerData.port}`;
- parentPort.postMessage({ port: workerData.port });
+ _httpServer.once("listening", () => {
+ const actualPort = _httpServer.address().port;
+ process.env.ORIGIN = `http://localhost:${actualPort}`;
+ safeSend({ port: actualPort });
});
- httpServer.on("error", (e) => {
- parentPort.postMessage({ error: e.message, stack: e.stack });
+ _httpServer.on("error", (e) => {
+ safeSend({ error: e.message, stack: e.stack });
});
- parentPort.on("message", (msg) => {
+ process.on("message", (msg) => {
if (msg?.type === "shutdown") {
- httpServer.closeAllConnections();
- httpServer.close(() => {
- parentPort.close();
+ _httpServer.closeAllConnections();
+ _httpServer.close(() => {
+ process.disconnect();
});
}
});
- httpServer.listen(workerData.port);
+ _httpServer.listen(0);
} catch (e) {
- parentPort.postMessage({ error: e.message, stack: e.stack });
+ safeSend({ error: e.message, stack: e.stack });
throw e;
}
}
diff --git a/test/take-screenshots.mjs b/test/take-screenshots.mjs
new file mode 100644
index 00000000..a9a58057
--- /dev/null
+++ b/test/take-screenshots.mjs
@@ -0,0 +1,413 @@
+/**
+ * Playwright script to capture DevTools screenshots for docs.
+ *
+ * Prerequisites:
+ * 1. Start the docs dev server with DevTools enabled:
+ * cd docs && pnpm dev --devtools
+ * 2. Run this script:
+ * node test/take-screenshots.mjs [port] [screenshot-name]
+ *
+ * Each screenshot is saved in both light and dark variants as WebP
+ * into docs/public/. Uses cwebp for PNGโWebP conversion.
+ */
+
+import { chromium } from "playwright-chromium";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { execFileSync } from "node:child_process";
+import { writeFileSync, unlinkSync } from "node:fs";
+import { tmpdir } from "node:os";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const PUBLIC = join(__dirname, "..", "docs", "public");
+
+const PORT = parseInt(process.argv[2] || "3000", 10);
+const BASE = `http://localhost:${PORT}`;
+
+// Viewport sized for docs screenshots โ wide enough to show devtools comfortably
+const VIEWPORT = { width: 1280, height: 800 };
+
+// DevTools localStorage key and desired state
+const DEVTOOLS_KEY = "__react_server_devtools__";
+const DEVTOOLS_SESSION_KEY = "__react_server_devtools_session__";
+
+/**
+ * Save a Playwright PNG buffer as WebP using cwebp.
+ * Quality 90 gives near-lossless results at ~30-40% of PNG size.
+ */
+function saveAsWebP(pngBuffer, outputPath) {
+ const tmpPng = join(tmpdir(), `screenshot-${Date.now()}.png`);
+ try {
+ writeFileSync(tmpPng, pngBuffer);
+ execFileSync("cwebp", ["-q", "90", "-m", "6", tmpPng, "-o", outputPath], {
+ stdio: "pipe",
+ });
+ } finally {
+ try {
+ unlinkSync(tmpPng);
+ } catch {}
+ }
+}
+
+/**
+ * Set the devtools state via localStorage + sessionStorage, then reload.
+ */
+async function setDevToolsState(
+ page,
+ {
+ open = true,
+ dockMode = "bottom",
+ panelHeight = 350,
+ panelWidth = 450,
+ activeTab = "status",
+ floatRect = null,
+ }
+) {
+ await page.evaluate(
+ ({ key, sessionKey, state, session }) => {
+ localStorage.setItem(key, JSON.stringify(state));
+ sessionStorage.setItem(sessionKey, JSON.stringify(session));
+ },
+ {
+ key: DEVTOOLS_KEY,
+ sessionKey: DEVTOOLS_SESSION_KEY,
+ state: {
+ open,
+ dockMode,
+ panelHeight,
+ panelWidth,
+ floatRect: floatRect ?? { x: 200, y: 120, width: 820, height: 480 },
+ },
+ session: { activeTab },
+ }
+ );
+}
+
+/**
+ * Wait for the devtools panel iframe to be loaded and rendered.
+ */
+async function waitForDevTools(page) {
+ // Wait for the iframe element to appear
+ await page.waitForSelector('iframe[src*="__react_server_devtools__"]', {
+ timeout: 10000,
+ });
+ // Give the iframe content time to hydrate and receive data via Socket.IO
+ await page.waitForTimeout(3000);
+}
+
+/**
+ * Set theme on the host page and notify the devtools iframe.
+ */
+async function setDarkMode(page, dark) {
+ await page.evaluate((isDark) => {
+ if (isDark) {
+ document.documentElement.classList.remove("light");
+ document.documentElement.classList.add("dark");
+ document.cookie = "dark=1;path=/";
+ } else {
+ document.documentElement.classList.remove("dark");
+ document.documentElement.classList.add("light");
+ document.cookie = "dark=0;path=/";
+ }
+ }, dark);
+ // Notify the devtools iframe about the theme change
+ await page.evaluate((isDark) => {
+ const iframe = document.querySelector(
+ 'iframe[src*="__react_server_devtools__"]'
+ );
+ if (iframe?.contentWindow) {
+ iframe.contentWindow.postMessage(
+ { type: "devtools:theme", dark: isDark },
+ "*"
+ );
+ }
+ }, dark);
+ // Wait for theme transition to settle
+ await page.waitForTimeout(800);
+}
+
+/**
+ * Take a screenshot in both light and dark mode, saving as WebP.
+ * If `panelOnly` is true, screenshots only the devtools panel element.
+ */
+async function screenshotBothThemes(
+ page,
+ baseName,
+ { panelOnly = false } = {}
+) {
+ const shotOpts = { type: "png" };
+
+ // If panelOnly, find the devtools panel element (the fixed-position ancestor of the iframe)
+ let target = page;
+ if (panelOnly) {
+ // The panel is the outermost fixed-position div containing the devtools iframe.
+ // We locate it by going up from the iframe.
+ target = page
+ .locator('iframe[src*="__react_server_devtools__"]')
+ .locator("xpath=ancestor::div[@style]")
+ .last();
+ }
+
+ // Light mode
+ await setDarkMode(page, false);
+ const lightBuf = await target.screenshot(shotOpts);
+ saveAsWebP(lightBuf, join(PUBLIC, `${baseName}-light.webp`));
+ console.log(` โ ${baseName}-light.webp`);
+
+ // Dark mode
+ await setDarkMode(page, true);
+ const darkBuf = await target.screenshot(shotOpts);
+ saveAsWebP(darkBuf, join(PUBLIC, `${baseName}-dark.webp`));
+ console.log(` โ ${baseName}-dark.webp`);
+}
+
+/**
+ * Navigate to a URL and set up devtools with a specific tab.
+ */
+async function setupPage(
+ page,
+ url,
+ {
+ dockMode = "bottom",
+ panelHeight = 350,
+ panelWidth = 450,
+ activeTab = "status",
+ floatRect = null,
+ } = {}
+) {
+ // Navigate first so we have a page context for localStorage
+ await page.goto(url, { waitUntil: "load" });
+ await page.waitForTimeout(1000);
+
+ // Set devtools state and reload so it opens with the right tab
+ await setDevToolsState(page, {
+ open: true,
+ dockMode,
+ panelHeight,
+ panelWidth,
+ activeTab,
+ floatRect,
+ });
+ await page.reload({ waitUntil: "load" });
+ await waitForDevTools(page);
+}
+
+// โโโ Screenshot definitions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+// Panel-only float rect: positioned at origin, sized to fill nicely
+const PANEL_FLOAT = { x: 0, y: 0, width: 960, height: 540 };
+
+const SCREENSHOTS = {
+ "devtools-overview": {
+ url: "/",
+ description: "Overview โ Status tab, bottom dock, docs landing page",
+ dockMode: "bottom",
+ panelHeight: 340,
+ activeTab: "status",
+ },
+ "devtools-float-mode": {
+ url: "/",
+ description: "Float mode โ floating window, draggable/resizable",
+ dockMode: "float",
+ activeTab: "status",
+ },
+ "devtools-status": {
+ url: "/",
+ description: "Status tab โ process, CPU, memory gauges",
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "status",
+ panelOnly: true,
+ },
+ "devtools-payload": {
+ url: "/",
+ description: "Payload tab โ RSC flight payload inspection",
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "payload",
+ panelOnly: true,
+ },
+ "devtools-cache": {
+ url: "/",
+ description: "Cache tab โ use cache hit/miss events (pokemon example)",
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "cache",
+ panelOnly: true,
+ },
+ "devtools-routes": {
+ url: "/",
+ description: "Routes tab โ full route tree from docs file-router",
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "routes",
+ panelOnly: true,
+ },
+ "devtools-outlets": {
+ url: "/features/devtools",
+ description: "Outlets tab โ named outlets from docs layout",
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "outlets",
+ panelOnly: true,
+ },
+ "devtools-remotes": {
+ url: "/",
+ description: "Remotes tab โ remote components (remote example)",
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "remotes",
+ panelOnly: true,
+ },
+ "devtools-live": {
+ url: "/",
+ description: "Live tab โ use live streaming components (remote example)",
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "live",
+ panelOnly: true,
+ },
+ "devtools-workers": {
+ url: "/",
+ description: "Workers tab โ use worker threads (use-worker example)",
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "workers",
+ panelOnly: true,
+ },
+ "devtools-highlighting": {
+ url: "/features/devtools",
+ description: "Element highlighting โ outlet overlay on the docs sidebar",
+ dockMode: "bottom",
+ panelHeight: 340,
+ activeTab: "outlets",
+ // After devtools is set up, trigger the highlight overlay on @sidebar
+ async afterSetup(page) {
+ // Send a postMessage to the host page to trigger the highlight overlay
+ await page.evaluate(() => {
+ window.postMessage(
+ {
+ type: "devtools:highlight",
+ selector: '[data-devtools-outlet="sidebar"]',
+ color: "rgba(99, 102, 241, 0.3)",
+ label: "@sidebar",
+ },
+ "*"
+ );
+ });
+ await page.waitForTimeout(800);
+ },
+ },
+ "devtools-logs": {
+ url: "/",
+ description: "Logs tab โ server stdout/stderr output capture",
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "logs",
+ panelOnly: true,
+ // Generate some page navigations so the Logs tab has visible content.
+ // Logs only appear for requests made while the devtools Socket.IO
+ // connection is active โ so we navigate *after* devtools is open.
+ async afterSetup(page, base) {
+ // Navigate away and back a few times to generate request logs
+ await page.goto(`${base}/`, { waitUntil: "load" });
+ await page.waitForTimeout(500);
+ await page.goto(`${base}/`, { waitUntil: "load" });
+ await page.waitForTimeout(500);
+ await page.goto(`${base}/`, { waitUntil: "load" });
+ await page.waitForTimeout(500);
+ // Now set devtools back to logs tab and wait for it to render
+ await setDevToolsState(page, {
+ open: true,
+ dockMode: "float",
+ floatRect: PANEL_FLOAT,
+ activeTab: "logs",
+ });
+ await page.reload({ waitUntil: "load" });
+ await waitForDevTools(page);
+ // The LogsPanel uses virtual scrolling. On initial mount the
+ // container clientHeight may be 0, so getVisibleRange returns an
+ // empty range and no rows render. Toggling a filter button forces
+ // a React state change โ re-render, which recalculates the range
+ // with the now-correct container dimensions.
+ const dtFrame = page.frameLocator(
+ 'iframe[src*="__react_server_devtools__"]'
+ );
+ // Click "stdout" filter, wait, click "All" to force re-render
+ await dtFrame.locator(".dt-filter-btn", { hasText: "stdout" }).click();
+ await page.waitForTimeout(300);
+ await dtFrame.locator(".dt-filter-btn", { hasText: "All" }).click();
+ await page.waitForTimeout(500);
+ },
+ },
+};
+
+// โโโ Main โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+async function main() {
+ const target = process.argv[3] || "all";
+
+ console.log(`\n๐ธ DevTools screenshot capture`);
+ console.log(` Server: ${BASE}`);
+ console.log(` Output: ${PUBLIC}/\n`);
+
+ // Verify cwebp is available
+ try {
+ execFileSync("cwebp", ["-version"], { stdio: "pipe" });
+ } catch {
+ console.error("โ cwebp not found. Install it: brew install webp\n");
+ process.exit(1);
+ }
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: VIEWPORT,
+ deviceScaleFactor: 2, // Retina-quality screenshots
+ });
+
+ try {
+ const page = await context.newPage();
+
+ // Check server is running
+ try {
+ await page.goto(BASE, { timeout: 5000 });
+ } catch {
+ console.error(
+ `โ Cannot connect to ${BASE}.\n` +
+ ` Start the docs dev server first:\n` +
+ ` cd docs && pnpm dev --devtools\n`
+ );
+ process.exit(1);
+ }
+
+ for (const [name, config] of Object.entries(SCREENSHOTS)) {
+ if (target !== "all" && target !== name) continue;
+
+ console.log(`๐ท ${name}: ${config.description}`);
+ await setupPage(page, `${BASE}${config.url}`, {
+ dockMode: config.dockMode || "bottom",
+ panelHeight: config.panelHeight || 340,
+ panelWidth: config.panelWidth || 450,
+ activeTab: config.activeTab || "status",
+ floatRect: config.floatRect || null,
+ });
+ if (config.afterSetup) {
+ await config.afterSetup(page, BASE);
+ }
+ await screenshotBothThemes(page, name, {
+ panelOnly: config.panelOnly || false,
+ });
+ console.log();
+ }
+
+ console.log("โ
Done!\n");
+ } finally {
+ await context.close();
+ await browser.close();
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/test/utils.mjs b/test/utils.mjs
index c1753bd3..4dce0870 100644
--- a/test/utils.mjs
+++ b/test/utils.mjs
@@ -1,9 +1,22 @@
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
import { expect } from "vitest";
import { logs, page } from "./vitestSetup.mjs";
export * from "./vitestSetup.mjs";
+const __testDir = resolve(fileURLToPath(import.meta.url), "..");
+
+/**
+ * Resolve a path relative to the repo root for use as `cwd` in server().
+ * e.g. appDir("examples/file-router") or appDir("docs")
+ */
+export function appDir(relPath) {
+ return join(__testDir, "..", relPath);
+}
+
export function nextAnimationFrame() {
return page.evaluate(() => new Promise(requestAnimationFrame));
}
diff --git a/test/vitest.config.mjs b/test/vitest.config.mjs
index 1f553c75..41ee29d9 100644
--- a/test/vitest.config.mjs
+++ b/test/vitest.config.mjs
@@ -1,4 +1,4 @@
-import { cpus } from "node:os";
+import { platform } from "node:os";
import { resolve } from "node:path";
import { defineConfig } from "vitest/config";
@@ -34,9 +34,7 @@ export default defineConfig({
["junit", { outputFile: "test-results/junit.xml" }],
]
: [process.env.REACT_SERVER_VERBOSE ? "verbose" : "default"],
- pool: "forks",
- maxForks: process.env.CI ? 1 : Math.max(1, cpus().length - 1),
- fileParallelism: !process.env.CI,
+ pool: platform() === "win32" ? "forks" : "threads",
retry: 3,
},
publicDir: false,
diff --git a/test/vitestSetup.mjs b/test/vitestSetup.mjs
index 33b18fcf..e9978805 100644
--- a/test/vitestSetup.mjs
+++ b/test/vitestSetup.mjs
@@ -1,7 +1,8 @@
import { createHash } from "node:crypto";
+import { fork } from "node:child_process";
import { readdir, rm } from "node:fs/promises";
-import { dirname, join } from "node:path";
-import { Worker } from "node:worker_threads";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
import { chromium } from "playwright-chromium";
import { afterAll, inject, test } from "vitest";
@@ -14,8 +15,20 @@ export let logs;
export let serverLogs;
let currentWorker;
+let currentCwd;
let terminating;
+// Ensure child server processes are killed when the fork exits.
+// Worker threads die with their parent process; child processes don't.
+function killCurrentWorker() {
+ try {
+ currentWorker?.kill();
+ } catch {}
+}
+process.on("exit", killCurrentWorker);
+process.on("SIGTERM", killCurrentWorker);
+process.on("SIGINT", killCurrentWorker);
+
export const testCwd = process.cwd();
const verbose = typeof process.env.REACT_SERVER_VERBOSE !== "undefined";
@@ -40,10 +53,10 @@ let portCounter = 0;
async function cleanup() {
try {
- if (!process.env.CI && testCwd !== process.cwd()) {
+ if (!process.env.CI && currentCwd && currentCwd !== testCwd) {
const files = [
- ...(await readdir(process.cwd(), { withFileTypes: true })),
- ...(await readdir(join(process.cwd(), "node_modules"), {
+ ...(await readdir(currentCwd, { withFileTypes: true })),
+ ...(await readdir(join(currentCwd, "node_modules"), {
withFileTypes: true,
})),
];
@@ -76,15 +89,69 @@ test.beforeAll(async (_context, suite) => {
page.on("console", (msg) => {
logs.push(msg.text());
});
- server = (root, initialConfig, base) =>
+ server = (
+ root,
+ {
+ initialConfig,
+ base,
+ timeout = process.env.CI ? 120000 : 60000,
+ cwd = testCwd,
+ } = {}
+ ) =>
new Promise(async (resolve, reject) => {
+ let settled = false;
+ const settle = (fn) => {
+ if (!settled) {
+ settled = true;
+ fn();
+ }
+ };
+
try {
+ // Kill previous server process before starting a new one.
+ // Unlike Worker threads, child processes survive independently
+ // and keep holding their ports until explicitly killed.
+ if (currentWorker) {
+ terminating = true;
+ await new Promise((res) => {
+ const t = setTimeout(() => {
+ try {
+ currentWorker?.kill("SIGKILL");
+ } catch {}
+ res();
+ }, 5000);
+ currentWorker.once("exit", () => {
+ clearTimeout(t);
+ res();
+ });
+ if (currentWorker.connected) {
+ currentWorker.send({ type: "shutdown" });
+ } else {
+ currentWorker.kill();
+ }
+ });
+ currentWorker = null;
+ }
+
+ // Create a fresh page between server() calls. When the previous
+ // server is killed, any in-flight requests or HMR WebSocket
+ // connections die, which can crash the Chromium renderer. A crashed
+ // page cannot be reused โ all subsequent navigations fail with
+ // "Page crashed". A fresh page avoids cascading failures.
+ try {
+ await page.close();
+ } catch {}
+ page = await browser.newPage();
+ page.on("console", (msg) => {
+ logs.push(msg.text());
+ });
logs = [];
serverLogs = [];
terminating = false;
+ currentCwd = cwd;
const hashValue = createHash("sha256")
.update(
- `${name}-${id}-${portCounter++}-${root?.[0] === "." ? join(process.cwd(), root) : root || process.cwd()}`
+ `${name}-${id}-${portCounter++}-${root?.[0] === "." ? join(cwd, root) : root || cwd}`
)
.digest();
const hash = hashValue.toString("hex");
@@ -111,16 +178,73 @@ test.beforeAll(async (_context, suite) => {
};
if (process.env.NODE_ENV === "production") {
- const { build } = await import("@lazarv/react-server/build");
- await build(
- root?.[0] === "." || !root
- ? root
- : join(process.cwd(), dirname(name), "..", root),
- { ...options, silent: true }
- );
+ const buildTimeout = timeout;
+ const buildRoot = root?.[0] === "." || !root ? root : join(cwd, root);
+ await new Promise((resolveBuild, rejectBuild) => {
+ const timer = setTimeout(() => {
+ buildProcess.kill();
+ rejectBuild(
+ new Error(
+ `Build timed out after ${buildTimeout / 1000}s for ${name}`
+ )
+ );
+ }, buildTimeout);
+
+ const buildProcess = fork(
+ fileURLToPath(new URL("./build-worker.mjs", import.meta.url)),
+ {
+ cwd,
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
+ env: {
+ ...process.env,
+ CI: "true",
+ NODE_ENV: "production",
+ BUILD_ROOT: buildRoot ?? "",
+ BUILD_OPTIONS: JSON.stringify(options),
+ },
+ }
+ );
+ buildProcess.on("message", (msg) => {
+ if (msg.type === "done") {
+ clearTimeout(timer);
+ resolveBuild();
+ } else if (msg.type === "error") {
+ clearTimeout(timer);
+ rejectBuild(new Error(msg.error));
+ }
+ });
+ buildProcess.on("error", (e) => {
+ clearTimeout(timer);
+ rejectBuild(e);
+ });
+ buildProcess.on("exit", (code) => {
+ clearTimeout(timer);
+ if (code !== 0) {
+ rejectBuild(
+ new Error(
+ `Build process exited with code ${code} for ${name}`
+ )
+ );
+ }
+ });
+ });
}
- const worker = new Worker(
+ const serverTimeout = timeout;
+ const serverTimer = setTimeout(() => {
+ settle(() => {
+ terminating = true;
+ currentWorker?.kill();
+ reject(
+ new Error(
+ `Server startup timed out after ${serverTimeout / 1000}s for ${name}`
+ )
+ );
+ });
+ }, serverTimeout);
+ serverTimer.unref();
+
+ const serverScript = fileURLToPath(
new URL(
process.env.NODE_ENV === "production"
? process.env.EDGE_ENTRY
@@ -128,60 +252,75 @@ test.beforeAll(async (_context, suite) => {
: "./server.node.mjs"
: "./server.dev.mjs",
import.meta.url
- ),
- {
- workerData: {
- root:
- root?.[0] === "." || !root
- ? root
- : join(process.cwd(), dirname(name), "..", root),
- options,
- initialConfig:
- process.env.NODE_ENV === "production"
- ? initialConfig
- : {
- server: {
- hmr: {
- port: port + 1,
- },
- },
- ...initialConfig,
- },
- port,
- base,
- },
- }
+ )
);
- // Don't let the worker thread prevent the fork process from exiting
+
+ const serverWorkerData = {
+ root: root?.[0] === "." || !root ? root : join(cwd, root),
+ options,
+ initialConfig:
+ process.env.NODE_ENV === "production"
+ ? initialConfig
+ : {
+ server: {
+ hmr: {
+ port: port + 1,
+ },
+ },
+ ...initialConfig,
+ },
+ port,
+ base,
+ };
+
+ const worker = fork(serverScript, {
+ cwd,
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
+ env: {
+ ...process.env,
+ WORKER_DATA: JSON.stringify(serverWorkerData),
+ },
+ });
worker.unref();
currentWorker = worker;
worker.on("message", (msg) => {
if (msg.port) {
+ clearTimeout(serverTimer);
hostname = `http://localhost:${msg.port}`;
process.env.ORIGIN = hostname;
logs = [];
serverLogs = [];
- resolve();
+ settle(() => resolve());
} else if (msg.console) {
console.log(...msg.console);
} else if (msg.error) {
- terminating = true;
- worker.terminate();
- reject(new Error(msg.error));
+ clearTimeout(serverTimer);
+ settle(() => {
+ terminating = true;
+ worker.kill();
+ reject(new Error(msg.error));
+ });
}
});
worker.on("error", (e) => {
+ clearTimeout(serverTimer);
consoleError(e);
- reject(e);
+ settle(() => reject(e));
});
worker.on("exit", (code) => {
- if (code !== 0 && !terminating) {
- consoleError(new Error(`Worker stopped with exit code ${code}`));
- reject(new Error(`Worker stopped with exit code ${code}`));
+ clearTimeout(serverTimer);
+ if (!terminating) {
+ settle(() => {
+ const err = new Error(
+ `Server process exited with code ${code} before server started for ${name}`
+ );
+ consoleError(err);
+ reject(err);
+ });
}
});
} catch (e) {
- reject(e);
+ settle(() => reject(e));
}
});
});
@@ -189,12 +328,12 @@ test.beforeAll(async (_context, suite) => {
afterAll(async () => {
await page?.close();
await browser?.close();
- if (currentWorker && process.env.NODE_ENV === "production") {
+ if (currentWorker) {
terminating = true;
await new Promise((resolve) => {
const timeout = setTimeout(() => {
try {
- currentWorker?.terminate();
+ currentWorker?.kill("SIGKILL");
} catch {
// ignore
}
@@ -204,7 +343,11 @@ afterAll(async () => {
clearTimeout(timeout);
resolve();
});
- currentWorker.postMessage({ type: "shutdown" });
+ if (currentWorker.connected) {
+ currentWorker.send({ type: "shutdown" });
+ } else {
+ currentWorker.kill();
+ }
});
}
currentWorker = null;