@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/router/middleware.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from "./middleware-types.js";
|
|
21
21
|
import { _getRequestContext } from "../server/request-context.js";
|
|
22
22
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
23
|
+
import { appendMetric, createMetricsStore } from "./metrics.js";
|
|
23
24
|
|
|
24
25
|
// Re-export types and cookie utilities for backward compatibility
|
|
25
26
|
export type {
|
|
@@ -33,25 +34,29 @@ export type {
|
|
|
33
34
|
} from "./middleware-types.js";
|
|
34
35
|
export { parseCookies, serializeCookie } from "./middleware-cookies.js";
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
const MIDDLEWARE_METRIC_DEPTH = 1;
|
|
38
|
+
/** Ignore post-next() durations below this threshold (measurement noise). */
|
|
39
|
+
const POST_METRIC_MIN_DURATION_MS = 0.01;
|
|
40
|
+
|
|
41
|
+
function getMiddlewareMetricBase<TEnv>(
|
|
42
|
+
entry: MiddlewareEntry<TEnv>,
|
|
43
|
+
ordinal: number,
|
|
44
|
+
): string {
|
|
45
|
+
const handlerName = entry.handler.name?.trim();
|
|
46
|
+
const scope = entry.pattern ?? "*";
|
|
47
|
+
|
|
48
|
+
if (handlerName) {
|
|
49
|
+
return `${handlerName}@${scope}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return `${scope}#${ordinal + 1}`;
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
function getMiddlewareMetricLabel<TEnv>(
|
|
56
|
+
entry: MiddlewareEntry<TEnv>,
|
|
57
|
+
ordinal: number,
|
|
58
|
+
): string {
|
|
59
|
+
return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/**
|
|
@@ -158,9 +163,25 @@ export function createMiddlewareContext<TEnv>(
|
|
|
158
163
|
// Cookie operations are handled by the standalone cookies() function which
|
|
159
164
|
// delegates to the shared RequestContext internally.
|
|
160
165
|
// The runtime implementation - types are enforced at call sites via MiddlewareContext<TEnv>
|
|
166
|
+
// Internal helper: resolve the current response (stub before next(), real after).
|
|
167
|
+
// Not exposed on the public MiddlewareContext type — use ctx.headers instead.
|
|
168
|
+
const getResponse = (): Response => {
|
|
169
|
+
if (isPreNext()) {
|
|
170
|
+
const reqCtx = _getRequestContext();
|
|
171
|
+
if (reqCtx) return reqCtx.res;
|
|
172
|
+
}
|
|
173
|
+
if (!responseHolder.response) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
"Response is not available - responseHolder was not initialized",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return responseHolder.response;
|
|
179
|
+
};
|
|
180
|
+
|
|
161
181
|
return {
|
|
162
182
|
request,
|
|
163
183
|
url,
|
|
184
|
+
originalUrl: new URL(request.url),
|
|
164
185
|
pathname: url.pathname,
|
|
165
186
|
searchParams: url.searchParams,
|
|
166
187
|
env: env as MiddlewareContext<TEnv>["env"],
|
|
@@ -175,24 +196,8 @@ export function createMiddlewareContext<TEnv>(
|
|
|
175
196
|
) as MiddlewareContext<TEnv>["routeName"];
|
|
176
197
|
},
|
|
177
198
|
|
|
178
|
-
get
|
|
179
|
-
|
|
180
|
-
// set via ctx.header() are visible on ctx.res.
|
|
181
|
-
if (isPreNext()) {
|
|
182
|
-
const reqCtx = _getRequestContext();
|
|
183
|
-
if (reqCtx) return reqCtx.res;
|
|
184
|
-
}
|
|
185
|
-
if (!responseHolder.response) {
|
|
186
|
-
throw new Error(
|
|
187
|
-
"ctx.res is not available - responseHolder was not initialized",
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
return responseHolder.response;
|
|
191
|
-
},
|
|
192
|
-
set res(_: Response) {
|
|
193
|
-
throw new Error(
|
|
194
|
-
"ctx.res is read-only. Use ctx.header() to set response headers, or cookies() for cookie mutations.",
|
|
195
|
-
);
|
|
199
|
+
get headers(): Headers {
|
|
200
|
+
return getResponse().headers;
|
|
196
201
|
},
|
|
197
202
|
|
|
198
203
|
get: ((keyOrVar: any) =>
|
|
@@ -202,6 +207,8 @@ export function createMiddlewareContext<TEnv>(
|
|
|
202
207
|
contextSet(variables, keyOrVar, value);
|
|
203
208
|
}) as MiddlewareContext<TEnv>["set"],
|
|
204
209
|
|
|
210
|
+
var: variables as MiddlewareContext<TEnv>["var"],
|
|
211
|
+
|
|
205
212
|
header(name: string, value: string): void {
|
|
206
213
|
// Before next(): delegate to shared RequestContext stub
|
|
207
214
|
if (isPreNext()) {
|
|
@@ -220,6 +227,24 @@ export function createMiddlewareContext<TEnv>(
|
|
|
220
227
|
responseHolder.response.headers.set(name, value);
|
|
221
228
|
},
|
|
222
229
|
|
|
230
|
+
get theme(): MiddlewareContext<TEnv>["theme"] {
|
|
231
|
+
return _getRequestContext()?.theme;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
|
|
235
|
+
return _getRequestContext()?.setTheme;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
setLocationState(entries) {
|
|
239
|
+
const reqCtx = _getRequestContext();
|
|
240
|
+
if (!reqCtx) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
"setLocationState() is not available outside a request context",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
reqCtx.setLocationState(entries);
|
|
246
|
+
},
|
|
247
|
+
|
|
223
248
|
reverse:
|
|
224
249
|
reverse ??
|
|
225
250
|
((name: string) => {
|
|
@@ -227,6 +252,14 @@ export function createMiddlewareContext<TEnv>(
|
|
|
227
252
|
`ctx.reverse() is not available - route map was not provided to middleware context`,
|
|
228
253
|
);
|
|
229
254
|
}),
|
|
255
|
+
|
|
256
|
+
debugPerformance(): void {
|
|
257
|
+
const reqCtx = _getRequestContext();
|
|
258
|
+
if (reqCtx) {
|
|
259
|
+
reqCtx._debugPerformance = true;
|
|
260
|
+
reqCtx._metricsStore ??= createMetricsStore(true);
|
|
261
|
+
}
|
|
262
|
+
},
|
|
230
263
|
};
|
|
231
264
|
}
|
|
232
265
|
|
|
@@ -265,9 +298,9 @@ export function matchMiddleware<TEnv>(
|
|
|
265
298
|
*
|
|
266
299
|
* Features:
|
|
267
300
|
* - `await next()` returns actual Response
|
|
268
|
-
* - `ctx.
|
|
269
|
-
* - `ctx.header()` shorthand for setting
|
|
270
|
-
* - Forgiving: if middleware doesn't return, uses
|
|
301
|
+
* - `ctx.headers` available before and after `await next()`
|
|
302
|
+
* - `ctx.header()` shorthand for setting a single header
|
|
303
|
+
* - Forgiving: if middleware doesn't return, uses the downstream response
|
|
271
304
|
* - Short-circuit: return Response to stop chain
|
|
272
305
|
* - Error catching: try/catch around `next()` works
|
|
273
306
|
*/
|
|
@@ -309,14 +342,21 @@ export async function executeMiddleware<TEnv>(
|
|
|
309
342
|
}
|
|
310
343
|
});
|
|
311
344
|
// Also merge shared RequestContext stub (cookies written via cookies().set()).
|
|
312
|
-
// Set-Cookie
|
|
313
|
-
//
|
|
345
|
+
// Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
|
|
346
|
+
// may have already merged the same reqCtx cookies into the response.
|
|
314
347
|
const reqCtx = _getRequestContext();
|
|
315
348
|
if (reqCtx) {
|
|
349
|
+
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
350
|
+
if (stubCookies.length > 0) {
|
|
351
|
+
const existing = new Set(mergedHeaders.getSetCookie());
|
|
352
|
+
for (const cookie of stubCookies) {
|
|
353
|
+
if (!existing.has(cookie)) {
|
|
354
|
+
mergedHeaders.append("set-cookie", cookie);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
316
358
|
reqCtx.res.headers.forEach((value, name) => {
|
|
317
|
-
if (name
|
|
318
|
-
mergedHeaders.append(name, value);
|
|
319
|
-
} else if (!mergedHeaders.has(name)) {
|
|
359
|
+
if (name !== "set-cookie" && !mergedHeaders.has(name)) {
|
|
320
360
|
mergedHeaders.set(name, value);
|
|
321
361
|
}
|
|
322
362
|
});
|
|
@@ -332,6 +372,7 @@ export async function executeMiddleware<TEnv>(
|
|
|
332
372
|
return responseHolder.response;
|
|
333
373
|
}
|
|
334
374
|
|
|
375
|
+
const middlewareOrdinal = index;
|
|
335
376
|
const { entry, params } = middlewares[index++];
|
|
336
377
|
const ctx = createMiddlewareContext(
|
|
337
378
|
request,
|
|
@@ -341,48 +382,77 @@ export async function executeMiddleware<TEnv>(
|
|
|
341
382
|
responseHolder,
|
|
342
383
|
reverse,
|
|
343
384
|
);
|
|
385
|
+
const metricStart = performance.now();
|
|
386
|
+
const metricLabel = getMiddlewareMetricLabel(entry, middlewareOrdinal);
|
|
387
|
+
let middlewareFinished = false;
|
|
388
|
+
const finishMiddleware = () => {
|
|
389
|
+
if (!middlewareFinished) {
|
|
390
|
+
middlewareFinished = true;
|
|
391
|
+
appendMetric(
|
|
392
|
+
_getRequestContext()?._metricsStore,
|
|
393
|
+
`${metricLabel}:pre`,
|
|
394
|
+
metricStart,
|
|
395
|
+
performance.now() - metricStart,
|
|
396
|
+
MIDDLEWARE_METRIC_DEPTH,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
344
400
|
|
|
345
401
|
// Track if next() was called and capture its Promise.
|
|
346
402
|
// Guard against double-calling: a second call would re-enter the
|
|
347
403
|
// downstream chain and overwrite responseHolder.response.
|
|
348
404
|
let nextPromise: Promise<Response> | null = null;
|
|
405
|
+
let nextResolvedAt: number | undefined;
|
|
349
406
|
const wrappedNext = (): Promise<Response> => {
|
|
350
407
|
if (nextPromise) {
|
|
351
408
|
throw new Error(
|
|
352
409
|
`[@rangojs/router] Middleware called next() more than once.`,
|
|
353
410
|
);
|
|
354
411
|
}
|
|
355
|
-
|
|
412
|
+
finishMiddleware();
|
|
413
|
+
const downstream = next();
|
|
414
|
+
nextPromise = downstream.then(
|
|
415
|
+
(res) => {
|
|
416
|
+
nextResolvedAt = performance.now();
|
|
417
|
+
return res;
|
|
418
|
+
},
|
|
419
|
+
(err) => {
|
|
420
|
+
nextResolvedAt = performance.now();
|
|
421
|
+
throw err;
|
|
422
|
+
},
|
|
423
|
+
);
|
|
356
424
|
return nextPromise;
|
|
357
425
|
};
|
|
358
426
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
427
|
+
let result: Response | void;
|
|
428
|
+
try {
|
|
429
|
+
result = await entry.handler(ctx, wrappedNext);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
finishMiddleware();
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
finishMiddleware();
|
|
435
|
+
|
|
436
|
+
// Record post-next() processing time when middleware did work after
|
|
437
|
+
// the downstream chain resolved (e.g. adding headers, logging).
|
|
438
|
+
if (nextResolvedAt !== undefined) {
|
|
439
|
+
const postDur = performance.now() - nextResolvedAt;
|
|
440
|
+
if (postDur > POST_METRIC_MIN_DURATION_MS) {
|
|
441
|
+
appendMetric(
|
|
442
|
+
_getRequestContext()?._metricsStore,
|
|
443
|
+
`${metricLabel}:post`,
|
|
444
|
+
nextResolvedAt,
|
|
445
|
+
postDur,
|
|
446
|
+
MIDDLEWARE_METRIC_DEPTH,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
367
449
|
}
|
|
368
|
-
|
|
369
|
-
const result = await entry.handler(ctx, wrappedNext);
|
|
370
450
|
|
|
371
451
|
// Explicit return takes precedence (middleware short-circuit).
|
|
372
452
|
// Merge stub headers (from ctx.header before this point) and
|
|
373
453
|
// RequestContext stub headers (from ctx.setCookie) into the
|
|
374
454
|
// returned Response so they are not lost.
|
|
375
455
|
if (result instanceof Response) {
|
|
376
|
-
// W5: warn if ctx.set() was called but middleware returned a redirect
|
|
377
|
-
if (
|
|
378
|
-
process.env.NODE_ENV !== "production" &&
|
|
379
|
-
ctxSetCalled &&
|
|
380
|
-
result.status >= 300 &&
|
|
381
|
-
result.status < 400
|
|
382
|
-
) {
|
|
383
|
-
warnCtxSetBeforeRedirect(entry.handler);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
456
|
const mergedHeaders = new Headers(result.headers);
|
|
387
457
|
stubResponse.headers.forEach((value, name) => {
|
|
388
458
|
if (name.toLowerCase() === "set-cookie") {
|
|
@@ -391,13 +461,22 @@ export async function executeMiddleware<TEnv>(
|
|
|
391
461
|
mergedHeaders.set(name, value);
|
|
392
462
|
}
|
|
393
463
|
});
|
|
394
|
-
// Also merge shared RequestContext stub (cookies written via setCookie)
|
|
464
|
+
// Also merge shared RequestContext stub (cookies written via setCookie).
|
|
465
|
+
// Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
|
|
466
|
+
// may have already merged the same reqCtx cookies into the response.
|
|
395
467
|
const reqCtx = _getRequestContext();
|
|
396
468
|
if (reqCtx) {
|
|
469
|
+
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
470
|
+
if (stubCookies.length > 0) {
|
|
471
|
+
const existing = new Set(mergedHeaders.getSetCookie());
|
|
472
|
+
for (const cookie of stubCookies) {
|
|
473
|
+
if (!existing.has(cookie)) {
|
|
474
|
+
mergedHeaders.append("set-cookie", cookie);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
397
478
|
reqCtx.res.headers.forEach((value, name) => {
|
|
398
|
-
if (name
|
|
399
|
-
mergedHeaders.append(name, value);
|
|
400
|
-
} else if (!mergedHeaders.has(name)) {
|
|
479
|
+
if (name !== "set-cookie" && !mergedHeaders.has(name)) {
|
|
401
480
|
mergedHeaders.set(name, value);
|
|
402
481
|
}
|
|
403
482
|
});
|
|
@@ -424,19 +503,6 @@ export async function executeMiddleware<TEnv>(
|
|
|
424
503
|
// If middleware called next(), await it and return the response
|
|
425
504
|
if (nextPromise) {
|
|
426
505
|
await nextPromise;
|
|
427
|
-
|
|
428
|
-
// W5: warn if ctx.set() was called but the downstream response is a redirect.
|
|
429
|
-
// The ctx.set() values will be lost because the redirect navigates away.
|
|
430
|
-
if (
|
|
431
|
-
process.env.NODE_ENV !== "production" &&
|
|
432
|
-
ctxSetCalled &&
|
|
433
|
-
responseHolder.response &&
|
|
434
|
-
responseHolder.response.status >= 300 &&
|
|
435
|
-
responseHolder.response.status < 400
|
|
436
|
-
) {
|
|
437
|
-
warnCtxSetBeforeRedirect(entry.handler);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
506
|
return responseHolder.response!;
|
|
441
507
|
}
|
|
442
508
|
|
|
@@ -459,6 +525,29 @@ export async function executeMiddleware<TEnv>(
|
|
|
459
525
|
throw new Error("No response generated by middleware chain");
|
|
460
526
|
}
|
|
461
527
|
|
|
528
|
+
// Final re-merge: capture any RequestContext stub headers added after the
|
|
529
|
+
// last merge point (e.g. cookies().set() called after await next()).
|
|
530
|
+
// The reqCtx stub may have already been partially merged during finalHandler
|
|
531
|
+
// or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
|
|
532
|
+
const reqCtx = _getRequestContext();
|
|
533
|
+
if (reqCtx) {
|
|
534
|
+
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
535
|
+
if (stubCookies.length > 0) {
|
|
536
|
+
const existingCookies = new Set(finalResponse.headers.getSetCookie());
|
|
537
|
+
for (const cookie of stubCookies) {
|
|
538
|
+
if (!existingCookies.has(cookie)) {
|
|
539
|
+
finalResponse.headers.append("set-cookie", cookie);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Fill in non-cookie headers that aren't already on the response
|
|
544
|
+
reqCtx.res.headers.forEach((value, name) => {
|
|
545
|
+
if (name !== "set-cookie" && !finalResponse.headers.has(name)) {
|
|
546
|
+
finalResponse.headers.set(name, value);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
462
551
|
return finalResponse;
|
|
463
552
|
}
|
|
464
553
|
|
|
@@ -16,6 +16,7 @@ export interface ParsedSegment {
|
|
|
16
16
|
value: string; // static text, param name, or "*"
|
|
17
17
|
optional: boolean;
|
|
18
18
|
constraint?: string[]; // enum values like ["en", "gb"]
|
|
19
|
+
suffix?: string; // literal text after param in same segment (e.g., ".html")
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -39,11 +40,21 @@ export function parsePattern(pattern: string): ParsedSegment[] {
|
|
|
39
40
|
// - :param(a|b)?
|
|
40
41
|
// - *
|
|
41
42
|
const segmentRegex =
|
|
42
|
-
/\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)
|
|
43
|
+
/\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([^/]*)|(\*)|([^/]+))/g;
|
|
43
44
|
|
|
44
45
|
let match;
|
|
45
46
|
while ((match = segmentRegex.exec(pattern)) !== null) {
|
|
46
|
-
const [
|
|
47
|
+
const [
|
|
48
|
+
,
|
|
49
|
+
,
|
|
50
|
+
paramName,
|
|
51
|
+
,
|
|
52
|
+
constraint,
|
|
53
|
+
optional,
|
|
54
|
+
suffix,
|
|
55
|
+
wildcard,
|
|
56
|
+
staticText,
|
|
57
|
+
] = match;
|
|
47
58
|
|
|
48
59
|
if (wildcard) {
|
|
49
60
|
segments.push({ type: "wildcard", value: "*", optional: false });
|
|
@@ -53,6 +64,7 @@ export function parsePattern(pattern: string): ParsedSegment[] {
|
|
|
53
64
|
value: paramName,
|
|
54
65
|
optional: optional === "?",
|
|
55
66
|
constraint: constraint ? constraint.split("|") : undefined,
|
|
67
|
+
suffix: suffix || undefined,
|
|
56
68
|
});
|
|
57
69
|
} else if (staticText) {
|
|
58
70
|
segments.push({ type: "static", value: staticText, optional: false });
|
|
@@ -139,16 +151,19 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
139
151
|
regexPattern += "/(.*)";
|
|
140
152
|
} else if (segment.type === "param") {
|
|
141
153
|
paramNames.push(segment.value);
|
|
154
|
+
const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
|
|
142
155
|
const valuePattern = segment.constraint
|
|
143
156
|
? `(${segment.constraint.map(escapeRegex).join("|")})`
|
|
144
|
-
:
|
|
157
|
+
: segment.suffix
|
|
158
|
+
? "([^/]+?)"
|
|
159
|
+
: "([^/]+)";
|
|
145
160
|
|
|
146
161
|
if (segment.optional) {
|
|
147
162
|
optionalParams.add(segment.value);
|
|
148
163
|
// Optional: make the whole /segment optional
|
|
149
|
-
regexPattern += `(?:/${valuePattern})?`;
|
|
164
|
+
regexPattern += `(?:/${valuePattern}${suffixPattern})?`;
|
|
150
165
|
} else {
|
|
151
|
-
regexPattern += `/${valuePattern}`;
|
|
166
|
+
regexPattern += `/${valuePattern}${suffixPattern}`;
|
|
152
167
|
}
|
|
153
168
|
} else {
|
|
154
169
|
// Static segment
|
|
@@ -101,6 +101,7 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
101
101
|
env: {} as TEnv,
|
|
102
102
|
request: new Request("http://prerender" + pathname),
|
|
103
103
|
url: new URL("http://prerender" + pathname),
|
|
104
|
+
originalUrl: new URL("http://prerender" + pathname),
|
|
104
105
|
pathname,
|
|
105
106
|
searchParams: new URLSearchParams(),
|
|
106
107
|
var: variables,
|
|
@@ -116,6 +117,7 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
116
117
|
deleteCookie: () => {},
|
|
117
118
|
header: () => {},
|
|
118
119
|
setStatus: () => {},
|
|
120
|
+
_setStatus: () => {},
|
|
119
121
|
use: (() => {
|
|
120
122
|
throw new Error("use() not available during pre-rendering");
|
|
121
123
|
}) as any,
|
|
@@ -331,6 +333,7 @@ export async function renderStaticSegment<TEnv = any>(
|
|
|
331
333
|
env: {} as TEnv,
|
|
332
334
|
request: syntheticRequest,
|
|
333
335
|
url: syntheticUrl,
|
|
336
|
+
originalUrl: syntheticUrl,
|
|
334
337
|
pathname: "/",
|
|
335
338
|
searchParams: syntheticUrl.searchParams,
|
|
336
339
|
var: {},
|
|
@@ -344,6 +347,7 @@ export async function renderStaticSegment<TEnv = any>(
|
|
|
344
347
|
deleteCookie: () => {},
|
|
345
348
|
header: () => {},
|
|
346
349
|
setStatus: () => {},
|
|
350
|
+
_setStatus: () => {},
|
|
347
351
|
use: (() => {
|
|
348
352
|
throw new Error("use() not available during static pre-rendering");
|
|
349
353
|
}) as any,
|
|
@@ -84,6 +84,7 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
84
84
|
} = options;
|
|
85
85
|
const nextParams = segment.params || {};
|
|
86
86
|
const paramsChanged = !paramsEqual(nextParams, prevParams);
|
|
87
|
+
const searchChanged = prevUrl.search !== nextUrl.search;
|
|
87
88
|
|
|
88
89
|
// Trace helper: push a structured entry to the request-scoped trace buffer.
|
|
89
90
|
// Guarded by isTraceActive() so object construction is skipped in production.
|
|
@@ -134,19 +135,38 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
134
135
|
// Only the route segment revalidates by default - all others require explicit opt-in
|
|
135
136
|
|
|
136
137
|
if (segment.type === "route") {
|
|
137
|
-
// Route segments revalidate when params change
|
|
138
|
-
//
|
|
139
|
-
|
|
138
|
+
// Route segments revalidate when path params OR search params change.
|
|
139
|
+
// Search params (e.g., ?page=2&sort=price) are server-parsed via ctx.search,
|
|
140
|
+
// so the handler must re-execute to produce updated content.
|
|
141
|
+
const routeChanged = paramsChanged || searchChanged;
|
|
142
|
+
defaultShouldRevalidate = routeChanged;
|
|
140
143
|
defaultReason = paramsChanged
|
|
141
144
|
? "nav:params-changed"
|
|
142
|
-
:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
: searchChanged
|
|
146
|
+
? "nav:search-changed"
|
|
147
|
+
: "nav:params-unchanged";
|
|
148
|
+
if (routeChanged) {
|
|
149
|
+
debugLog("revalidation", "route revalidating", {
|
|
145
150
|
segmentId: segment.id,
|
|
151
|
+
paramsChanged,
|
|
152
|
+
searchChanged,
|
|
146
153
|
});
|
|
147
154
|
}
|
|
155
|
+
} else if (segment.belongsToRoute && (paramsChanged || searchChanged)) {
|
|
156
|
+
// Children of the route path (loaders, orphan layouts/parallels)
|
|
157
|
+
// revalidate when path params or search params change
|
|
158
|
+
defaultShouldRevalidate = true;
|
|
159
|
+
defaultReason = paramsChanged
|
|
160
|
+
? "nav:route-child-params-changed"
|
|
161
|
+
: "nav:route-child-search-changed";
|
|
162
|
+
debugLog("revalidation", "route child revalidating", {
|
|
163
|
+
segmentId: segment.id,
|
|
164
|
+
segmentType: segment.type,
|
|
165
|
+
paramsChanged,
|
|
166
|
+
searchChanged,
|
|
167
|
+
});
|
|
148
168
|
} else {
|
|
149
|
-
//
|
|
169
|
+
// Parent layouts and parallels default to no revalidation
|
|
150
170
|
// Cannot assume these segments depend on params without explicit declaration
|
|
151
171
|
// Use custom revalidation functions to opt-in when needed
|
|
152
172
|
defaultShouldRevalidate = false;
|
|
@@ -258,10 +258,17 @@ export interface RSCRouterInternal<
|
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
260
|
* Cache-Control header value for prefetch responses.
|
|
261
|
-
* False means no
|
|
261
|
+
* False means no caching of prefetch responses.
|
|
262
|
+
* Derived from prefetchCacheTTL.
|
|
262
263
|
*/
|
|
263
264
|
readonly prefetchCacheControl: string | false;
|
|
264
265
|
|
|
266
|
+
/**
|
|
267
|
+
* TTL in milliseconds for the client-side in-memory prefetch cache.
|
|
268
|
+
* 0 means caching is disabled.
|
|
269
|
+
*/
|
|
270
|
+
readonly prefetchCacheTTL: number;
|
|
271
|
+
|
|
265
272
|
/**
|
|
266
273
|
* Whether connection warmup is enabled.
|
|
267
274
|
* When true, the client sends HEAD /?_rsc_warmup after idle periods
|
|
@@ -269,6 +276,12 @@ export interface RSCRouterInternal<
|
|
|
269
276
|
*/
|
|
270
277
|
readonly warmupEnabled: boolean;
|
|
271
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Whether router-wide performance debugging is enabled.
|
|
281
|
+
* Used by the request handler to create metrics before middleware runs.
|
|
282
|
+
*/
|
|
283
|
+
readonly debugPerformance?: boolean;
|
|
284
|
+
|
|
272
285
|
/**
|
|
273
286
|
* Whether ?__debug_manifest is allowed in production.
|
|
274
287
|
* Always enabled in development.
|
|
@@ -239,7 +239,7 @@ export interface RSCRouterOptions<TEnv = any> {
|
|
|
239
239
|
*
|
|
240
240
|
* @example Static config
|
|
241
241
|
* ```typescript
|
|
242
|
-
* import { MemorySegmentCacheStore } from "
|
|
242
|
+
* import { MemorySegmentCacheStore } from "@rangojs/router/cache";
|
|
243
243
|
*
|
|
244
244
|
* const router = createRouter({
|
|
245
245
|
* cache: {
|
|
@@ -415,16 +415,21 @@ export interface RSCRouterOptions<TEnv = any> {
|
|
|
415
415
|
version?: string;
|
|
416
416
|
|
|
417
417
|
/**
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* `X-Rango-Prefetch` header (sent by the Link component's prefetch fetch).
|
|
421
|
-
* Navigation responses are never cached by the browser.
|
|
418
|
+
* TTL (in seconds) for the in-memory prefetch cache and the
|
|
419
|
+
* Cache-Control header on prefetch responses.
|
|
422
420
|
*
|
|
423
|
-
*
|
|
421
|
+
* Controls how long prefetch responses are kept in the client-side
|
|
422
|
+
* in-memory cache and sets `Cache-Control: private, max-age=<ttl>`
|
|
423
|
+
* on server responses for CDN/edge caching.
|
|
424
424
|
*
|
|
425
|
-
*
|
|
425
|
+
* The cache is automatically invalidated on server actions regardless
|
|
426
|
+
* of TTL, so this is primarily a staleness safety net.
|
|
427
|
+
*
|
|
428
|
+
* Set to `false` to disable prefetch caching entirely.
|
|
429
|
+
*
|
|
430
|
+
* @default 300 (5 minutes)
|
|
426
431
|
*/
|
|
427
|
-
|
|
432
|
+
prefetchCacheTTL?: number | false;
|
|
428
433
|
|
|
429
434
|
/**
|
|
430
435
|
* Enable connection warmup to keep TCP+TLS alive after idle periods.
|