@rangojs/router 0.0.0-experimental.ffbe1b7f → 0.0.0-experimental.preview.1774275339
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 +278 -4
- package/package.json +2 -2
- package/skills/loader/SKILL.md +1 -1
- package/skills/router-setup/SKILL.md +52 -2
- package/src/browser/debug-channel.ts +112 -0
- package/src/browser/navigation-client.ts +24 -1
- package/src/browser/rsc-router.tsx +19 -2
- package/src/browser/server-action-bridge.ts +12 -0
- package/src/browser/types.ts +18 -2
- package/src/deps/browser.ts +1 -0
- package/src/rsc/handler.ts +26 -2
- package/src/rsc/loader-fetch.ts +7 -2
- package/src/rsc/progressive-enhancement.ts +4 -1
- package/src/rsc/rsc-rendering.ts +5 -1
- package/src/rsc/server-action.ts +2 -0
- package/src/rsc/types.ts +10 -1
- package/src/server/request-context.ts +11 -0
- package/src/ssr/index.tsx +13 -1
- package/src/vite/plugins/performance-tracks.ts +448 -0
- package/src/vite/rango.ts +30 -1
- package/src/vite/utils/shared-utils.ts +3 -2
package/dist/vite/index.js
CHANGED
|
@@ -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.preview.1774275339",
|
|
1749
1749
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
1750
1750
|
keywords: [
|
|
1751
1751
|
"react",
|
|
@@ -1887,7 +1887,7 @@ var package_default = {
|
|
|
1887
1887
|
"test:unit:watch": "vitest"
|
|
1888
1888
|
},
|
|
1889
1889
|
dependencies: {
|
|
1890
|
-
"@vitejs/plugin-rsc": "^0.5.
|
|
1890
|
+
"@vitejs/plugin-rsc": "^0.5.19",
|
|
1891
1891
|
"magic-string": "^0.30.17",
|
|
1892
1892
|
picomatch: "^4.0.3",
|
|
1893
1893
|
"rsc-html-stream": "^0.0.7"
|
|
@@ -2784,6 +2784,258 @@ function createVersionPlugin() {
|
|
|
2784
2784
|
|
|
2785
2785
|
// src/vite/utils/shared-utils.ts
|
|
2786
2786
|
import * as Vite from "vite";
|
|
2787
|
+
|
|
2788
|
+
// src/vite/plugins/performance-tracks.ts
|
|
2789
|
+
import { readFile } from "node:fs/promises";
|
|
2790
|
+
var DEBUG_S2C_EVENT = "rango:perf-s2c";
|
|
2791
|
+
var DEBUG_C2S_EVENT = "rango:perf-c2s";
|
|
2792
|
+
var DEBUG_RELAY_PREFIX = "/__rango_devtools__/perf";
|
|
2793
|
+
function getRegistry() {
|
|
2794
|
+
return globalThis.__RANGO_DEBUG_CHANNELS__ ??= {
|
|
2795
|
+
sessions: /* @__PURE__ */ new Map()
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
var bytesToBase64 = (bytes) => Buffer.from(bytes).toString("base64");
|
|
2799
|
+
var base64ToBytes = (base64) => new Uint8Array(Buffer.from(base64, "base64"));
|
|
2800
|
+
var RSDW_PATCH_RE = /((?:var|let|const)\s+\w+\s*=\s*root\._children\s*,\s*(\w+)\s*=\s*root\._debugInfo\s*[;,])/;
|
|
2801
|
+
function buildPatchReplacement(match, debugInfoVar) {
|
|
2802
|
+
return `${match}
|
|
2803
|
+
if (${debugInfoVar} && 0 === ${debugInfoVar}.length && "fulfilled" === root.status) {
|
|
2804
|
+
var _resolved = "function" === typeof resolveLazy ? resolveLazy(root.value) : root.value;
|
|
2805
|
+
if ("object" === typeof _resolved && null !== _resolved && isArrayImpl(_resolved._debugInfo)) {
|
|
2806
|
+
${debugInfoVar} = _resolved._debugInfo;
|
|
2807
|
+
}
|
|
2808
|
+
}`;
|
|
2809
|
+
}
|
|
2810
|
+
function patchRsdwClientDebugInfoRecovery(code) {
|
|
2811
|
+
const match = code.match(RSDW_PATCH_RE);
|
|
2812
|
+
if (!match) {
|
|
2813
|
+
return { code, debugInfoVar: null };
|
|
2814
|
+
}
|
|
2815
|
+
return {
|
|
2816
|
+
code: code.replace(
|
|
2817
|
+
match[1],
|
|
2818
|
+
buildPatchReplacement(match[1], match[2])
|
|
2819
|
+
),
|
|
2820
|
+
debugInfoVar: match[2]
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
function performanceTracksOptimizeDepsPlugin() {
|
|
2824
|
+
return {
|
|
2825
|
+
name: "@rangojs/router:performance-tracks-optimize-deps",
|
|
2826
|
+
setup(build) {
|
|
2827
|
+
build.onLoad(
|
|
2828
|
+
{
|
|
2829
|
+
filter: /react-server-dom-webpack-client\.browser\.(development|production)\.js$/
|
|
2830
|
+
},
|
|
2831
|
+
async (args) => {
|
|
2832
|
+
const code = await readFile(args.path, "utf8");
|
|
2833
|
+
const patched = patchRsdwClientDebugInfoRecovery(code);
|
|
2834
|
+
return {
|
|
2835
|
+
contents: patched.code,
|
|
2836
|
+
loader: "js"
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
);
|
|
2840
|
+
}
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2843
|
+
function performanceTracksPlugin() {
|
|
2844
|
+
return {
|
|
2845
|
+
name: "@rangojs/router:performance-tracks",
|
|
2846
|
+
// configureServer + transform — naturally dev-only
|
|
2847
|
+
transform(code, id) {
|
|
2848
|
+
if (!id.includes("react-server-dom") || !id.includes("client")) return;
|
|
2849
|
+
const patched = patchRsdwClientDebugInfoRecovery(code);
|
|
2850
|
+
if (!patched.debugInfoVar) return;
|
|
2851
|
+
console.log(
|
|
2852
|
+
"[perf-tracks] patched RSDW client for plain-object _debugInfo recovery (var:",
|
|
2853
|
+
patched.debugInfoVar,
|
|
2854
|
+
")"
|
|
2855
|
+
);
|
|
2856
|
+
return patched.code;
|
|
2857
|
+
},
|
|
2858
|
+
configureServer(server) {
|
|
2859
|
+
console.log("[perf-tracks] plugin loaded, configureServer called");
|
|
2860
|
+
const hot = server.environments.client.hot;
|
|
2861
|
+
const registry = getRegistry();
|
|
2862
|
+
const sessions = registry.sessions;
|
|
2863
|
+
const getSession = (debugId) => {
|
|
2864
|
+
let session = sessions.get(debugId);
|
|
2865
|
+
if (!session) {
|
|
2866
|
+
session = {
|
|
2867
|
+
pendingClientChunks: [],
|
|
2868
|
+
pendingServerMessages: [],
|
|
2869
|
+
waiters: [],
|
|
2870
|
+
ready: false,
|
|
2871
|
+
ended: false,
|
|
2872
|
+
browserDoneSent: false
|
|
2873
|
+
};
|
|
2874
|
+
sessions.set(debugId, session);
|
|
2875
|
+
}
|
|
2876
|
+
return session;
|
|
2877
|
+
};
|
|
2878
|
+
const sendChunk = (debugId, chunk) => {
|
|
2879
|
+
hot.send(DEBUG_S2C_EVENT, {
|
|
2880
|
+
i: debugId,
|
|
2881
|
+
b: bytesToBase64(chunk)
|
|
2882
|
+
});
|
|
2883
|
+
};
|
|
2884
|
+
const flushBrowserDoneIfReady = (debugId, session) => {
|
|
2885
|
+
if (!session.ended || !session.ready || session.browserDoneSent) return;
|
|
2886
|
+
session.browserDoneSent = true;
|
|
2887
|
+
hot.send(DEBUG_S2C_EVENT, {
|
|
2888
|
+
i: debugId,
|
|
2889
|
+
d: true
|
|
2890
|
+
});
|
|
2891
|
+
};
|
|
2892
|
+
const closeRuntimeWaiters = (session) => {
|
|
2893
|
+
const waiters = session.waiters.splice(0);
|
|
2894
|
+
for (const waiter of waiters) {
|
|
2895
|
+
waiter({ type: "done" });
|
|
2896
|
+
}
|
|
2897
|
+
};
|
|
2898
|
+
const cleanupIfSettled = (debugId, session) => {
|
|
2899
|
+
if (!session.ended || !session.browserDoneSent || session.waiters.length > 0 || session.pendingServerMessages.length > 0) {
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
sessions.delete(debugId);
|
|
2903
|
+
};
|
|
2904
|
+
const readRequestBody = async (req) => {
|
|
2905
|
+
const chunks = [];
|
|
2906
|
+
for await (const chunk of req) {
|
|
2907
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2908
|
+
}
|
|
2909
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
2910
|
+
};
|
|
2911
|
+
server.middlewares.use(async (req, res, next) => {
|
|
2912
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
2913
|
+
if (!url.pathname.startsWith(DEBUG_RELAY_PREFIX)) {
|
|
2914
|
+
next();
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
const debugId = url.searchParams.get("i");
|
|
2918
|
+
if (!debugId) {
|
|
2919
|
+
res.statusCode = 400;
|
|
2920
|
+
res.end("missing debugId");
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
const session = getSession(debugId);
|
|
2924
|
+
if (url.pathname === `${DEBUG_RELAY_PREFIX}/s2c`) {
|
|
2925
|
+
if (url.searchParams.get("done") === "1") {
|
|
2926
|
+
session.ended = true;
|
|
2927
|
+
closeRuntimeWaiters(session);
|
|
2928
|
+
flushBrowserDoneIfReady(debugId, session);
|
|
2929
|
+
cleanupIfSettled(debugId, session);
|
|
2930
|
+
res.statusCode = 204;
|
|
2931
|
+
res.end();
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
const chunk = await readRequestBody(req);
|
|
2935
|
+
if (chunk.byteLength <= 3) {
|
|
2936
|
+
console.log(
|
|
2937
|
+
"[perf-tracks] writable: chunk size:",
|
|
2938
|
+
chunk.byteLength,
|
|
2939
|
+
"for",
|
|
2940
|
+
debugId.slice(0, 8),
|
|
2941
|
+
session.ready ? "(sent)" : "(buffered)"
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
if (session.ready) {
|
|
2945
|
+
sendChunk(debugId, chunk);
|
|
2946
|
+
} else {
|
|
2947
|
+
session.pendingClientChunks.push(chunk);
|
|
2948
|
+
}
|
|
2949
|
+
res.statusCode = 204;
|
|
2950
|
+
res.end();
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
if (url.pathname === `${DEBUG_RELAY_PREFIX}/c2s`) {
|
|
2954
|
+
const nextMessage = session.pendingServerMessages.shift();
|
|
2955
|
+
if (nextMessage) {
|
|
2956
|
+
res.setHeader("Content-Type", "application/json");
|
|
2957
|
+
res.end(
|
|
2958
|
+
JSON.stringify(
|
|
2959
|
+
nextMessage.type === "done" ? { d: true } : { b: bytesToBase64(nextMessage.chunk) }
|
|
2960
|
+
)
|
|
2961
|
+
);
|
|
2962
|
+
cleanupIfSettled(debugId, session);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
if (session.ended) {
|
|
2966
|
+
res.setHeader("Content-Type", "application/json");
|
|
2967
|
+
res.end(JSON.stringify({ d: true }));
|
|
2968
|
+
cleanupIfSettled(debugId, session);
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
const timeout = setTimeout(() => {
|
|
2972
|
+
const index = session.waiters.indexOf(waiter);
|
|
2973
|
+
if (index >= 0) session.waiters.splice(index, 1);
|
|
2974
|
+
res.statusCode = 204;
|
|
2975
|
+
res.end();
|
|
2976
|
+
}, 3e4);
|
|
2977
|
+
const waiter = (message) => {
|
|
2978
|
+
clearTimeout(timeout);
|
|
2979
|
+
if (!message) {
|
|
2980
|
+
res.statusCode = 204;
|
|
2981
|
+
res.end();
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
res.setHeader("Content-Type", "application/json");
|
|
2985
|
+
res.end(
|
|
2986
|
+
JSON.stringify(
|
|
2987
|
+
message.type === "done" ? { d: true } : { b: bytesToBase64(message.chunk) }
|
|
2988
|
+
)
|
|
2989
|
+
);
|
|
2990
|
+
};
|
|
2991
|
+
session.waiters.push(waiter);
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
res.statusCode = 404;
|
|
2995
|
+
res.end();
|
|
2996
|
+
});
|
|
2997
|
+
hot.on(DEBUG_C2S_EVENT, (raw) => {
|
|
2998
|
+
const payload = raw;
|
|
2999
|
+
const session = getSession(payload.i);
|
|
3000
|
+
const pushServerMessage = (message) => {
|
|
3001
|
+
const waiter = session.waiters.shift();
|
|
3002
|
+
if (waiter) {
|
|
3003
|
+
waiter(message);
|
|
3004
|
+
} else {
|
|
3005
|
+
session.pendingServerMessages.push(message);
|
|
3006
|
+
}
|
|
3007
|
+
};
|
|
3008
|
+
if (payload.d) {
|
|
3009
|
+
pushServerMessage({ type: "done" });
|
|
3010
|
+
cleanupIfSettled(payload.i, session);
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
if (payload.b) {
|
|
3014
|
+
pushServerMessage({ type: "chunk", chunk: base64ToBytes(payload.b) });
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
session.ready = true;
|
|
3018
|
+
const pending = session.pendingClientChunks.length;
|
|
3019
|
+
console.log(
|
|
3020
|
+
"[perf-tracks] ready signal for",
|
|
3021
|
+
payload.i.slice(0, 8),
|
|
3022
|
+
"pending:",
|
|
3023
|
+
pending,
|
|
3024
|
+
"ended:",
|
|
3025
|
+
session.ended
|
|
3026
|
+
);
|
|
3027
|
+
for (const chunk of session.pendingClientChunks) {
|
|
3028
|
+
sendChunk(payload.i, chunk);
|
|
3029
|
+
}
|
|
3030
|
+
session.pendingClientChunks = [];
|
|
3031
|
+
flushBrowserDoneIfReady(payload.i, session);
|
|
3032
|
+
cleanupIfSettled(payload.i, session);
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
// src/vite/utils/shared-utils.ts
|
|
2787
3039
|
var versionEsbuildPlugin = {
|
|
2788
3040
|
name: "@rangojs/router-version",
|
|
2789
3041
|
setup(build) {
|
|
@@ -2801,7 +3053,7 @@ var versionEsbuildPlugin = {
|
|
|
2801
3053
|
}
|
|
2802
3054
|
};
|
|
2803
3055
|
var sharedEsbuildOptions = {
|
|
2804
|
-
plugins: [versionEsbuildPlugin]
|
|
3056
|
+
plugins: [versionEsbuildPlugin, performanceTracksOptimizeDepsPlugin()]
|
|
2805
3057
|
};
|
|
2806
3058
|
function createVirtualEntriesPlugin(entries, routerPathRef) {
|
|
2807
3059
|
const virtualModules = {};
|
|
@@ -4865,10 +5117,20 @@ ${details}`
|
|
|
4865
5117
|
async function rango(options) {
|
|
4866
5118
|
const resolvedOptions = options ?? { preset: "node" };
|
|
4867
5119
|
const preset = resolvedOptions.preset ?? "node";
|
|
5120
|
+
console.log("[perf-tracks] rango() called, preset:", preset);
|
|
4868
5121
|
const showBanner = resolvedOptions.banner ?? true;
|
|
4869
5122
|
const plugins = [];
|
|
4870
5123
|
const rangoAliases = getPackageAliases();
|
|
4871
|
-
const excludeDeps =
|
|
5124
|
+
const excludeDeps = [
|
|
5125
|
+
...getExcludeDeps(),
|
|
5126
|
+
// The public browser entry re-exports the RSDW browser client.
|
|
5127
|
+
// Excluding both keeps Vite from freezing the unpatched bundle into
|
|
5128
|
+
// .vite/deps before our source transforms run.
|
|
5129
|
+
"@vitejs/plugin-rsc/browser",
|
|
5130
|
+
// Keep the browser RSDW client out of Vite's dep optimizer so our
|
|
5131
|
+
// cjs-to-esm and performance-tracks transforms can patch the real file.
|
|
5132
|
+
"@vitejs/plugin-rsc/vendor/react-server-dom/client.browser"
|
|
5133
|
+
];
|
|
4872
5134
|
const routerRef = { path: void 0 };
|
|
4873
5135
|
const prerenderEnabled = true;
|
|
4874
5136
|
if (preset === "cloudflare") {
|
|
@@ -4964,6 +5226,12 @@ async function rango(options) {
|
|
|
4964
5226
|
}
|
|
4965
5227
|
});
|
|
4966
5228
|
plugins.push(createVirtualEntriesPlugin(finalEntries));
|
|
5229
|
+
const perfPlugin = performanceTracksPlugin();
|
|
5230
|
+
console.log(
|
|
5231
|
+
"[perf-tracks] rango: plugin created, has configureServer:",
|
|
5232
|
+
!!perfPlugin.configureServer
|
|
5233
|
+
);
|
|
5234
|
+
plugins.push(perfPlugin);
|
|
4967
5235
|
plugins.push(
|
|
4968
5236
|
rsc({
|
|
4969
5237
|
entries: finalEntries,
|
|
@@ -5082,6 +5350,12 @@ ${list}`);
|
|
|
5082
5350
|
}
|
|
5083
5351
|
});
|
|
5084
5352
|
plugins.push(createVirtualEntriesPlugin(finalEntries, routerRef));
|
|
5353
|
+
const perfPlugin = performanceTracksPlugin();
|
|
5354
|
+
console.log(
|
|
5355
|
+
"[perf-tracks] rango: plugin created, has configureServer:",
|
|
5356
|
+
!!perfPlugin.configureServer
|
|
5357
|
+
);
|
|
5358
|
+
plugins.push(perfPlugin);
|
|
5085
5359
|
plugins.push(
|
|
5086
5360
|
rsc({
|
|
5087
5361
|
entries: finalEntries
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.0.0-experimental.
|
|
3
|
+
"version": "0.0.0-experimental.preview.1774275339",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
"test:unit:watch": "vitest"
|
|
143
143
|
},
|
|
144
144
|
"dependencies": {
|
|
145
|
-
"@vitejs/plugin-rsc": "^0.5.
|
|
145
|
+
"@vitejs/plugin-rsc": "^0.5.19",
|
|
146
146
|
"magic-string": "^0.30.17",
|
|
147
147
|
"picomatch": "^4.0.3",
|
|
148
148
|
"rsc-html-stream": "^0.0.7"
|
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,112 @@
|
|
|
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
|
+
let chunks = 0;
|
|
59
|
+
onServerData = (payload: DebugPayload) => {
|
|
60
|
+
if (closed || payload.i !== debugId) return;
|
|
61
|
+
if ("b" in payload) {
|
|
62
|
+
chunks++;
|
|
63
|
+
if (chunks <= 3)
|
|
64
|
+
console.log(
|
|
65
|
+
"[perf-tracks] client readable: chunk #" + chunks,
|
|
66
|
+
"size:",
|
|
67
|
+
base64ToBytes(payload.b).byteLength,
|
|
68
|
+
);
|
|
69
|
+
controller.enqueue(base64ToBytes(payload.b));
|
|
70
|
+
}
|
|
71
|
+
if ("d" in payload) {
|
|
72
|
+
console.log(
|
|
73
|
+
"[perf-tracks] client readable: done after",
|
|
74
|
+
chunks,
|
|
75
|
+
"chunks",
|
|
76
|
+
);
|
|
77
|
+
cleanup();
|
|
78
|
+
controller.close();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
hot.on(DEBUG_S2C_EVENT, onServerData);
|
|
82
|
+
// Send "ready" signal so the server flushes buffered debug data
|
|
83
|
+
console.log(
|
|
84
|
+
"[perf-tracks] client: sending ready signal for",
|
|
85
|
+
debugId.slice(0, 8),
|
|
86
|
+
);
|
|
87
|
+
hot.send(DEBUG_C2S_EVENT, { i: debugId });
|
|
88
|
+
},
|
|
89
|
+
cancel() {
|
|
90
|
+
cleanup(true);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Writable: sends client-to-server commands via HMR WS
|
|
95
|
+
const writable = new WritableStream<Uint8Array>({
|
|
96
|
+
write(chunk) {
|
|
97
|
+
if (closed) throw new TypeError("Channel is closed");
|
|
98
|
+
hot.send(DEBUG_C2S_EVENT, {
|
|
99
|
+
i: debugId,
|
|
100
|
+
b: bytesToBase64(chunk),
|
|
101
|
+
} satisfies DebugPayload);
|
|
102
|
+
},
|
|
103
|
+
close() {
|
|
104
|
+
cleanup(true);
|
|
105
|
+
},
|
|
106
|
+
abort() {
|
|
107
|
+
cleanup(true);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return { readable, writable };
|
|
112
|
+
}
|
|
@@ -12,6 +12,8 @@ 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";
|
|
16
|
+
import { findSourceMapURL } from "../deps/browser.js";
|
|
15
17
|
import {
|
|
16
18
|
extractRscHeaderUrl,
|
|
17
19
|
emptyResponse,
|
|
@@ -107,8 +109,25 @@ export function createNavigationClient(
|
|
|
107
109
|
resolveStreamComplete = resolve;
|
|
108
110
|
});
|
|
109
111
|
|
|
112
|
+
// Dev-only: debug channel is created lazily only for fresh fetches.
|
|
113
|
+
// Cached/inflight responses don't have a server-side channel, so passing
|
|
114
|
+
// debugChannel to createFromFetch would hang waiting for data that never comes.
|
|
115
|
+
let debugChannel: {
|
|
116
|
+
readable: ReadableStream<Uint8Array>;
|
|
117
|
+
writable: WritableStream<Uint8Array>;
|
|
118
|
+
} | null = null;
|
|
119
|
+
|
|
110
120
|
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
111
121
|
const doFreshFetch = (): Promise<Response> => {
|
|
122
|
+
// Dev-only: create debug channel for React Performance Tracks
|
|
123
|
+
const debugId = (import.meta as any).hot
|
|
124
|
+
? crypto.randomUUID()
|
|
125
|
+
: undefined;
|
|
126
|
+
if (debugId) {
|
|
127
|
+
debugChannel = createClientDebugChannel(debugId);
|
|
128
|
+
console.log("[perf-tracks] client: fresh fetch, debugId =", debugId);
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
if (tx) {
|
|
113
132
|
browserDebugLog(tx, "fetching", {
|
|
114
133
|
path: `${fetchUrl.pathname}${fetchUrl.search}`,
|
|
@@ -124,6 +143,7 @@ export function createNavigationClient(
|
|
|
124
143
|
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
125
144
|
}),
|
|
126
145
|
...(hmr && { "X-RSC-HMR": "1" }),
|
|
146
|
+
...(debugId && { [DEBUG_ID_HEADER]: debugId }),
|
|
127
147
|
},
|
|
128
148
|
signal,
|
|
129
149
|
}).then((response) => {
|
|
@@ -220,7 +240,10 @@ export function createNavigationClient(
|
|
|
220
240
|
|
|
221
241
|
try {
|
|
222
242
|
// Deserialize RSC payload
|
|
223
|
-
const payload = await deps.createFromFetch<RscPayload>(
|
|
243
|
+
const payload = await deps.createFromFetch<RscPayload>(
|
|
244
|
+
responsePromise,
|
|
245
|
+
debugChannel ? { debugChannel, findSourceMapURL } : undefined,
|
|
246
|
+
);
|
|
224
247
|
if (tx) {
|
|
225
248
|
browserDebugLog(tx, "response received", {
|
|
226
249
|
isPartial: payload.metadata?.isPartial,
|
|
@@ -11,6 +11,8 @@ import { createEventController } from "./event-controller.js";
|
|
|
11
11
|
import { createNavigationClient } from "./navigation-client.js";
|
|
12
12
|
import { createServerActionBridge } from "./server-action-bridge.js";
|
|
13
13
|
import { createNavigationBridge } from "./navigation-bridge.js";
|
|
14
|
+
import { createClientDebugChannel } from "./debug-channel.js";
|
|
15
|
+
import { findSourceMapURL } from "../deps/browser.js";
|
|
14
16
|
import { NavigationProvider } from "./react/index.js";
|
|
15
17
|
import type {
|
|
16
18
|
RscPayload,
|
|
@@ -139,9 +141,24 @@ export async function initBrowserApp(
|
|
|
139
141
|
initialTheme,
|
|
140
142
|
} = options;
|
|
141
143
|
|
|
144
|
+
// Dev-only: create debug channel for React Performance Tracks.
|
|
145
|
+
// The debugId was injected into the HTML bootstrap by the SSR handler.
|
|
146
|
+
// The client sends a "ready" signal so the server flushes buffered data.
|
|
147
|
+
const ssrDebugId = (globalThis as any).__RANGO_DEBUG_ID__ as
|
|
148
|
+
| string
|
|
149
|
+
| undefined;
|
|
150
|
+
const ssrDebugChannel =
|
|
151
|
+
ssrDebugId && (import.meta as any).hot
|
|
152
|
+
? createClientDebugChannel(ssrDebugId)
|
|
153
|
+
: undefined;
|
|
154
|
+
|
|
142
155
|
// Load initial payload from SSR-injected __FLIGHT_DATA__
|
|
143
|
-
const initialPayload =
|
|
144
|
-
|
|
156
|
+
const initialPayload = await deps.createFromReadableStream<RscPayload>(
|
|
157
|
+
rscStream,
|
|
158
|
+
ssrDebugChannel
|
|
159
|
+
? { debugChannel: ssrDebugChannel, findSourceMapURL }
|
|
160
|
+
: undefined,
|
|
161
|
+
);
|
|
145
162
|
|
|
146
163
|
// Extract themeConfig and initialTheme from payload if not explicitly provided
|
|
147
164
|
// This allows virtual entries to work without importing the router
|
|
@@ -4,6 +4,8 @@ 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";
|
|
8
|
+
import { findSourceMapURL } from "../deps/browser.js";
|
|
7
9
|
import { createNavigationTransaction } from "./navigation-transaction.js";
|
|
8
10
|
import {
|
|
9
11
|
reconcileSegments,
|
|
@@ -199,6 +201,14 @@ export function createServerActionBridge(
|
|
|
199
201
|
const onHandleAbort = () => fetchAbort.abort();
|
|
200
202
|
handle.signal.addEventListener("abort", onHandleAbort, { once: true });
|
|
201
203
|
|
|
204
|
+
// Dev-only: create debug channel for React Performance Tracks
|
|
205
|
+
const debugId = (import.meta as any).hot
|
|
206
|
+
? crypto.randomUUID()
|
|
207
|
+
: undefined;
|
|
208
|
+
const debugChannel = debugId
|
|
209
|
+
? createClientDebugChannel(debugId)
|
|
210
|
+
: undefined;
|
|
211
|
+
|
|
202
212
|
// Send action request with stream tracking
|
|
203
213
|
const responsePromise = fetch(url, {
|
|
204
214
|
method: "POST",
|
|
@@ -210,6 +220,7 @@ export function createServerActionBridge(
|
|
|
210
220
|
...(interceptSourceUrl && {
|
|
211
221
|
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
212
222
|
}),
|
|
223
|
+
...(debugId && { [DEBUG_ID_HEADER]: debugId }),
|
|
213
224
|
},
|
|
214
225
|
body: encodedBody,
|
|
215
226
|
signal: fetchAbort.signal,
|
|
@@ -272,6 +283,7 @@ export function createServerActionBridge(
|
|
|
272
283
|
try {
|
|
273
284
|
payload = await deps.createFromFetch<RscPayload>(responsePromise, {
|
|
274
285
|
temporaryReferences,
|
|
286
|
+
...(debugChannel && { debugChannel, findSourceMapURL }),
|
|
275
287
|
});
|
|
276
288
|
} catch (error) {
|
|
277
289
|
// Clean up streaming token on error (may be null if fetch failed before .then() ran)
|