@rangojs/router 0.0.0-experimental.f3c21dba → 0.0.0-experimental.f681f24a
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 +153 -5
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +8 -0
- package/skills/loader/SKILL.md +53 -43
- package/skills/parallel/SKILL.md +67 -0
- package/skills/route/SKILL.md +31 -0
- package/skills/router-setup/SKILL.md +52 -2
- package/skills/typesafety/SKILL.md +10 -0
- package/src/browser/debug-channel.ts +93 -0
- package/src/browser/navigation-client.ts +17 -1
- package/src/browser/partial-update.ts +11 -0
- package/src/browser/prefetch/queue.ts +61 -29
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/NavigationProvider.tsx +5 -3
- package/src/browser/server-action-bridge.ts +12 -0
- package/src/browser/types.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +46 -5
- package/src/cache/taint.ts +55 -0
- package/src/context-var.ts +72 -2
- package/src/deps/browser.ts +1 -0
- package/src/route-definition/helpers-types.ts +6 -5
- package/src/router/handler-context.ts +31 -8
- package/src/router/loader-resolution.ts +7 -1
- package/src/router/match-middleware/background-revalidation.ts +12 -1
- package/src/router/match-middleware/cache-lookup.ts +12 -5
- package/src/router/match-middleware/cache-store.ts +21 -4
- package/src/router/match-result.ts +11 -5
- package/src/router/middleware-types.ts +6 -2
- package/src/router/middleware.ts +2 -2
- package/src/router/router-context.ts +1 -0
- package/src/router/segment-resolution/fresh.ts +12 -6
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/revalidation.ts +9 -2
- package/src/router/types.ts +1 -0
- package/src/router.ts +1 -0
- package/src/rsc/handler.ts +28 -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/context.ts +12 -0
- package/src/server/request-context.ts +49 -8
- package/src/types/handler-context.ts +20 -8
- package/src/types/loader-types.ts +4 -4
- package/src/vite/plugins/performance-tracks.ts +231 -0
- package/src/vite/rango.ts +4 -0
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,21 @@ 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
|
+
console.log("[perf-tracks] handler: debugId header =", debugId);
|
|
433
|
+
if (debugId) {
|
|
434
|
+
const channel = createServerDebugChannel(debugId);
|
|
435
|
+
console.log(
|
|
436
|
+
"[perf-tracks] handler: channel =",
|
|
437
|
+
channel ? "created" : "NOT FOUND",
|
|
438
|
+
);
|
|
439
|
+
if (channel) {
|
|
440
|
+
requestContext._debugChannel = channel;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
421
444
|
// Wire background error reporting so "use cache" and other subsystems
|
|
422
445
|
// can surface non-fatal errors through the router's onError callback.
|
|
423
446
|
requestContext._reportBackgroundError = (
|
|
@@ -1039,7 +1062,10 @@ export function createRSCHandler<
|
|
|
1039
1062
|
},
|
|
1040
1063
|
};
|
|
1041
1064
|
|
|
1042
|
-
const
|
|
1065
|
+
const debugChannel = requireRequestContext()._debugChannel;
|
|
1066
|
+
const rscStream = renderToReadableStream(payload, {
|
|
1067
|
+
...(debugChannel && { debugChannel }),
|
|
1068
|
+
});
|
|
1043
1069
|
|
|
1044
1070
|
// Determine if this is an RSC request or HTML request.
|
|
1045
1071
|
// 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
|
/**
|
package/src/server/context.ts
CHANGED
|
@@ -273,6 +273,9 @@ interface HelperContext {
|
|
|
273
273
|
string,
|
|
274
274
|
import("../cache/profile-registry.js").CacheProfile
|
|
275
275
|
>;
|
|
276
|
+
/** True when resolving handlers inside a cache() DSL boundary.
|
|
277
|
+
* Read by ctx.get() to guard non-cacheable variable reads. */
|
|
278
|
+
insideCacheScope?: boolean;
|
|
276
279
|
}
|
|
277
280
|
// Use a global symbol key so the AsyncLocalStorage instance survives HMR
|
|
278
281
|
// module re-evaluation. Without this, Vite's RSC module runner may create
|
|
@@ -666,3 +669,12 @@ export function track(label: string, depth?: number): () => void {
|
|
|
666
669
|
});
|
|
667
670
|
};
|
|
668
671
|
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Check if the current execution is inside a cache() DSL boundary.
|
|
675
|
+
* Returns false inside loader execution — loaders are always fresh
|
|
676
|
+
* (never cached), so non-cacheable reads are safe.
|
|
677
|
+
*/
|
|
678
|
+
export function isInsideCacheScope(): boolean {
|
|
679
|
+
return RSCRouterContext.getStore()?.insideCacheScope === true;
|
|
680
|
+
}
|
|
@@ -20,7 +20,12 @@ import type {
|
|
|
20
20
|
DefaultRouteName,
|
|
21
21
|
} from "../types/global-namespace.js";
|
|
22
22
|
import type { Handle } from "../handle.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
type ContextVar,
|
|
25
|
+
contextGet,
|
|
26
|
+
contextSet,
|
|
27
|
+
isNonCacheable,
|
|
28
|
+
} from "../context-var.js";
|
|
24
29
|
import { createHandleStore, type HandleStore } from "./handle-store.js";
|
|
25
30
|
import { isHandle } from "../handle.js";
|
|
26
31
|
import { track, type MetricsStore } from "./context.js";
|
|
@@ -30,6 +35,7 @@ import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
|
|
|
30
35
|
import { THEME_COOKIE } from "../theme/constants.js";
|
|
31
36
|
import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
|
|
32
37
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
38
|
+
import { isInsideCacheScope } from "./context.js";
|
|
33
39
|
import {
|
|
34
40
|
createReverseFunction,
|
|
35
41
|
stripInternalParams,
|
|
@@ -72,8 +78,12 @@ export interface RequestContext<
|
|
|
72
78
|
};
|
|
73
79
|
/** Set a variable (shared with middleware and handlers) */
|
|
74
80
|
set: {
|
|
75
|
-
<T>(
|
|
76
|
-
|
|
81
|
+
<T>(
|
|
82
|
+
contextVar: ContextVar<T>,
|
|
83
|
+
value: T,
|
|
84
|
+
options?: { cache?: boolean },
|
|
85
|
+
): void;
|
|
86
|
+
<K extends string>(key: K, value: any, options?: { cache?: boolean }): void;
|
|
77
87
|
};
|
|
78
88
|
/**
|
|
79
89
|
* Route params (populated after route matching)
|
|
@@ -277,6 +287,12 @@ export interface RequestContext<
|
|
|
277
287
|
|
|
278
288
|
/** @internal Request-scoped performance metrics store */
|
|
279
289
|
_metricsStore?: MetricsStore;
|
|
290
|
+
|
|
291
|
+
/** @internal Dev-only: debug channel for React Performance Tracks */
|
|
292
|
+
_debugChannel?: {
|
|
293
|
+
readable: ReadableStream;
|
|
294
|
+
writable: WritableStream;
|
|
295
|
+
};
|
|
280
296
|
}
|
|
281
297
|
|
|
282
298
|
/**
|
|
@@ -306,6 +322,7 @@ export type PublicRequestContext<
|
|
|
306
322
|
| "_reportBackgroundError"
|
|
307
323
|
| "_debugPerformance"
|
|
308
324
|
| "_metricsStore"
|
|
325
|
+
| "_debugChannel"
|
|
309
326
|
| "_setStatus"
|
|
310
327
|
| "res"
|
|
311
328
|
>;
|
|
@@ -506,6 +523,18 @@ export function createRequestContext<TEnv>(
|
|
|
506
523
|
responseCookieCache = null;
|
|
507
524
|
};
|
|
508
525
|
|
|
526
|
+
// Guard: throw if a response-level side effect is called inside a cache() scope.
|
|
527
|
+
// Uses ALS to detect the scope (set during segment resolution).
|
|
528
|
+
function assertNotInsideCacheScopeALS(methodName: string): void {
|
|
529
|
+
if (isInsideCacheScope()) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
`ctx.${methodName}() cannot be called inside a cache() boundary. ` +
|
|
532
|
+
`On cache hit the handler is skipped, so this side effect would be lost. ` +
|
|
533
|
+
`Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
509
538
|
// Effective cookie read: response stub Set-Cookie wins, then original header.
|
|
510
539
|
// The stub IS the source of truth for same-request mutations.
|
|
511
540
|
const effectiveCookie = (name: string): string | undefined => {
|
|
@@ -570,11 +599,19 @@ export function createRequestContext<TEnv>(
|
|
|
570
599
|
pathname: url.pathname,
|
|
571
600
|
searchParams: cleanUrl.searchParams,
|
|
572
601
|
var: variables,
|
|
573
|
-
get: ((keyOrVar: any) =>
|
|
574
|
-
|
|
575
|
-
|
|
602
|
+
get: ((keyOrVar: any) => {
|
|
603
|
+
if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
|
|
604
|
+
throw new Error(
|
|
605
|
+
`ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` +
|
|
606
|
+
`The variable was created with { cache: false } or set with { cache: false }, ` +
|
|
607
|
+
`and its value would be stale on cache hit. Move the read outside the cached scope.`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
return contextGet(variables, keyOrVar);
|
|
611
|
+
}) as RequestContext<TEnv>["get"],
|
|
612
|
+
set: ((keyOrVar: any, value: any, options?: any) => {
|
|
576
613
|
assertNotInsideCacheExec(ctx, "set");
|
|
577
|
-
contextSet(variables, keyOrVar, value);
|
|
614
|
+
contextSet(variables, keyOrVar, value, options);
|
|
578
615
|
}) as RequestContext<TEnv>["set"],
|
|
579
616
|
params: {} as Record<string, string>,
|
|
580
617
|
|
|
@@ -612,6 +649,7 @@ export function createRequestContext<TEnv>(
|
|
|
612
649
|
|
|
613
650
|
setCookie(name: string, value: string, options?: CookieOptions): void {
|
|
614
651
|
assertNotInsideCacheExec(ctx, "setCookie");
|
|
652
|
+
assertNotInsideCacheScopeALS("setCookie");
|
|
615
653
|
stubResponse.headers.append(
|
|
616
654
|
"Set-Cookie",
|
|
617
655
|
serializeCookieValue(name, value, options),
|
|
@@ -624,6 +662,7 @@ export function createRequestContext<TEnv>(
|
|
|
624
662
|
options?: Pick<CookieOptions, "domain" | "path">,
|
|
625
663
|
): void {
|
|
626
664
|
assertNotInsideCacheExec(ctx, "deleteCookie");
|
|
665
|
+
assertNotInsideCacheScopeALS("deleteCookie");
|
|
627
666
|
stubResponse.headers.append(
|
|
628
667
|
"Set-Cookie",
|
|
629
668
|
serializeCookieValue(name, "", { ...options, maxAge: 0 }),
|
|
@@ -633,11 +672,13 @@ export function createRequestContext<TEnv>(
|
|
|
633
672
|
|
|
634
673
|
header(name: string, value: string): void {
|
|
635
674
|
assertNotInsideCacheExec(ctx, "header");
|
|
675
|
+
assertNotInsideCacheScopeALS("header");
|
|
636
676
|
stubResponse.headers.set(name, value);
|
|
637
677
|
},
|
|
638
678
|
|
|
639
679
|
setStatus(status: number): void {
|
|
640
680
|
assertNotInsideCacheExec(ctx, "setStatus");
|
|
681
|
+
assertNotInsideCacheScopeALS("setStatus");
|
|
641
682
|
stubResponse = new Response(null, {
|
|
642
683
|
status,
|
|
643
684
|
headers: stubResponse.headers,
|
|
@@ -676,6 +717,7 @@ export function createRequestContext<TEnv>(
|
|
|
676
717
|
|
|
677
718
|
onResponse(callback: (response: Response) => Response): void {
|
|
678
719
|
assertNotInsideCacheExec(ctx, "onResponse");
|
|
720
|
+
assertNotInsideCacheScopeALS("onResponse");
|
|
679
721
|
this._onResponseCallbacks.push(callback);
|
|
680
722
|
},
|
|
681
723
|
|
|
@@ -906,7 +948,6 @@ export function createUseFunction<TEnv>(
|
|
|
906
948
|
),
|
|
907
949
|
};
|
|
908
950
|
|
|
909
|
-
// Start loader execution with tracking
|
|
910
951
|
const doneLoader = track(`loader:${loader.$$id}`, 2);
|
|
911
952
|
const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => {
|
|
912
953
|
doneLoader();
|
|
@@ -272,8 +272,16 @@ export type HandlerContext<
|
|
|
272
272
|
* ```
|
|
273
273
|
*/
|
|
274
274
|
set: {
|
|
275
|
-
<T>(
|
|
276
|
-
|
|
275
|
+
<T>(
|
|
276
|
+
contextVar: ContextVar<T>,
|
|
277
|
+
value: T,
|
|
278
|
+
options?: { cache?: boolean },
|
|
279
|
+
): void;
|
|
280
|
+
} & (<K extends keyof DefaultVars>(
|
|
281
|
+
key: K,
|
|
282
|
+
value: DefaultVars[K],
|
|
283
|
+
options?: { cache?: boolean },
|
|
284
|
+
) => void);
|
|
277
285
|
/**
|
|
278
286
|
* Response headers. Headers set here are merged into the final response.
|
|
279
287
|
*
|
|
@@ -293,8 +301,11 @@ export type HandlerContext<
|
|
|
293
301
|
* and server components rendered within the request context.
|
|
294
302
|
*
|
|
295
303
|
* For loaders: Returns a promise that resolves to the loader data.
|
|
296
|
-
* Loaders are executed in parallel and memoized per request
|
|
297
|
-
* `
|
|
304
|
+
* Loaders are executed in parallel and memoized per request.
|
|
305
|
+
* Prefer DSL `loader()` + client `useLoader()` over `ctx.use(Loader)` —
|
|
306
|
+
* DSL loaders are always fresh and cache-safe. Use `ctx.use(Loader)` only
|
|
307
|
+
* when you need loader data in the handler itself (e.g., to set context
|
|
308
|
+
* variables or make routing decisions).
|
|
298
309
|
*
|
|
299
310
|
* For handles: Returns a push function to add data for this segment.
|
|
300
311
|
* Handle data accumulates across all matched route segments.
|
|
@@ -302,10 +313,11 @@ export type HandlerContext<
|
|
|
302
313
|
*
|
|
303
314
|
* @example
|
|
304
315
|
* ```typescript
|
|
305
|
-
* // Loader
|
|
306
|
-
* route("
|
|
307
|
-
* const
|
|
308
|
-
*
|
|
316
|
+
* // Loader escape hatch — use when handler needs the data directly
|
|
317
|
+
* route("product", async (ctx) => {
|
|
318
|
+
* const { product } = await ctx.use(ProductLoader);
|
|
319
|
+
* ctx.set(Product, product); // make available to children
|
|
320
|
+
* return <ProductPage />;
|
|
309
321
|
* });
|
|
310
322
|
*
|
|
311
323
|
* // Handle usage - direct value
|
|
@@ -166,11 +166,11 @@ export type LoadOptions =
|
|
|
166
166
|
* return await db.products.findBySlug(slug);
|
|
167
167
|
* });
|
|
168
168
|
*
|
|
169
|
-
* //
|
|
170
|
-
* const
|
|
169
|
+
* // Client usage (preferred — cache-safe, always fresh)
|
|
170
|
+
* const { data } = useLoader(CartLoader);
|
|
171
171
|
*
|
|
172
|
-
* //
|
|
173
|
-
* const cart =
|
|
172
|
+
* // Server escape hatch (handler needs data directly)
|
|
173
|
+
* const cart = await ctx.use(CartLoader);
|
|
174
174
|
* ```
|
|
175
175
|
*/
|
|
176
176
|
export type LoaderDefinition<
|
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
* Uses globalThis to share state between the Vite plugin (main process)
|
|
16
|
+
* and the RSC handler (RSC module graph) — they run in the same Node.js
|
|
17
|
+
* process but different module evaluation contexts.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Plugin } from "vite";
|
|
21
|
+
|
|
22
|
+
export const DEBUG_ID_HEADER = "X-RSC-Debug-Id";
|
|
23
|
+
const DEBUG_S2C_EVENT = "rango:perf-s2c";
|
|
24
|
+
const DEBUG_C2S_EVENT = "rango:perf-c2s";
|
|
25
|
+
|
|
26
|
+
type DebugPayload =
|
|
27
|
+
| { i: string; b: string } // chunk (base64)
|
|
28
|
+
| { i: string; d: true }; // done
|
|
29
|
+
|
|
30
|
+
interface DebugSession {
|
|
31
|
+
cmdController?: ReadableStreamDefaultController<Uint8Array>;
|
|
32
|
+
pendingChunks?: Uint8Array[];
|
|
33
|
+
ended: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type DebugChannelRegistry = {
|
|
37
|
+
channels: Map<
|
|
38
|
+
string,
|
|
39
|
+
{
|
|
40
|
+
readable: ReadableStream<Uint8Array>;
|
|
41
|
+
writable: WritableStream<Uint8Array>;
|
|
42
|
+
}
|
|
43
|
+
>;
|
|
44
|
+
sessions: Map<string, DebugSession>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const GLOBAL_KEY = "__RANGO_DEBUG_CHANNELS__";
|
|
48
|
+
|
|
49
|
+
function getRegistry(): DebugChannelRegistry {
|
|
50
|
+
return ((globalThis as any)[GLOBAL_KEY] ??= {
|
|
51
|
+
channels: new Map(),
|
|
52
|
+
sessions: new Map(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a debug channel for a given request.
|
|
58
|
+
* Called by the RSC handler for each request that has a debugId.
|
|
59
|
+
* Returns the { readable, writable } pair for renderToReadableStream.
|
|
60
|
+
*
|
|
61
|
+
* This works across module graphs because the channel is pre-created
|
|
62
|
+
* by the Vite plugin and stored on globalThis.
|
|
63
|
+
*/
|
|
64
|
+
export function createServerDebugChannel(debugId: string): {
|
|
65
|
+
readable: ReadableStream<Uint8Array>;
|
|
66
|
+
writable: WritableStream<Uint8Array>;
|
|
67
|
+
} | null {
|
|
68
|
+
const registry = getRegistry();
|
|
69
|
+
const channel = registry.channels.get(debugId);
|
|
70
|
+
if (channel) {
|
|
71
|
+
registry.channels.delete(debugId);
|
|
72
|
+
console.log("[perf-tracks] debug channel attached for", debugId);
|
|
73
|
+
return channel;
|
|
74
|
+
}
|
|
75
|
+
console.log(
|
|
76
|
+
"[perf-tracks] no channel found for",
|
|
77
|
+
debugId,
|
|
78
|
+
"channels:",
|
|
79
|
+
registry.channels.size,
|
|
80
|
+
);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const bytesToBase64 = (bytes: Uint8Array) =>
|
|
85
|
+
Buffer.from(bytes).toString("base64");
|
|
86
|
+
|
|
87
|
+
const base64ToBytes = (base64: string) =>
|
|
88
|
+
new Uint8Array(Buffer.from(base64, "base64"));
|
|
89
|
+
|
|
90
|
+
export function performanceTracksPlugin(): Plugin {
|
|
91
|
+
return {
|
|
92
|
+
name: "@rangojs/router:performance-tracks",
|
|
93
|
+
apply: "serve",
|
|
94
|
+
enforce: "pre",
|
|
95
|
+
|
|
96
|
+
configureServer(server) {
|
|
97
|
+
const hot = server.environments.client.hot;
|
|
98
|
+
const registry = getRegistry();
|
|
99
|
+
const sessions = registry.sessions;
|
|
100
|
+
|
|
101
|
+
const sendChunk = (debugId: string, chunk: Uint8Array) => {
|
|
102
|
+
hot.send(DEBUG_S2C_EVENT, {
|
|
103
|
+
i: debugId,
|
|
104
|
+
b: bytesToBase64(chunk),
|
|
105
|
+
} satisfies DebugPayload);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const cleanupIfEnded = (debugId: string, session: DebugSession) => {
|
|
109
|
+
if (session.pendingChunks || !session.ended) return;
|
|
110
|
+
sessions.delete(debugId);
|
|
111
|
+
hot.send(DEBUG_S2C_EVENT, {
|
|
112
|
+
i: debugId,
|
|
113
|
+
d: true,
|
|
114
|
+
} satisfies DebugPayload);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const registerDebugChannel = (debugId: string) => {
|
|
118
|
+
let session = sessions.get(debugId);
|
|
119
|
+
if (!session) {
|
|
120
|
+
session = { pendingChunks: [], ended: false };
|
|
121
|
+
sessions.set(debugId, session);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Readable: receives client-to-server commands via WS
|
|
125
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
126
|
+
start(controller) {
|
|
127
|
+
session!.cmdController = controller;
|
|
128
|
+
},
|
|
129
|
+
cancel() {
|
|
130
|
+
delete session!.cmdController;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Writable: React writes debug data here, we forward to client via WS
|
|
135
|
+
const writable = new WritableStream<Uint8Array>({
|
|
136
|
+
write(chunk) {
|
|
137
|
+
if (session!.pendingChunks) {
|
|
138
|
+
session!.pendingChunks.push(chunk);
|
|
139
|
+
} else {
|
|
140
|
+
sendChunk(debugId, chunk);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
close() {
|
|
144
|
+
session!.ended = true;
|
|
145
|
+
cleanupIfEnded(debugId, session!);
|
|
146
|
+
},
|
|
147
|
+
abort() {
|
|
148
|
+
session!.ended = true;
|
|
149
|
+
cleanupIfEnded(debugId, session!);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Store on globalThis so the RSC handler can retrieve it
|
|
154
|
+
registry.channels.set(debugId, { readable, writable });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Listen for client-to-server debug messages
|
|
158
|
+
// Payload shapes: { i, d: true } (done), { i, b } (chunk), { i } (ready)
|
|
159
|
+
hot.on(DEBUG_C2S_EVENT, (raw: unknown) => {
|
|
160
|
+
const payload = raw as { i: string; b?: string; d?: true };
|
|
161
|
+
const session = sessions.get(payload.i);
|
|
162
|
+
|
|
163
|
+
if (payload.d) {
|
|
164
|
+
if (session?.cmdController) {
|
|
165
|
+
try {
|
|
166
|
+
session.cmdController.close();
|
|
167
|
+
} catch {
|
|
168
|
+
// ignore
|
|
169
|
+
}
|
|
170
|
+
delete session.cmdController;
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (payload.b) {
|
|
176
|
+
if (session?.cmdController) {
|
|
177
|
+
try {
|
|
178
|
+
session.cmdController.enqueue(base64ToBytes(payload.b));
|
|
179
|
+
} catch {
|
|
180
|
+
delete session!.cmdController;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Ready signal — flush pending chunks
|
|
187
|
+
if (session) {
|
|
188
|
+
if (session.pendingChunks) {
|
|
189
|
+
for (const chunk of session.pendingChunks) {
|
|
190
|
+
sendChunk(payload.i, chunk);
|
|
191
|
+
}
|
|
192
|
+
delete session.pendingChunks;
|
|
193
|
+
}
|
|
194
|
+
cleanupIfEnded(payload.i, session);
|
|
195
|
+
} else {
|
|
196
|
+
sessions.set(payload.i, { ended: false });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Register middleware directly (not as post-hook) so it runs
|
|
201
|
+
// BEFORE the RSC handler — the channel must exist before rendering.
|
|
202
|
+
server.middlewares.use((req: any, _res: any, next: any) => {
|
|
203
|
+
const existingId = req.headers[DEBUG_ID_HEADER.toLowerCase()] as string;
|
|
204
|
+
const isHtml = req.headers.accept?.includes("text/html");
|
|
205
|
+
|
|
206
|
+
if (!existingId && !isHtml) {
|
|
207
|
+
next();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const debugId = existingId || crypto.randomUUID();
|
|
212
|
+
if (!existingId) {
|
|
213
|
+
const lowerName = DEBUG_ID_HEADER.toLowerCase();
|
|
214
|
+
req.headers[lowerName] = debugId;
|
|
215
|
+
if (req.rawHeaders) {
|
|
216
|
+
req.rawHeaders.push(DEBUG_ID_HEADER, debugId);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
registerDebugChannel(debugId);
|
|
221
|
+
console.log(
|
|
222
|
+
"[perf-tracks] middleware: created channel for",
|
|
223
|
+
debugId,
|
|
224
|
+
"from",
|
|
225
|
+
existingId ? "client header" : "SSR inject",
|
|
226
|
+
);
|
|
227
|
+
next();
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
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
|
}
|