@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.
- package/dist/vite/index.js +70 -7
- package/package.json +1 -1
- package/skills/loader/SKILL.md +1 -1
- package/skills/router-setup/SKILL.md +52 -2
- package/src/browser/debug-channel.ts +93 -0
- package/src/browser/navigation-client.ts +16 -1
- package/src/browser/react/NavigationProvider.tsx +4 -2
- package/src/browser/server-action-bridge.ts +11 -0
- package/src/browser/types.ts +4 -1
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/types.ts +1 -0
- package/src/router.ts +1 -0
- package/src/rsc/handler.ts +23 -2
- package/src/rsc/loader-fetch.ts +7 -2
- package/src/rsc/progressive-enhancement.ts +4 -1
- package/src/rsc/rsc-rendering.ts +4 -1
- package/src/rsc/server-action.ts +2 -0
- package/src/rsc/types.ts +7 -1
- package/src/server/request-context.ts +7 -0
- package/src/types/handler-context.ts +3 -3
- package/src/vite/plugins/performance-tracks.ts +195 -0
- package/src/vite/rango.ts +4 -0
package/dist/vite/index.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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.
|
|
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(
|
|
2716
|
-
server =
|
|
2717
|
-
|
|
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
package/skills/loader/SKILL.md
CHANGED
|
@@ -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
|
|
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>(
|
|
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
|
|
293
|
-
//
|
|
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)
|
package/src/browser/types.ts
CHANGED
|
@@ -341,7 +341,10 @@ export type ReadonlyURLSearchParams = Omit<
|
|
|
341
341
|
export interface RscBrowserDependencies {
|
|
342
342
|
createFromFetch: <T>(
|
|
343
343
|
response: Promise<Response>,
|
|
344
|
-
options?: {
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
);
|
|
191
|
+
const notFoundInfo = createNotFoundInfo(
|
|
192
|
+
error,
|
|
193
|
+
entry.shortCode,
|
|
194
|
+
entry.type,
|
|
195
|
+
pathname,
|
|
196
|
+
);
|
|
191
197
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
198
|
+
reportError(true, {
|
|
199
|
+
notFound: true,
|
|
200
|
+
message: notFoundInfo.message,
|
|
201
|
+
});
|
|
196
202
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
debugLog("segment", "notFound boundary handled error", {
|
|
204
|
+
segmentId: entry.shortCode,
|
|
205
|
+
message: notFoundInfo.message,
|
|
206
|
+
});
|
|
201
207
|
|
|
202
|
-
|
|
208
|
+
setResponseStatus(404);
|
|
203
209
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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);
|
package/src/router/types.ts
CHANGED
|
@@ -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
package/src/rsc/handler.ts
CHANGED
|
@@ -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
|
|
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
|
|
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).
|
package/src/rsc/loader-fetch.ts
CHANGED
|
@@ -168,8 +168,13 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
168
168
|
loaderResult: unknown;
|
|
169
169
|
}
|
|
170
170
|
const loaderPayload: LoaderPayload = { loaderResult: result };
|
|
171
|
-
const
|
|
172
|
-
|
|
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
|
|
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.
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -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
|
|
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(
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -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?: {
|
|
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:
|
|
282
|
-
value: 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
|
}
|