@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1b930379
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/README.md +46 -12
- package/dist/bin/rango.js +109 -15
- package/dist/vite/index.js +323 -121
- package/package.json +15 -16
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/caching/SKILL.md +4 -4
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/hooks/SKILL.md +33 -31
- package/skills/host-router/SKILL.md +218 -0
- package/skills/loader/SKILL.md +55 -15
- package/skills/prerender/SKILL.md +2 -2
- package/skills/rango/SKILL.md +0 -1
- package/skills/route/SKILL.md +3 -4
- package/skills/router-setup/SKILL.md +8 -3
- package/skills/typesafety/SKILL.md +25 -23
- package/src/__internal.ts +92 -0
- package/src/bin/rango.ts +18 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +95 -5
- package/src/browser/navigation-client.ts +97 -72
- package/src/browser/prefetch/cache.ts +112 -25
- package/src/browser/prefetch/fetch.ts +28 -30
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/react/Link.tsx +19 -7
- package/src/browser/rsc-router.tsx +11 -2
- package/src/browser/server-action-bridge.ts +448 -432
- package/src/browser/types.ts +24 -0
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-trie.ts +19 -3
- package/src/build/route-types/router-processing.ts +125 -15
- package/src/client.rsc.tsx +2 -1
- package/src/client.tsx +1 -46
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/host/index.ts +0 -3
- package/src/index.rsc.ts +5 -36
- package/src/index.ts +32 -66
- package/src/prerender/store.ts +56 -15
- package/src/route-definition/index.ts +0 -3
- package/src/router/handler-context.ts +30 -3
- package/src/router/loader-resolution.ts +1 -1
- package/src/router/match-api.ts +1 -1
- package/src/router/match-result.ts +0 -9
- package/src/router/metrics.ts +233 -13
- package/src/router/middleware-types.ts +53 -10
- package/src/router/middleware.ts +170 -81
- package/src/router/pattern-matching.ts +20 -5
- package/src/router/prerender-match.ts +4 -0
- package/src/router/revalidation.ts +27 -7
- package/src/router/router-interfaces.ts +14 -1
- package/src/router/router-options.ts +13 -8
- package/src/router/segment-resolution/fresh.ts +18 -0
- package/src/router/segment-resolution/helpers.ts +1 -1
- package/src/router/segment-resolution/revalidation.ts +22 -9
- package/src/router/trie-matching.ts +20 -2
- package/src/router.ts +29 -9
- package/src/rsc/handler.ts +106 -11
- package/src/rsc/index.ts +0 -20
- package/src/rsc/progressive-enhancement.ts +21 -8
- package/src/rsc/rsc-rendering.ts +30 -43
- package/src/rsc/server-action.ts +14 -10
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +2 -0
- package/src/search-params.ts +16 -13
- package/src/server/context.ts +8 -2
- package/src/server/request-context.ts +38 -16
- package/src/server.ts +6 -0
- package/src/theme/index.ts +4 -13
- package/src/types/handler-context.ts +12 -16
- package/src/types/route-config.ts +17 -8
- package/src/types/segments.ts +0 -5
- package/src/vite/discovery/bundle-postprocess.ts +31 -56
- package/src/vite/discovery/discover-routers.ts +18 -4
- package/src/vite/discovery/prerender-collection.ts +34 -14
- package/src/vite/discovery/state.ts +4 -7
- package/src/vite/index.ts +4 -3
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/refresh-cmd.ts +65 -0
- package/src/vite/rango.ts +11 -0
- package/src/vite/router-discovery.ts +16 -0
- package/src/vite/utils/prerender-utils.ts +60 -0
- package/skills/testing/SKILL.md +0 -226
- package/src/route-definition/route-function.ts +0 -119
- /package/{CLAUDE.md → AGENTS.md} +0 -0
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
getLocationState,
|
|
13
13
|
} from "../server/request-context.js";
|
|
14
14
|
import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
|
|
15
|
+
import { appendMetric } from "../router/metrics.js";
|
|
16
|
+
import { getSSRSetup } from "./ssr-setup.js";
|
|
15
17
|
import type { RscPayload } from "./types.js";
|
|
16
18
|
import {
|
|
17
19
|
createResponseWithMergedHeaders,
|
|
@@ -28,13 +30,9 @@ export async function handleRscRendering<TEnv>(
|
|
|
28
30
|
handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
|
|
29
31
|
nonce: string | undefined,
|
|
30
32
|
): Promise<Response> {
|
|
31
|
-
// Retrieve handler-level timing from variables
|
|
32
33
|
const reqCtx = requireRequestContext();
|
|
33
|
-
const handlerTimingArr: string[] = reqCtx.var.__handlerTiming || [];
|
|
34
|
-
const handlerStart: number = reqCtx.var.__handlerStart || 0;
|
|
35
34
|
|
|
36
35
|
let payload: RscPayload;
|
|
37
|
-
let serverTiming: string | undefined;
|
|
38
36
|
let hasInterceptSlots = false;
|
|
39
37
|
|
|
40
38
|
if (isPartial) {
|
|
@@ -53,8 +51,6 @@ export async function handleRscRendering<TEnv>(
|
|
|
53
51
|
return createSimpleRedirectResponse(match.redirect);
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
serverTiming = match.serverTiming;
|
|
57
|
-
|
|
58
54
|
payload = {
|
|
59
55
|
metadata: {
|
|
60
56
|
pathname: url.pathname,
|
|
@@ -66,13 +62,14 @@ export async function handleRscRendering<TEnv>(
|
|
|
66
62
|
rootLayout: ctx.router.rootLayout,
|
|
67
63
|
handles: handleStore.stream(),
|
|
68
64
|
version: ctx.version,
|
|
65
|
+
prefetchCacheTTL: ctx.router.prefetchCacheTTL,
|
|
69
66
|
themeConfig: ctx.router.themeConfig,
|
|
70
67
|
initialTheme: reqCtx.theme,
|
|
71
68
|
},
|
|
72
69
|
};
|
|
73
70
|
} else {
|
|
74
71
|
setRequestContextParams(result.params, result.routeName);
|
|
75
|
-
|
|
72
|
+
|
|
76
73
|
hasInterceptSlots = !!result.slots;
|
|
77
74
|
|
|
78
75
|
payload = {
|
|
@@ -86,6 +83,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
86
83
|
slots: result.slots,
|
|
87
84
|
handles: handleStore.stream(),
|
|
88
85
|
version: ctx.version,
|
|
86
|
+
prefetchCacheTTL: ctx.router.prefetchCacheTTL,
|
|
89
87
|
},
|
|
90
88
|
};
|
|
91
89
|
}
|
|
@@ -132,8 +130,6 @@ export async function handleRscRendering<TEnv>(
|
|
|
132
130
|
{ headers: { "Content-Type": "application/json" } },
|
|
133
131
|
);
|
|
134
132
|
} else {
|
|
135
|
-
serverTiming = match.serverTiming;
|
|
136
|
-
|
|
137
133
|
payload = {
|
|
138
134
|
// Initial SSR can reconstruct the tree from segments + rootLayout,
|
|
139
135
|
// so we omit root to avoid sending the same structure twice.
|
|
@@ -148,6 +144,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
148
144
|
rootLayout: ctx.router.rootLayout,
|
|
149
145
|
handles: handleStore.stream(),
|
|
150
146
|
version: ctx.version,
|
|
147
|
+
prefetchCacheTTL: ctx.router.prefetchCacheTTL,
|
|
151
148
|
themeConfig: ctx.router.themeConfig,
|
|
152
149
|
initialTheme: reqCtx.theme,
|
|
153
150
|
},
|
|
@@ -166,10 +163,20 @@ export async function handleRscRendering<TEnv>(
|
|
|
166
163
|
}
|
|
167
164
|
}
|
|
168
165
|
|
|
166
|
+
const metricsStore = reqCtx._metricsStore;
|
|
167
|
+
const renderStart = performance.now();
|
|
168
|
+
|
|
169
169
|
// Serialize to RSC stream
|
|
170
170
|
const rscSerializeStart = performance.now();
|
|
171
171
|
const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
|
|
172
172
|
const rscSerializeDur = performance.now() - rscSerializeStart;
|
|
173
|
+
// This measures synchronous stream creation, not end-to-end stream consumption.
|
|
174
|
+
appendMetric(
|
|
175
|
+
metricsStore,
|
|
176
|
+
"rsc-serialize",
|
|
177
|
+
rscSerializeStart,
|
|
178
|
+
rscSerializeDur,
|
|
179
|
+
);
|
|
173
180
|
|
|
174
181
|
// Determine if this is an RSC request or HTML request.
|
|
175
182
|
// Partial requests (_rsc_partial) are always RSC -- they come from client-side
|
|
@@ -181,15 +188,9 @@ export async function handleRscRendering<TEnv>(
|
|
|
181
188
|
!url.searchParams.has("__html")) ||
|
|
182
189
|
url.searchParams.has("__rsc");
|
|
183
190
|
|
|
184
|
-
// Build complete Server-Timing: handler phases + match/manifest + RSC serialize
|
|
185
|
-
const timingParts: string[] = [...handlerTimingArr];
|
|
186
|
-
if (serverTiming) {
|
|
187
|
-
timingParts.push(serverTiming);
|
|
188
|
-
}
|
|
189
|
-
timingParts.push(`rsc-serialize;dur=${rscSerializeDur.toFixed(2)}`);
|
|
190
|
-
|
|
191
191
|
if (isRscRequest) {
|
|
192
|
-
const
|
|
192
|
+
const renderDur = performance.now() - renderStart;
|
|
193
|
+
appendMetric(metricsStore, "render:total", renderStart, renderDur);
|
|
193
194
|
const rscHeaders: Record<string, string> = {
|
|
194
195
|
"content-type": "text/x-component;charset=utf-8",
|
|
195
196
|
vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
|
|
@@ -205,22 +206,19 @@ export async function handleRscRendering<TEnv>(
|
|
|
205
206
|
rscHeaders["cache-control"] = cc;
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
|
-
if (fullTiming) {
|
|
209
|
-
rscHeaders["Server-Timing"] = fullTiming;
|
|
210
|
-
}
|
|
211
209
|
return createResponseWithMergedHeaders(rscStream, {
|
|
212
210
|
headers: rscHeaders,
|
|
213
211
|
});
|
|
214
212
|
}
|
|
215
213
|
|
|
216
|
-
// Delegate to SSR for HTML response
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
214
|
+
// Delegate to SSR for HTML response (reuse early setup if available)
|
|
215
|
+
const [ssrModule, streamMode] = await getSSRSetup(
|
|
216
|
+
ctx,
|
|
217
|
+
request,
|
|
218
|
+
env,
|
|
219
|
+
url,
|
|
220
|
+
metricsStore,
|
|
221
|
+
);
|
|
224
222
|
|
|
225
223
|
const ssrRenderStart = performance.now();
|
|
226
224
|
const htmlStream = await ssrModule.renderHTML(rscStream, {
|
|
@@ -228,23 +226,12 @@ export async function handleRscRendering<TEnv>(
|
|
|
228
226
|
streamMode,
|
|
229
227
|
});
|
|
230
228
|
const ssrRenderDur = performance.now() - ssrRenderStart;
|
|
231
|
-
|
|
229
|
+
appendMetric(metricsStore, "ssr-render-html", ssrRenderStart, ssrRenderDur);
|
|
232
230
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const totalHandler = performance.now() - handlerStart;
|
|
236
|
-
timingParts.push(`handler-total;dur=${totalHandler.toFixed(2)}`);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const fullTiming = timingParts.join(", ");
|
|
240
|
-
const htmlHeaders: Record<string, string> = {
|
|
241
|
-
"content-type": "text/html;charset=utf-8",
|
|
242
|
-
};
|
|
243
|
-
if (fullTiming) {
|
|
244
|
-
htmlHeaders["Server-Timing"] = fullTiming;
|
|
245
|
-
}
|
|
231
|
+
const renderDur = performance.now() - renderStart;
|
|
232
|
+
appendMetric(metricsStore, "render:total", renderStart, renderDur);
|
|
246
233
|
|
|
247
234
|
return createResponseWithMergedHeaders(htmlStream, {
|
|
248
|
-
headers:
|
|
235
|
+
headers: { "content-type": "text/html;charset=utf-8" },
|
|
249
236
|
});
|
|
250
237
|
}
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getLocationState,
|
|
22
22
|
} from "../server/request-context.js";
|
|
23
23
|
import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
|
|
24
|
+
import { appendMetric } from "../router/metrics.js";
|
|
24
25
|
import type { RscPayload } from "./types.js";
|
|
25
26
|
import {
|
|
26
27
|
hasBodyContent,
|
|
@@ -274,6 +275,8 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
274
275
|
): Promise<Response> {
|
|
275
276
|
const { returnValue, actionStatus, temporaryReferences, actionContext } =
|
|
276
277
|
continuation;
|
|
278
|
+
const reqCtx = requireRequestContext();
|
|
279
|
+
const metricsStore = reqCtx._metricsStore;
|
|
277
280
|
|
|
278
281
|
const matchResult = await ctx.router.matchPartial(
|
|
279
282
|
request,
|
|
@@ -308,8 +311,6 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
308
311
|
// Return updated segments
|
|
309
312
|
setRequestContextParams(matchResult.params, matchResult.routeName);
|
|
310
313
|
|
|
311
|
-
const serverTiming = matchResult.serverTiming;
|
|
312
|
-
|
|
313
314
|
const payload: RscPayload = {
|
|
314
315
|
metadata: {
|
|
315
316
|
pathname: url.pathname,
|
|
@@ -326,19 +327,22 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
326
327
|
|
|
327
328
|
attachLocationState(payload);
|
|
328
329
|
|
|
330
|
+
const renderStart = performance.now();
|
|
329
331
|
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
330
332
|
temporaryReferences,
|
|
331
333
|
});
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
334
|
+
const rscSerializeDur = performance.now() - renderStart;
|
|
335
|
+
// This measures synchronous stream creation, not end-to-end stream consumption.
|
|
336
|
+
appendMetric(metricsStore, "rsc-serialize", renderStart, rscSerializeDur);
|
|
337
|
+
appendMetric(
|
|
338
|
+
metricsStore,
|
|
339
|
+
"render:total",
|
|
340
|
+
renderStart,
|
|
341
|
+
performance.now() - renderStart,
|
|
342
|
+
);
|
|
339
343
|
|
|
340
344
|
return createResponseWithMergedHeaders(rscStream, {
|
|
341
345
|
status: actionStatus,
|
|
342
|
-
headers:
|
|
346
|
+
headers: { "content-type": "text/x-component;charset=utf-8" },
|
|
343
347
|
});
|
|
344
348
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR Setup Utilities
|
|
3
|
+
*
|
|
4
|
+
* Manages early kickoff and retrieval of SSR module loading and stream mode
|
|
5
|
+
* resolution. Both operations are request-scoped but independent of route
|
|
6
|
+
* matching, so they can run in parallel with segment resolution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HandlerContext } from "./handler-context.js";
|
|
10
|
+
import type { SSRModule } from "./types.js";
|
|
11
|
+
import type { SSRStreamMode } from "../router/router-options.js";
|
|
12
|
+
import type { MetricsStore } from "../server/context.js";
|
|
13
|
+
import { appendMetric } from "../router/metrics.js";
|
|
14
|
+
import { _getRequestContext } from "../server/request-context.js";
|
|
15
|
+
|
|
16
|
+
export type SSRSetup = readonly [SSRModule, SSRStreamMode];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Key used to stash the early SSR setup promise on request variables.
|
|
20
|
+
* Read back via `getSSRSetup`.
|
|
21
|
+
*/
|
|
22
|
+
export const SSR_SETUP_VAR = "__ssrSetup";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Start loading the SSR module and resolving the stream mode in parallel.
|
|
26
|
+
* When a `getMetricsStore` getter is provided, records individual
|
|
27
|
+
* `ssr:module-load` and `ssr:stream-mode` metrics (the getter is called
|
|
28
|
+
* lazily so stores created after kickoff are still captured). Without a
|
|
29
|
+
* getter the promises run bare — no `.then()` microtasks, no
|
|
30
|
+
* `performance.now()` calls — keeping the non-debug hot path lean.
|
|
31
|
+
*/
|
|
32
|
+
export function startSSRSetup<TEnv>(
|
|
33
|
+
ctx: HandlerContext<TEnv>,
|
|
34
|
+
request: Request,
|
|
35
|
+
env: TEnv,
|
|
36
|
+
url: URL,
|
|
37
|
+
getMetricsStore?: () => MetricsStore | undefined,
|
|
38
|
+
): Promise<SSRSetup> {
|
|
39
|
+
if (!getMetricsStore) {
|
|
40
|
+
return Promise.all([
|
|
41
|
+
ctx.loadSSRModule(),
|
|
42
|
+
ctx.resolveStreamMode(request, env, url),
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
const start = performance.now();
|
|
46
|
+
return Promise.all([
|
|
47
|
+
ctx.loadSSRModule().then((mod) => {
|
|
48
|
+
appendMetric(
|
|
49
|
+
getMetricsStore(),
|
|
50
|
+
"ssr:module-load",
|
|
51
|
+
start,
|
|
52
|
+
performance.now() - start,
|
|
53
|
+
);
|
|
54
|
+
return mod;
|
|
55
|
+
}),
|
|
56
|
+
ctx.resolveStreamMode(request, env, url).then((mode) => {
|
|
57
|
+
appendMetric(
|
|
58
|
+
getMetricsStore(),
|
|
59
|
+
"ssr:stream-mode",
|
|
60
|
+
start,
|
|
61
|
+
performance.now() - start,
|
|
62
|
+
);
|
|
63
|
+
return mode;
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Retrieve the SSR setup result. Returns the early-kicked-off promise
|
|
70
|
+
* when available (stashed on request variables), otherwise starts a
|
|
71
|
+
* fresh setup.
|
|
72
|
+
*/
|
|
73
|
+
export function getSSRSetup<TEnv>(
|
|
74
|
+
ctx: HandlerContext<TEnv>,
|
|
75
|
+
request: Request,
|
|
76
|
+
env: TEnv,
|
|
77
|
+
url: URL,
|
|
78
|
+
metricsStore: MetricsStore | undefined,
|
|
79
|
+
): Promise<SSRSetup> {
|
|
80
|
+
const early = _getRequestContext()?.var?.[SSR_SETUP_VAR] as
|
|
81
|
+
| Promise<SSRSetup>
|
|
82
|
+
| undefined;
|
|
83
|
+
if (early) return early;
|
|
84
|
+
return startSSRSetup(
|
|
85
|
+
ctx,
|
|
86
|
+
request,
|
|
87
|
+
env,
|
|
88
|
+
url,
|
|
89
|
+
metricsStore ? () => metricsStore : undefined,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Classify whether a request may require SSR (HTML rendering).
|
|
95
|
+
*
|
|
96
|
+
* Returns false for requests that are definitively RSC-only, loader fetches,
|
|
97
|
+
* prerender collection, or Accept-based RSC (no text/html). This mirrors
|
|
98
|
+
* the isRscRequest decision in rsc-rendering.ts.
|
|
99
|
+
*
|
|
100
|
+
* Note: response/mime routes are excluded by the caller — this function
|
|
101
|
+
* runs after previewMatch() classifies the route type.
|
|
102
|
+
*/
|
|
103
|
+
export function mayNeedSSR(request: Request, url: URL): boolean {
|
|
104
|
+
if (
|
|
105
|
+
url.searchParams.has("_rsc_partial") ||
|
|
106
|
+
url.searchParams.has("_rsc_action") ||
|
|
107
|
+
request.headers.has("rsc-action") ||
|
|
108
|
+
url.searchParams.has("_rsc_loader") ||
|
|
109
|
+
url.searchParams.has("__rsc") ||
|
|
110
|
+
url.searchParams.has("__prerender_collect")
|
|
111
|
+
) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Mirror the Accept-based RSC decision from rsc-rendering.ts:
|
|
116
|
+
// if Accept is present and does not include text/html (and no __html override),
|
|
117
|
+
// the response will be RSC, not HTML.
|
|
118
|
+
const accept = request.headers.get("accept");
|
|
119
|
+
if (
|
|
120
|
+
accept &&
|
|
121
|
+
!accept.includes("text/html") &&
|
|
122
|
+
!url.searchParams.has("__html")
|
|
123
|
+
) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return true;
|
|
128
|
+
}
|
package/src/rsc/types.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface RscPayload {
|
|
|
32
32
|
handles?: AsyncGenerator<HandleData, void, unknown>;
|
|
33
33
|
/** RSC version string for cache invalidation */
|
|
34
34
|
version?: string;
|
|
35
|
+
/** TTL in milliseconds for the client-side in-memory prefetch cache */
|
|
36
|
+
prefetchCacheTTL?: number;
|
|
35
37
|
/** Theme configuration for FOUC prevention */
|
|
36
38
|
themeConfig?: ResolvedThemeConfig | null;
|
|
37
39
|
/** Initial theme from cookie (for SSR hydration) */
|
package/src/search-params.ts
CHANGED
|
@@ -55,14 +55,22 @@ type Simplify<T> = { [K in keyof T]: T[K] };
|
|
|
55
55
|
/**
|
|
56
56
|
* Resolve a SearchSchema to its typed object.
|
|
57
57
|
*
|
|
58
|
+
* Both required and optional params resolve to `T | undefined` at the handler
|
|
59
|
+
* level. The required/optional distinction is a consumer-facing contract
|
|
60
|
+
* (e.g., for href() and reverse() autocomplete) — it tells callers which
|
|
61
|
+
* params the route expects, but the handler must still check for undefined
|
|
62
|
+
* since the framework cannot trust the client to send all required params.
|
|
63
|
+
*
|
|
58
64
|
* @example
|
|
59
65
|
* type S = { q: "string"; page: "number?"; sort: "string?" };
|
|
60
66
|
* type R = ResolveSearchSchema<S>;
|
|
61
|
-
* // { q: string; page?: number; sort?: string }
|
|
67
|
+
* // { q: string | undefined; page?: number; sort?: string }
|
|
62
68
|
*/
|
|
63
69
|
export type ResolveSearchSchema<T extends SearchSchema> = Simplify<
|
|
64
70
|
{
|
|
65
|
-
[K in RequiredKeys<T> & string]:
|
|
71
|
+
[K in RequiredKeys<T> & string]:
|
|
72
|
+
| ResolveBaseType<BaseType<T[K]>>
|
|
73
|
+
| undefined;
|
|
66
74
|
} & {
|
|
67
75
|
[K in OptionalKeys<T> & string]?: ResolveBaseType<BaseType<T[K]>>;
|
|
68
76
|
}
|
|
@@ -166,7 +174,9 @@ type ExtractParamsFromPattern<T extends string> =
|
|
|
166
174
|
* - `"number"` / `"number?"` - coerced via `Number()`; NaN treated as missing
|
|
167
175
|
* - `"boolean"` / `"boolean?"` - `"true"` / `"1"` -> true, `"false"` / `"0"` / `""` -> false
|
|
168
176
|
*
|
|
169
|
-
* Missing
|
|
177
|
+
* Missing params (both required and optional) are omitted from the result
|
|
178
|
+
* (undefined). The required/optional distinction is a consumer-facing contract
|
|
179
|
+
* only — the handler must check for undefined.
|
|
170
180
|
*/
|
|
171
181
|
export function parseSearchParams<T extends SearchSchema>(
|
|
172
182
|
searchParams: URLSearchParams,
|
|
@@ -180,13 +190,7 @@ export function parseSearchParams<T extends SearchSchema>(
|
|
|
180
190
|
const raw = searchParams.get(key);
|
|
181
191
|
|
|
182
192
|
if (raw === null) {
|
|
183
|
-
|
|
184
|
-
// Required param missing: use zero value
|
|
185
|
-
if (baseType === "string") result[key] = "";
|
|
186
|
-
else if (baseType === "number") result[key] = 0;
|
|
187
|
-
else if (baseType === "boolean") result[key] = false;
|
|
188
|
-
}
|
|
189
|
-
// Optional params are omitted (undefined)
|
|
193
|
+
// Missing params are omitted (undefined) regardless of required/optional
|
|
190
194
|
continue;
|
|
191
195
|
}
|
|
192
196
|
|
|
@@ -194,11 +198,10 @@ export function parseSearchParams<T extends SearchSchema>(
|
|
|
194
198
|
result[key] = raw;
|
|
195
199
|
} else if (baseType === "number") {
|
|
196
200
|
const num = Number(raw);
|
|
197
|
-
if (Number.isNaN(num)) {
|
|
198
|
-
if (!isOptional) result[key] = 0;
|
|
199
|
-
} else {
|
|
201
|
+
if (!Number.isNaN(num)) {
|
|
200
202
|
result[key] = num;
|
|
201
203
|
}
|
|
204
|
+
// NaN treated as missing (undefined)
|
|
202
205
|
} else if (baseType === "boolean") {
|
|
203
206
|
result[key] = raw === "true" || raw === "1";
|
|
204
207
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface PerformanceMetric {
|
|
|
26
26
|
label: string; // e.g., "route-matching", "loader:UserLoader"
|
|
27
27
|
duration: number; // milliseconds
|
|
28
28
|
startTime: number; // relative to request start
|
|
29
|
+
depth?: number; // nesting level for hierarchical display (0 = top-level)
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -567,7 +568,7 @@ export type { HelperContext };
|
|
|
567
568
|
* done(); // Records duration
|
|
568
569
|
* ```
|
|
569
570
|
*/
|
|
570
|
-
export function track(label: string): () => void {
|
|
571
|
+
export function track(label: string, depth?: number): () => void {
|
|
571
572
|
const store = RSCRouterContext.getStore();
|
|
572
573
|
|
|
573
574
|
// No-op if context unavailable or metrics not enabled
|
|
@@ -580,6 +581,11 @@ export function track(label: string): () => void {
|
|
|
580
581
|
return () => {
|
|
581
582
|
const duration =
|
|
582
583
|
performance.now() - store.metrics!.requestStart - startTime;
|
|
583
|
-
store.metrics!.metrics.push({
|
|
584
|
+
store.metrics!.metrics.push({
|
|
585
|
+
label,
|
|
586
|
+
duration,
|
|
587
|
+
startTime,
|
|
588
|
+
...(depth != null ? { depth } : {}),
|
|
589
|
+
});
|
|
584
590
|
};
|
|
585
591
|
}
|
|
@@ -15,6 +15,7 @@ import type { CookieOptions } from "../router/middleware.js";
|
|
|
15
15
|
import type { LoaderDefinition, LoaderContext } from "../types.js";
|
|
16
16
|
import type { ScopedReverseFunction } from "../reverse.js";
|
|
17
17
|
import type {
|
|
18
|
+
DefaultEnv,
|
|
18
19
|
DefaultReverseRouteMap,
|
|
19
20
|
DefaultRouteName,
|
|
20
21
|
} from "../types/global-namespace.js";
|
|
@@ -22,7 +23,7 @@ import type { Handle } from "../handle.js";
|
|
|
22
23
|
import { type ContextVar, contextGet, contextSet } from "../context-var.js";
|
|
23
24
|
import { createHandleStore, type HandleStore } from "./handle-store.js";
|
|
24
25
|
import { isHandle } from "../handle.js";
|
|
25
|
-
import { track } from "./context.js";
|
|
26
|
+
import { track, type MetricsStore } from "./context.js";
|
|
26
27
|
import { getFetchableLoader } from "./fetchable-loader-store.js";
|
|
27
28
|
import type { SegmentCacheStore } from "../cache/types.js";
|
|
28
29
|
import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
|
|
@@ -41,15 +42,20 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
|
41
42
|
* Use this when you need access to request data outside of route handlers.
|
|
42
43
|
*/
|
|
43
44
|
export interface RequestContext<
|
|
44
|
-
TEnv =
|
|
45
|
+
TEnv = DefaultEnv,
|
|
45
46
|
TParams = Record<string, string>,
|
|
46
47
|
> {
|
|
47
48
|
/** Platform bindings (Cloudflare env, etc.) */
|
|
48
49
|
env: TEnv;
|
|
49
50
|
/** Original HTTP request */
|
|
50
51
|
request: Request;
|
|
51
|
-
/** Parsed URL (
|
|
52
|
+
/** Parsed URL (with internal `_rsc*` params stripped) */
|
|
52
53
|
url: URL;
|
|
54
|
+
/**
|
|
55
|
+
* The original request URL with all parameters intact, including
|
|
56
|
+
* internal `_rsc*` transport params.
|
|
57
|
+
*/
|
|
58
|
+
originalUrl: URL;
|
|
53
59
|
/** URL pathname */
|
|
54
60
|
pathname: string;
|
|
55
61
|
/** URL search params (system params like _rsc* are NOT filtered here) */
|
|
@@ -71,12 +77,7 @@ export interface RequestContext<
|
|
|
71
77
|
* Initially empty, then set to matched params
|
|
72
78
|
*/
|
|
73
79
|
params: TParams;
|
|
74
|
-
/**
|
|
75
|
-
* Stub response for setting headers/cookies (read-only).
|
|
76
|
-
* Headers set here are merged into the final response.
|
|
77
|
-
* Use header() or setStatus() to mutate response headers/status.
|
|
78
|
-
* Use cookies().set()/cookies().delete() for cookie mutations.
|
|
79
|
-
*/
|
|
80
|
+
/** @internal Stub response for collecting headers/cookies. Use ctx.headers or ctx.header() instead. */
|
|
80
81
|
readonly res: Response;
|
|
81
82
|
|
|
82
83
|
/** @internal Get a cookie value (effective: request + response mutations). Use cookies().get() instead. */
|
|
@@ -94,6 +95,8 @@ export interface RequestContext<
|
|
|
94
95
|
header(name: string, value: string): void;
|
|
95
96
|
/** Set the response status code */
|
|
96
97
|
setStatus(status: number): void;
|
|
98
|
+
/** @internal Set status bypassing cache-exec guard (for framework error handling) */
|
|
99
|
+
_setStatus(status: number): void;
|
|
97
100
|
|
|
98
101
|
/**
|
|
99
102
|
* Access loader data or push handle data.
|
|
@@ -265,6 +268,12 @@ export interface RequestContext<
|
|
|
265
268
|
* errors without failing the response.
|
|
266
269
|
*/
|
|
267
270
|
_reportBackgroundError?: (error: unknown, category: string) => void;
|
|
271
|
+
|
|
272
|
+
/** @internal Per-request debug performance override (set via ctx.debugPerformance()) */
|
|
273
|
+
_debugPerformance?: boolean;
|
|
274
|
+
|
|
275
|
+
/** @internal Request-scoped performance metrics store */
|
|
276
|
+
_metricsStore?: MetricsStore;
|
|
268
277
|
}
|
|
269
278
|
|
|
270
279
|
/**
|
|
@@ -274,7 +283,7 @@ export interface RequestContext<
|
|
|
274
283
|
* use the full RequestContext interface directly.
|
|
275
284
|
*/
|
|
276
285
|
export type PublicRequestContext<
|
|
277
|
-
TEnv =
|
|
286
|
+
TEnv = DefaultEnv,
|
|
278
287
|
TParams = Record<string, string>,
|
|
279
288
|
> = Omit<
|
|
280
289
|
RequestContext<TEnv, TParams>,
|
|
@@ -292,6 +301,10 @@ export type PublicRequestContext<
|
|
|
292
301
|
| "_prevRouteKey"
|
|
293
302
|
| "_reportedErrors"
|
|
294
303
|
| "_reportBackgroundError"
|
|
304
|
+
| "_debugPerformance"
|
|
305
|
+
| "_metricsStore"
|
|
306
|
+
| "_setStatus"
|
|
307
|
+
| "res"
|
|
295
308
|
>;
|
|
296
309
|
|
|
297
310
|
// AsyncLocalStorage instance for request context
|
|
@@ -312,7 +325,7 @@ export function runWithRequestContext<TEnv, T>(
|
|
|
312
325
|
* Get the current request context
|
|
313
326
|
* Throws if called outside of a request context
|
|
314
327
|
*/
|
|
315
|
-
export function getRequestContext<TEnv =
|
|
328
|
+
export function getRequestContext<TEnv = DefaultEnv>(): RequestContext<TEnv> {
|
|
316
329
|
const ctx = requestContextStorage.getStore() as
|
|
317
330
|
| RequestContext<TEnv>
|
|
318
331
|
| undefined;
|
|
@@ -329,7 +342,7 @@ export function getRequestContext<TEnv = unknown>(): RequestContext<TEnv> {
|
|
|
329
342
|
* @internal Get the request context without throwing — for internal code that
|
|
330
343
|
* may run outside a request context (cache stores, optional handle lookups, etc.)
|
|
331
344
|
*/
|
|
332
|
-
export function _getRequestContext<TEnv =
|
|
345
|
+
export function _getRequestContext<TEnv = DefaultEnv>():
|
|
333
346
|
| RequestContext<TEnv>
|
|
334
347
|
| undefined {
|
|
335
348
|
return requestContextStorage.getStore() as RequestContext<TEnv> | undefined;
|
|
@@ -394,7 +407,9 @@ export function getLocationState(): LocationStateEntry[] | undefined {
|
|
|
394
407
|
* Get the current request context, throwing if not available
|
|
395
408
|
* @deprecated Use getRequestContext() directly — it now throws if outside context
|
|
396
409
|
*/
|
|
397
|
-
export function requireRequestContext<
|
|
410
|
+
export function requireRequestContext<
|
|
411
|
+
TEnv = DefaultEnv,
|
|
412
|
+
>(): RequestContext<TEnv> {
|
|
398
413
|
return getRequestContext<TEnv>();
|
|
399
414
|
}
|
|
400
415
|
|
|
@@ -545,6 +560,7 @@ export function createRequestContext<TEnv>(
|
|
|
545
560
|
env,
|
|
546
561
|
request,
|
|
547
562
|
url,
|
|
563
|
+
originalUrl: new URL(request.url),
|
|
548
564
|
pathname: url.pathname,
|
|
549
565
|
searchParams: url.searchParams,
|
|
550
566
|
var: variables,
|
|
@@ -616,8 +632,13 @@ export function createRequestContext<TEnv>(
|
|
|
616
632
|
|
|
617
633
|
setStatus(status: number): void {
|
|
618
634
|
assertNotInsideCacheExec(ctx, "setStatus");
|
|
619
|
-
|
|
620
|
-
|
|
635
|
+
stubResponse = new Response(null, {
|
|
636
|
+
status,
|
|
637
|
+
headers: stubResponse.headers,
|
|
638
|
+
});
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
_setStatus(status: number): void {
|
|
621
642
|
stubResponse = new Response(null, {
|
|
622
643
|
status,
|
|
623
644
|
headers: stubResponse.headers,
|
|
@@ -674,6 +695,7 @@ export function createRequestContext<TEnv>(
|
|
|
674
695
|
_locationState: undefined,
|
|
675
696
|
|
|
676
697
|
_reportedErrors: new WeakSet<object>(),
|
|
698
|
+
_metricsStore: undefined,
|
|
677
699
|
|
|
678
700
|
reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
|
|
679
701
|
};
|
|
@@ -879,7 +901,7 @@ export function createUseFunction<TEnv>(
|
|
|
879
901
|
};
|
|
880
902
|
|
|
881
903
|
// Start loader execution with tracking
|
|
882
|
-
const doneLoader = track(`loader:${loader.$$id}
|
|
904
|
+
const doneLoader = track(`loader:${loader.$$id}`, 2);
|
|
883
905
|
const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => {
|
|
884
906
|
doneLoader();
|
|
885
907
|
});
|
package/src/server.ts
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
// Router registry (used by Vite plugin for build-time discovery)
|
|
12
12
|
export { RSC_ROUTER_BRAND, RouterRegistry } from "./router.js";
|
|
13
13
|
|
|
14
|
+
// Host router registry (used by Vite plugin for host-router lazy discovery)
|
|
15
|
+
export {
|
|
16
|
+
HostRouterRegistry,
|
|
17
|
+
type HostRouterRegistryEntry,
|
|
18
|
+
} from "./host/router.js";
|
|
19
|
+
|
|
14
20
|
// Route map builder (Vite plugin injects these via virtual modules)
|
|
15
21
|
export {
|
|
16
22
|
registerRouteMap,
|
package/src/theme/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Theme module exports for @rangojs/router/theme
|
|
3
3
|
*
|
|
4
|
-
* This module provides
|
|
4
|
+
* This module provides the public theme API:
|
|
5
5
|
* - useTheme: Hook for accessing theme state in client components
|
|
6
6
|
* - ThemeProvider: Component for manual theme provider setup (typically not needed)
|
|
7
|
+
* - ThemeScript: FOUC-prevention script component for document/head usage
|
|
7
8
|
* - Types for theme configuration
|
|
8
9
|
*
|
|
9
10
|
* @example
|
|
@@ -43,15 +44,5 @@ export type {
|
|
|
43
44
|
ThemeContextValue,
|
|
44
45
|
} from "./types.js";
|
|
45
46
|
|
|
46
|
-
// Constants
|
|
47
|
-
export {
|
|
48
|
-
THEME_DEFAULTS,
|
|
49
|
-
THEME_COOKIE,
|
|
50
|
-
resolveThemeConfig,
|
|
51
|
-
} from "./constants.js";
|
|
52
|
-
|
|
53
|
-
// Script generation (for advanced SSR use cases)
|
|
54
|
-
export { generateThemeScript, getNonceAttribute } from "./theme-script.js";
|
|
55
|
-
|
|
56
|
-
// Context (for advanced use cases)
|
|
57
|
-
export { ThemeContext, useThemeContext } from "./theme-context.js";
|
|
47
|
+
// Constants
|
|
48
|
+
export { THEME_DEFAULTS, THEME_COOKIE } from "./constants.js";
|