@rangojs/router 0.0.0-experimental.9c87b9aa → 0.0.0-experimental.9c9afef3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,18 +9,18 @@ import fs from "node:fs";
9
9
 
10
10
  // src/vite/plugins/expose-id-utils.ts
11
11
  import path from "node:path";
12
- import crypto from "node:crypto";
12
+ import crypto2 from "node:crypto";
13
13
  function normalizePath(p) {
14
14
  return p.split(path.sep).join("/");
15
15
  }
16
16
  function hashId(filePath, exportName) {
17
17
  const input = `${filePath}#${exportName}`;
18
- const hash = crypto.createHash("sha256").update(input).digest("hex");
18
+ const hash = crypto2.createHash("sha256").update(input).digest("hex");
19
19
  return `${hash.slice(0, 8)}#${exportName}`;
20
20
  }
21
21
  function hashInlineId(filePath, lineNumber, index) {
22
22
  const input = index !== void 0 && index > 0 ? `${filePath}:${lineNumber}:${index}` : `${filePath}:${lineNumber}`;
23
- return crypto.createHash("sha256").update(input).digest("hex").slice(0, 8);
23
+ return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 8);
24
24
  }
25
25
  function buildExportMap(program) {
26
26
  const exportMap = /* @__PURE__ */ new Map();
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.9c87b9aa",
1748
+ version: "0.0.0-experimental.9c9afef3",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
@@ -2712,9 +2712,9 @@ function createVersionPlugin() {
2712
2712
  configResolved(config) {
2713
2713
  isDev = config.command === "serve";
2714
2714
  },
2715
- configureServer(devServer) {
2716
- server = devServer;
2717
- devServer.watcher.on("unlink", (filePath) => {
2715
+ configureServer(devServer2) {
2716
+ server = devServer2;
2717
+ devServer2.watcher.on("unlink", (filePath) => {
2718
2718
  if (!isDev) return;
2719
2719
  if (!clientModuleSignatures.has(filePath)) return;
2720
2720
  clientModuleSignatures.delete(filePath);
@@ -4861,6 +4861,68 @@ ${details}`
4861
4861
  };
4862
4862
  }
4863
4863
 
4864
+ // src/vite/plugins/performance-tracks.ts
4865
+ var DEBUG_ID_HEADER = "X-RSC-Debug-Id";
4866
+ var DEBUG_C2S_EVENT = "rango:perf-c2s";
4867
+ var base64ToBytes = (base64) => new Uint8Array(Buffer.from(base64, "base64"));
4868
+ var devServer = null;
4869
+ var sessions = /* @__PURE__ */ new Map();
4870
+ function performanceTracksPlugin() {
4871
+ return {
4872
+ name: "@rangojs/router:performance-tracks",
4873
+ apply: "serve",
4874
+ configureServer(server) {
4875
+ devServer = server;
4876
+ const hot = server.environments.client.hot;
4877
+ hot.on(DEBUG_C2S_EVENT, (payload) => {
4878
+ const session = sessions.get(payload.i);
4879
+ if ("d" in payload) {
4880
+ if (session?.cmdController) {
4881
+ try {
4882
+ session.cmdController.close();
4883
+ } catch {
4884
+ }
4885
+ delete session.cmdController;
4886
+ }
4887
+ return;
4888
+ }
4889
+ if ("b" in payload) {
4890
+ if (session?.cmdController) {
4891
+ try {
4892
+ session.cmdController.enqueue(base64ToBytes(payload.b));
4893
+ } catch {
4894
+ delete session.cmdController;
4895
+ }
4896
+ }
4897
+ return;
4898
+ }
4899
+ });
4900
+ return () => {
4901
+ server.middlewares.use((req, _res, next) => {
4902
+ const existingId = req.headers[DEBUG_ID_HEADER.toLowerCase()];
4903
+ const isHtml = req.headers.accept?.includes("text/html");
4904
+ if (!existingId && !isHtml) {
4905
+ next();
4906
+ return;
4907
+ }
4908
+ const debugId = existingId || crypto.randomUUID();
4909
+ if (!existingId) {
4910
+ const lowerName = DEBUG_ID_HEADER.toLowerCase();
4911
+ req.headers[lowerName] = debugId;
4912
+ if (req.rawHeaders) {
4913
+ req.rawHeaders.push(DEBUG_ID_HEADER, debugId);
4914
+ }
4915
+ }
4916
+ if (!sessions.has(debugId)) {
4917
+ sessions.set(debugId, { pendingChunks: [], ended: false });
4918
+ }
4919
+ next();
4920
+ });
4921
+ };
4922
+ }
4923
+ };
4924
+ }
4925
+
4864
4926
  // src/vite/rango.ts
4865
4927
  async function rango(options) {
4866
4928
  const resolvedOptions = options ?? { preset: "node" };
@@ -5125,6 +5187,7 @@ ${list}`);
5125
5187
  staticRouteTypesGeneration: resolvedOptions.staticRouteTypesGeneration
5126
5188
  })
5127
5189
  );
5190
+ plugins.push(performanceTracksPlugin());
5128
5191
  return plugins;
5129
5192
  }
5130
5193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.9c87b9aa",
3
+ "version": "0.0.0-experimental.9c9afef3",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -554,7 +554,7 @@ export const ProductLoader = createLoader(async (ctx) => {
554
554
  .first();
555
555
 
556
556
  if (!product) {
557
- throw new Response("Product not found", { status: 404 });
557
+ notFound("Product not found");
558
558
  }
559
559
 
560
560
  return { product };
@@ -84,10 +84,10 @@ interface RSCRouterOptions<TEnv> {
84
84
  // Default error boundary
85
85
  defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler;
86
86
 
87
- // Default not-found boundary
87
+ // Default not-found boundary for notFound() thrown in handlers/loaders
88
88
  defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
89
89
 
90
- // Component for 404 routes
90
+ // Component for 404 (no route match, or notFound() without a boundary)
91
91
  notFound?: ReactNode | ((props: { pathname: string }) => ReactNode);
92
92
 
93
93
  // Error logging callback
@@ -290,6 +290,56 @@ const router = createRouter({
290
290
  export default router;
291
291
  ```
292
292
 
293
+ ## Not Found Handling
294
+
295
+ Two distinct 404 scenarios:
296
+
297
+ **1. No route matches the URL** — the router renders the `notFound` component from `createRouter()` config. This is automatic.
298
+
299
+ **2. A handler/loader calls `notFound()`** — signals that the route matched but the data doesn't exist (e.g., invalid product ID).
300
+
301
+ ```typescript
302
+ import { notFound } from "@rangojs/router";
303
+
304
+ // In a handler or loader
305
+ path("/product/:slug", async (ctx) => {
306
+ const product = await db.getProduct(ctx.params.slug);
307
+ if (!product) notFound("Product not found");
308
+ return <ProductPage product={product} />;
309
+ });
310
+ ```
311
+
312
+ ### Fallback chain for `notFound()`
313
+
314
+ When `notFound()` is thrown, the router looks for a fallback in this order:
315
+
316
+ 1. **`notFoundBoundary()`** — nearest boundary in the route tree (route-level)
317
+ 2. **`defaultNotFoundBoundary`** — from `createRouter()` config (app-level)
318
+ 3. **`notFound`** — from `createRouter()` config (same component used for no-route-match)
319
+ 4. **Default `<h1>Not Found</h1>`** — built-in fallback
320
+
321
+ All cases set HTTP 404 status.
322
+
323
+ ### notFoundBoundary
324
+
325
+ Wrap routes with `notFoundBoundary()` for route-specific not-found UI:
326
+
327
+ ```typescript
328
+ urls(({ path, layout }) => [
329
+ layout(ShopLayout, () => [
330
+ notFoundBoundary(({ notFound: info }) => (
331
+ <div>
332
+ <h1>Not Found</h1>
333
+ <p>{info.message}</p>
334
+ </div>
335
+ )),
336
+ path("/product/:slug", ProductPage),
337
+ ]),
338
+ ]);
339
+ ```
340
+
341
+ `notFoundBoundary` receives `{ notFound: NotFoundInfo }` where `NotFoundInfo` contains `message`, `segmentId`, `segmentType`, and `pathname`.
342
+
293
343
  ## Including Sub-patterns
294
344
 
295
345
  ```typescript
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Client-side debug channel for React Performance Tracks.
3
+ *
4
+ * Creates a bidirectional channel that communicates with the server-side
5
+ * debug channel via Vite's HMR WebSocket. Used with createFromFetch()
6
+ * so Chrome DevTools can display Server Components in the Performance tab.
7
+ *
8
+ * Dev-only — gated behind import.meta.hot.
9
+ */
10
+
11
+ export const DEBUG_ID_HEADER = "X-RSC-Debug-Id";
12
+ const DEBUG_S2C_EVENT = "rango:perf-s2c";
13
+ const DEBUG_C2S_EVENT = "rango:perf-c2s";
14
+
15
+ type DebugPayload =
16
+ | { i: string; b: string } // chunk (base64)
17
+ | { i: string; d: true }; // done
18
+
19
+ const bytesToBase64 = (bytes: Uint8Array) => {
20
+ let binary = "";
21
+ for (let i = 0; i < bytes.length; i++) {
22
+ binary += String.fromCharCode(bytes[i]!);
23
+ }
24
+ return btoa(binary);
25
+ };
26
+
27
+ const base64ToBytes = (base64: string) =>
28
+ Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
29
+
30
+ /**
31
+ * Create a client-side debug channel for the given debugId.
32
+ * The channel communicates with the server via Vite's HMR WebSocket.
33
+ */
34
+ export function createClientDebugChannel(debugId: string): {
35
+ readable: ReadableStream<Uint8Array>;
36
+ writable: WritableStream<Uint8Array>;
37
+ } | null {
38
+ const hot = (import.meta as any).hot;
39
+ if (!hot) return null;
40
+
41
+ let closed = false;
42
+ let onServerData: ((payload: DebugPayload) => void) | undefined;
43
+
44
+ const cleanup = (notify?: boolean) => {
45
+ if (closed) return;
46
+ closed = true;
47
+ if (onServerData) {
48
+ hot.off(DEBUG_S2C_EVENT, onServerData);
49
+ }
50
+ if (notify) {
51
+ hot.send(DEBUG_C2S_EVENT, { i: debugId, d: true } satisfies DebugPayload);
52
+ }
53
+ };
54
+
55
+ // Readable: receives server-to-client debug data via HMR WS
56
+ const readable = new ReadableStream<Uint8Array>({
57
+ start(controller) {
58
+ onServerData = (payload: DebugPayload) => {
59
+ if (closed || payload.i !== debugId) return;
60
+ if ("b" in payload) {
61
+ controller.enqueue(base64ToBytes(payload.b));
62
+ }
63
+ if ("d" in payload) {
64
+ cleanup();
65
+ controller.close();
66
+ }
67
+ };
68
+ hot.on(DEBUG_S2C_EVENT, onServerData);
69
+ },
70
+ cancel() {
71
+ cleanup(true);
72
+ },
73
+ });
74
+
75
+ // Writable: sends client-to-server commands via HMR WS
76
+ const writable = new WritableStream<Uint8Array>({
77
+ write(chunk) {
78
+ if (closed) throw new TypeError("Channel is closed");
79
+ hot.send(DEBUG_C2S_EVENT, {
80
+ i: debugId,
81
+ b: bytesToBase64(chunk),
82
+ } satisfies DebugPayload);
83
+ },
84
+ close() {
85
+ cleanup(true);
86
+ },
87
+ abort() {
88
+ cleanup(true);
89
+ },
90
+ });
91
+
92
+ return { readable, writable };
93
+ }
@@ -12,6 +12,7 @@ import {
12
12
  startBrowserTransaction,
13
13
  } from "./logging.js";
14
14
  import { getRangoState } from "./rango-state.js";
15
+ import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
15
16
  import {
16
17
  extractRscHeaderUrl,
17
18
  emptyResponse,
@@ -107,6 +108,14 @@ export function createNavigationClient(
107
108
  resolveStreamComplete = resolve;
108
109
  });
109
110
 
111
+ // Dev-only: create debug channel for React Performance Tracks
112
+ const debugId = (import.meta as any).hot
113
+ ? crypto.randomUUID()
114
+ : undefined;
115
+ const debugChannel = debugId
116
+ ? createClientDebugChannel(debugId)
117
+ : undefined;
118
+
110
119
  /** Start a fresh navigation fetch (no cache / inflight hit). */
111
120
  const doFreshFetch = (): Promise<Response> => {
112
121
  if (tx) {
@@ -124,6 +133,7 @@ export function createNavigationClient(
124
133
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
125
134
  }),
126
135
  ...(hmr && { "X-RSC-HMR": "1" }),
136
+ ...(debugId && { [DEBUG_ID_HEADER]: debugId }),
127
137
  },
128
138
  signal,
129
139
  }).then((response) => {
@@ -220,7 +230,12 @@ export function createNavigationClient(
220
230
 
221
231
  try {
222
232
  // Deserialize RSC payload
223
- const payload = await deps.createFromFetch<RscPayload>(responsePromise);
233
+ const payload = await deps.createFromFetch<RscPayload>(
234
+ responsePromise,
235
+ {
236
+ ...(debugChannel && { debugChannel }),
237
+ },
238
+ );
224
239
  if (tx) {
225
240
  browserDebugLog(tx, "response received", {
226
241
  isPartial: payload.metadata?.isPartial,
@@ -289,8 +289,10 @@ export function NavigationProvider({
289
289
  };
290
290
  }, [warmupEnabled]);
291
291
 
292
- // Cancel speculative prefetches when navigation starts.
293
- // Viewport/render prefetches should not compete with navigation fetches.
292
+ // Cancel non-matching prefetches when navigation starts.
293
+ // Frees connections so the navigation fetch isn't competing with
294
+ // speculative prefetches. The prefetch matching the navigation target
295
+ // is kept alive so it can be reused via consumeInflightPrefetch.
294
296
  useEffect(() => {
295
297
  let wasIdle = true;
296
298
  const unsub = eventController.subscribe(() => {
@@ -4,6 +4,7 @@ import type {
4
4
  RscPayload,
5
5
  } from "./types.js";
6
6
  import { createPartialUpdater } from "./partial-update.js";
7
+ import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
7
8
  import { createNavigationTransaction } from "./navigation-transaction.js";
8
9
  import {
9
10
  reconcileSegments,
@@ -199,6 +200,14 @@ export function createServerActionBridge(
199
200
  const onHandleAbort = () => fetchAbort.abort();
200
201
  handle.signal.addEventListener("abort", onHandleAbort, { once: true });
201
202
 
203
+ // Dev-only: create debug channel for React Performance Tracks
204
+ const debugId = (import.meta as any).hot
205
+ ? crypto.randomUUID()
206
+ : undefined;
207
+ const debugChannel = debugId
208
+ ? createClientDebugChannel(debugId)
209
+ : undefined;
210
+
202
211
  // Send action request with stream tracking
203
212
  const responsePromise = fetch(url, {
204
213
  method: "POST",
@@ -210,6 +219,7 @@ export function createServerActionBridge(
210
219
  ...(interceptSourceUrl && {
211
220
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
212
221
  }),
222
+ ...(debugId && { [DEBUG_ID_HEADER]: debugId }),
213
223
  },
214
224
  body: encodedBody,
215
225
  signal: fetchAbort.signal,
@@ -272,6 +282,7 @@ export function createServerActionBridge(
272
282
  try {
273
283
  payload = await deps.createFromFetch<RscPayload>(responsePromise, {
274
284
  temporaryReferences,
285
+ ...(debugChannel && { debugChannel }),
275
286
  });
276
287
  } catch (error) {
277
288
  // Clean up streaming token on error (may be null if fetch failed before .then() ran)
@@ -341,7 +341,10 @@ export type ReadonlyURLSearchParams = Omit<
341
341
  export interface RscBrowserDependencies {
342
342
  createFromFetch: <T>(
343
343
  response: Promise<Response>,
344
- options?: { temporaryReferences?: any },
344
+ options?: {
345
+ temporaryReferences?: any;
346
+ debugChannel?: { readable?: ReadableStream; writable?: WritableStream };
347
+ },
345
348
  ) => Promise<T>;
346
349
  createFromReadableStream: <T>(stream: ReadableStream) => Promise<T>;
347
350
  encodeReply: (
@@ -8,7 +8,7 @@
8
8
  * - Error boundary segment creation
9
9
  */
10
10
 
11
- import type { ReactNode } from "react";
11
+ import { createElement, type ReactNode } from "react";
12
12
  import { DataNotFoundError } from "../../errors";
13
13
  import {
14
14
  createErrorInfo,
@@ -180,34 +180,39 @@ export function catchSegmentError<TEnv>(
180
180
 
181
181
  if (error instanceof DataNotFoundError) {
182
182
  const notFoundFallback = deps.findNearestNotFoundBoundary(entry);
183
+ // Fall back to router's notFound component, then a plain default
184
+ const notFoundOption = deps.notFoundComponent;
185
+ const defaultFallback =
186
+ typeof notFoundOption === "function"
187
+ ? notFoundOption({ pathname: pathname ?? "" })
188
+ : (notFoundOption ?? createElement("h1", null, "Not Found"));
189
+ const effectiveNotFoundFallback = notFoundFallback ?? defaultFallback;
183
190
 
184
- if (notFoundFallback) {
185
- const notFoundInfo = createNotFoundInfo(
186
- error,
187
- entry.shortCode,
188
- entry.type,
189
- pathname,
190
- );
191
+ const notFoundInfo = createNotFoundInfo(
192
+ error,
193
+ entry.shortCode,
194
+ entry.type,
195
+ pathname,
196
+ );
191
197
 
192
- reportError(true, {
193
- notFound: true,
194
- message: notFoundInfo.message,
195
- });
198
+ reportError(true, {
199
+ notFound: true,
200
+ message: notFoundInfo.message,
201
+ });
196
202
 
197
- debugLog("segment", "notFound boundary handled error", {
198
- segmentId: entry.shortCode,
199
- message: notFoundInfo.message,
200
- });
203
+ debugLog("segment", "notFound boundary handled error", {
204
+ segmentId: entry.shortCode,
205
+ message: notFoundInfo.message,
206
+ });
201
207
 
202
- setResponseStatus(404);
208
+ setResponseStatus(404);
203
209
 
204
- return createNotFoundSegment(
205
- notFoundInfo,
206
- notFoundFallback,
207
- entry,
208
- params,
209
- );
210
- }
210
+ return createNotFoundSegment(
211
+ notFoundInfo,
212
+ effectiveNotFoundFallback,
213
+ entry,
214
+ params,
215
+ );
211
216
  }
212
217
 
213
218
  const fallback = deps.findNearestErrorBoundary(entry);
@@ -96,6 +96,7 @@ export interface SegmentResolutionDeps<TEnv = any> {
96
96
  findNearestNotFoundBoundary: (
97
97
  entry: EntryData | null,
98
98
  ) => ReactNode | NotFoundBoundaryHandler | null;
99
+ notFoundComponent?: ReactNode | ((props: { pathname: string }) => ReactNode);
99
100
  callOnError: (error: unknown, phase: ErrorPhase, context: any) => void;
100
101
  }
101
102
 
package/src/router.ts CHANGED
@@ -526,6 +526,7 @@ export function createRouter<TEnv = any>(
526
526
  trackHandler,
527
527
  findNearestErrorBoundary,
528
528
  findNearestNotFoundBoundary,
529
+ notFoundComponent: notFound,
529
530
  callOnError,
530
531
  };
531
532
 
@@ -14,9 +14,14 @@ import {
14
14
  runWithRequestContext,
15
15
  setRequestContextParams,
16
16
  requireRequestContext,
17
+ getRequestContext,
17
18
  createRequestContext,
18
19
  } from "../server/request-context.js";
19
20
  import * as rscDeps from "@vitejs/plugin-rsc/rsc";
21
+ import {
22
+ DEBUG_ID_HEADER,
23
+ createServerDebugChannel,
24
+ } from "../vite/plugins/performance-tracks.js";
20
25
 
21
26
  import type {
22
27
  RscPayload,
@@ -262,7 +267,10 @@ export function createRSCHandler<
262
267
  ...(locationState && { locationState }),
263
268
  },
264
269
  };
265
- const rscStream = renderToReadableStream<RscPayload>(redirectPayload);
270
+ const debugChannel = getRequestContext()?._debugChannel;
271
+ const rscStream = renderToReadableStream<RscPayload>(redirectPayload, {
272
+ ...(debugChannel && { debugChannel }),
273
+ });
266
274
  return createResponseWithMergedHeaders(rscStream, {
267
275
  status: 200,
268
276
  headers: { "content-type": "text/x-component;charset=utf-8" },
@@ -418,6 +426,16 @@ export function createRSCHandler<
418
426
  requestContext._debugPerformance = true;
419
427
  requestContext._metricsStore = earlyMetricsStore;
420
428
  }
429
+ // Dev-only: wire debug channel for React Performance Tracks
430
+ if (process.env.NODE_ENV !== "production") {
431
+ const debugId = request.headers.get(DEBUG_ID_HEADER);
432
+ if (debugId) {
433
+ const channel = createServerDebugChannel(debugId);
434
+ if (channel) {
435
+ requestContext._debugChannel = channel;
436
+ }
437
+ }
438
+ }
421
439
  // Wire background error reporting so "use cache" and other subsystems
422
440
  // can surface non-fatal errors through the router's onError callback.
423
441
  requestContext._reportBackgroundError = (
@@ -1039,7 +1057,10 @@ export function createRSCHandler<
1039
1057
  },
1040
1058
  };
1041
1059
 
1042
- const rscStream = renderToReadableStream(payload);
1060
+ const debugChannel = requireRequestContext()._debugChannel;
1061
+ const rscStream = renderToReadableStream(payload, {
1062
+ ...(debugChannel && { debugChannel }),
1063
+ });
1043
1064
 
1044
1065
  // Determine if this is an RSC request or HTML request.
1045
1066
  // Partial requests are always RSC (see main isRscRequest comment).
@@ -168,8 +168,13 @@ export async function handleLoaderFetch<TEnv>(
168
168
  loaderResult: unknown;
169
169
  }
170
170
  const loaderPayload: LoaderPayload = { loaderResult: result };
171
- const rscStream =
172
- ctx.renderToReadableStream<LoaderPayload>(loaderPayload);
171
+ const debugChannel = reqCtx._debugChannel;
172
+ const rscStream = ctx.renderToReadableStream<LoaderPayload>(
173
+ loaderPayload,
174
+ {
175
+ ...(debugChannel && { debugChannel }),
176
+ },
177
+ );
173
178
 
174
179
  return createResponseWithMergedHeaders(rscStream, {
175
180
  headers: { "content-type": "text/x-component;charset=utf-8" },
@@ -257,7 +257,10 @@ export async function handleProgressiveEnhancement<TEnv>(
257
257
  formState: actionResult,
258
258
  };
259
259
 
260
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
260
+ const debugChannel = requireRequestContext()._debugChannel;
261
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
262
+ ...(debugChannel && { debugChannel }),
263
+ });
261
264
  // metricsStore=undefined is safe: the handler already stashed the early
262
265
  // SSR setup promise on request variables, so getSSRSetup returns it
263
266
  // without falling back to a fresh startSSRSetup.
@@ -168,7 +168,10 @@ export async function handleRscRendering<TEnv>(
168
168
 
169
169
  // Serialize to RSC stream
170
170
  const rscSerializeStart = performance.now();
171
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
171
+ const debugChannel = reqCtx._debugChannel;
172
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
173
+ ...(debugChannel && { debugChannel }),
174
+ });
172
175
  const rscSerializeDur = performance.now() - rscSerializeStart;
173
176
  // This measures synchronous stream creation, not end-to-end stream consumption.
174
177
  appendMetric(
@@ -223,8 +223,10 @@ export async function executeServerAction<TEnv>(
223
223
  // location state is a success-only semantic. Error boundary responses
224
224
  // update the error UI but should not mutate browser history state.
225
225
 
226
+ const debugChannel = requireRequestContext()._debugChannel;
226
227
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
227
228
  temporaryReferences,
229
+ ...(debugChannel && { debugChannel }),
228
230
  });
229
231
 
230
232
  return createResponseWithMergedHeaders(rscStream, {
package/src/rsc/types.ts CHANGED
@@ -63,7 +63,13 @@ export interface RSCDependencies {
63
63
  */
64
64
  renderToReadableStream: <T>(
65
65
  payload: T,
66
- options?: { temporaryReferences?: unknown },
66
+ options?: {
67
+ temporaryReferences?: unknown;
68
+ debugChannel?: {
69
+ readable?: ReadableStream;
70
+ writable?: WritableStream;
71
+ };
72
+ },
67
73
  ) => ReadableStream<Uint8Array>;
68
74
 
69
75
  /**
@@ -287,6 +287,12 @@ export interface RequestContext<
287
287
 
288
288
  /** @internal Request-scoped performance metrics store */
289
289
  _metricsStore?: MetricsStore;
290
+
291
+ /** @internal Dev-only: debug channel for React Performance Tracks */
292
+ _debugChannel?: {
293
+ readable: ReadableStream;
294
+ writable: WritableStream;
295
+ };
290
296
  }
291
297
 
292
298
  /**
@@ -316,6 +322,7 @@ export type PublicRequestContext<
316
322
  | "_reportBackgroundError"
317
323
  | "_debugPerformance"
318
324
  | "_metricsStore"
325
+ | "_debugChannel"
319
326
  | "_setStatus"
320
327
  | "res"
321
328
  >;
@@ -277,9 +277,9 @@ export type HandlerContext<
277
277
  value: T,
278
278
  options?: { cache?: boolean },
279
279
  ): void;
280
- } & ((
281
- key: keyof DefaultVars,
282
- value: DefaultVars[keyof DefaultVars],
280
+ } & (<K extends keyof DefaultVars>(
281
+ key: K,
282
+ value: DefaultVars[K],
283
283
  options?: { cache?: boolean },
284
284
  ) => void);
285
285
  /**
@@ -0,0 +1,195 @@
1
+ /**
2
+ * React Performance Tracks — Vite plugin
3
+ *
4
+ * Dev-only plugin that enables Chrome DevTools Performance tab integration
5
+ * for React Server Components. Creates a bidirectional debug channel per
6
+ * RSC request and transports data over Vite's HMR WebSocket.
7
+ *
8
+ * Architecture:
9
+ * - Server: renderToReadableStream writes timing data to debugChannel.writable
10
+ * - Transport: chunks are base64-encoded and sent via HMR custom events
11
+ * - Client: createFromFetch reads from debugChannel.readable
12
+ *
13
+ * Each request gets a unique debugId (UUID) to correlate the two sides.
14
+ */
15
+
16
+ import type { Plugin, ViteDevServer } from "vite";
17
+
18
+ export const DEBUG_ID_HEADER = "X-RSC-Debug-Id";
19
+ const DEBUG_S2C_EVENT = "rango:perf-s2c";
20
+ const DEBUG_C2S_EVENT = "rango:perf-c2s";
21
+
22
+ type DebugPayload =
23
+ | { i: string; b: string } // chunk (base64)
24
+ | { i: string; d: true }; // done
25
+
26
+ interface DebugSession {
27
+ // Server → Client: writable that React writes to, we read and forward via WS
28
+ // Server → Client: readable that client commands come into
29
+ cmdController?: ReadableStreamDefaultController<Uint8Array>;
30
+ pendingChunks?: Uint8Array[];
31
+ ended: boolean;
32
+ }
33
+
34
+ const bytesToBase64 = (bytes: Uint8Array) =>
35
+ Buffer.from(bytes).toString("base64");
36
+
37
+ const base64ToBytes = (base64: string) =>
38
+ new Uint8Array(Buffer.from(base64, "base64"));
39
+
40
+ // Module-level registry shared with RSC handler code (same Node.js process in dev)
41
+ let devServer: ViteDevServer | null = null;
42
+ const sessions = new Map<string, DebugSession>();
43
+
44
+ /**
45
+ * Create a debug channel for a given request.
46
+ * Called by the RSC handler for each request that has a debugId.
47
+ * Returns the { readable, writable } pair for renderToReadableStream.
48
+ */
49
+ export function createServerDebugChannel(debugId: string): {
50
+ readable: ReadableStream<Uint8Array>;
51
+ writable: WritableStream<Uint8Array>;
52
+ } | null {
53
+ if (!devServer) return null;
54
+
55
+ const hot = devServer.environments.client.hot;
56
+ let session = sessions.get(debugId);
57
+ if (!session) {
58
+ session = { pendingChunks: [], ended: false };
59
+ sessions.set(debugId, session);
60
+ }
61
+
62
+ const sendChunk = (chunk: Uint8Array) => {
63
+ hot.send(DEBUG_S2C_EVENT, {
64
+ i: debugId,
65
+ b: bytesToBase64(chunk),
66
+ } satisfies DebugPayload);
67
+ };
68
+
69
+ const flushPendingChunks = () => {
70
+ if (!session!.pendingChunks) return;
71
+ for (const chunk of session!.pendingChunks) {
72
+ sendChunk(chunk);
73
+ }
74
+ delete session!.pendingChunks;
75
+ };
76
+
77
+ const cleanupIfEnded = () => {
78
+ if (session!.pendingChunks || !session!.ended) return;
79
+ sessions.delete(debugId);
80
+ hot.send(DEBUG_S2C_EVENT, {
81
+ i: debugId,
82
+ d: true,
83
+ } satisfies DebugPayload);
84
+ };
85
+
86
+ // Readable: receives client-to-server commands via WS
87
+ const readable = new ReadableStream<Uint8Array>({
88
+ start(controller) {
89
+ session!.cmdController = controller;
90
+ // If client already sent "ready", flush buffered data
91
+ flushPendingChunks();
92
+ cleanupIfEnded();
93
+ },
94
+ cancel() {
95
+ delete session!.cmdController;
96
+ },
97
+ });
98
+
99
+ // Writable: React writes debug data here, we forward to client via WS
100
+ const writable = new WritableStream<Uint8Array>({
101
+ write(chunk) {
102
+ if (session!.pendingChunks) {
103
+ // Client not connected yet — buffer
104
+ session!.pendingChunks.push(chunk);
105
+ } else {
106
+ sendChunk(chunk);
107
+ }
108
+ },
109
+ close() {
110
+ session!.ended = true;
111
+ cleanupIfEnded();
112
+ },
113
+ abort() {
114
+ session!.ended = true;
115
+ cleanupIfEnded();
116
+ },
117
+ });
118
+
119
+ return { readable, writable };
120
+ }
121
+
122
+ export function performanceTracksPlugin(): Plugin {
123
+ return {
124
+ name: "@rangojs/router:performance-tracks",
125
+ apply: "serve",
126
+
127
+ configureServer(server) {
128
+ devServer = server;
129
+ const hot = server.environments.client.hot;
130
+
131
+ // Listen for client-to-server debug messages
132
+ hot.on(DEBUG_C2S_EVENT, (payload: DebugPayload) => {
133
+ const session = sessions.get(payload.i);
134
+
135
+ if ("d" in payload) {
136
+ // Client closed channel
137
+ if (session?.cmdController) {
138
+ try {
139
+ session.cmdController.close();
140
+ } catch {
141
+ // ignore
142
+ }
143
+ delete session.cmdController;
144
+ }
145
+ return;
146
+ }
147
+
148
+ if ("b" in payload) {
149
+ // Client sent command data
150
+ if (session?.cmdController) {
151
+ try {
152
+ session.cmdController.enqueue(base64ToBytes(payload.b));
153
+ } catch {
154
+ delete session!.cmdController;
155
+ }
156
+ }
157
+ return;
158
+ }
159
+ });
160
+
161
+ // Intercept requests to inject debugId
162
+ return () => {
163
+ server.middlewares.use((req, _res, next) => {
164
+ const existingId = req.headers[
165
+ DEBUG_ID_HEADER.toLowerCase()
166
+ ] as string;
167
+ const isHtml = req.headers.accept?.includes("text/html");
168
+
169
+ if (!existingId && !isHtml) {
170
+ next();
171
+ return;
172
+ }
173
+
174
+ // Use existing debugId from client or generate one for SSR
175
+ const debugId = existingId || crypto.randomUUID();
176
+ if (!existingId) {
177
+ // Inject header so the RSC handler can read it
178
+ const lowerName = DEBUG_ID_HEADER.toLowerCase();
179
+ req.headers[lowerName] = debugId;
180
+ if (req.rawHeaders) {
181
+ req.rawHeaders.push(DEBUG_ID_HEADER, debugId);
182
+ }
183
+ }
184
+
185
+ // Pre-create session so the channel is ready when handler runs
186
+ if (!sessions.has(debugId)) {
187
+ sessions.set(debugId, { pendingChunks: [], ended: false });
188
+ }
189
+
190
+ next();
191
+ });
192
+ };
193
+ },
194
+ };
195
+ }
package/src/vite/rango.ts CHANGED
@@ -26,6 +26,7 @@ import { printBanner, rangoVersion } from "./utils/banner.js";
26
26
  import { createVersionInjectorPlugin } from "./plugins/version-injector.js";
27
27
  import { createCjsToEsmPlugin } from "./plugins/cjs-to-esm.js";
28
28
  import { createRouterDiscoveryPlugin } from "./router-discovery.js";
29
+ import { performanceTracksPlugin } from "./plugins/performance-tracks.js";
29
30
 
30
31
  /**
31
32
  * Vite plugin for @rangojs/router.
@@ -441,5 +442,8 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
441
442
  }),
442
443
  );
443
444
 
445
+ // Dev-only: React Performance Tracks (debugChannel transport via HMR WS)
446
+ plugins.push(performanceTracksPlugin());
447
+
444
448
  return plugins;
445
449
  }