@rangojs/router 0.0.0-experimental.131 → 0.0.0-experimental.132
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/bin/rango.js +69 -24
- package/dist/vite/index.js +182 -41
- package/package.json +6 -3
- package/src/browser/connection-warmup.ts +134 -0
- package/src/browser/event-controller.ts +5 -4
- package/src/browser/partial-update.ts +32 -16
- package/src/browser/react/NavigationProvider.tsx +6 -83
- package/src/browser/react/filter-segment-order.ts +17 -0
- package/src/browser/react/use-link-status.ts +10 -2
- package/src/browser/react/use-navigation.ts +10 -2
- package/src/build/route-types/ast-route-extraction.ts +15 -8
- package/src/build/route-types/include-resolution.ts +109 -21
- package/src/build/route-types/per-module-writer.ts +15 -2
- package/src/cache/cache-key-utils.ts +29 -13
- package/src/cache/cf/cf-cache-store.ts +129 -5
- package/src/decode-loader-results.ts +11 -1
- package/src/encode-kv.ts +49 -0
- package/src/handles/meta.ts +5 -1
- package/src/host/cookie-handler.ts +2 -21
- package/src/prerender/param-hash.ts +6 -5
- package/src/regex-escape.ts +8 -0
- package/src/route-definition/dsl-helpers.ts +6 -2
- package/src/router/error-handling.ts +32 -1
- package/src/router/handler-context.ts +6 -1
- package/src/router/instrument.ts +14 -10
- package/src/router/intercept-resolution.ts +16 -1
- package/src/router/loader-resolution.ts +49 -19
- package/src/router/match-middleware/background-revalidation.ts +6 -0
- package/src/router/match-middleware/cache-store.ts +6 -0
- package/src/router/middleware.ts +67 -27
- package/src/router/pattern-matching.ts +3 -9
- package/src/router/revalidation.ts +65 -23
- package/src/router/router-context.ts +1 -0
- package/src/router/router-options.ts +3 -3
- package/src/router/segment-resolution/loader-cache.ts +13 -0
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/trie-matching.ts +74 -20
- package/src/router.ts +2 -2
- package/src/rsc/progressive-enhancement.ts +20 -0
- package/src/rsc/server-action.ts +124 -47
- package/src/search-params.ts +8 -6
- package/src/segment-system.tsx +7 -1
- package/src/server/cookie-parse.ts +32 -0
- package/src/server/handle-store.ts +14 -14
- package/src/server/request-context.ts +5 -26
- package/src/ssr/index.tsx +5 -4
- package/src/testing/render-handler.ts +11 -0
- package/src/vite/plugins/expose-id-utils.ts +77 -2
- package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
- package/src/vite/utils/prerender-utils.ts +1 -3
|
@@ -109,16 +109,40 @@ export function wrapLoaderWithErrorHandling<T>(
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
// Render fallback on server
|
|
112
|
+
// Render fallback on server. The user ErrorBoundaryHandler may throw
|
|
113
|
+
// synchronously; if it does we must NOT let that rejection escape — the
|
|
114
|
+
// wrapped LoaderDataResult promise is contracted to never reject (see
|
|
115
|
+
// segment-resolution/fresh.ts `await Promise.all(...wrapped)`), and a
|
|
116
|
+
// rejection here would collapse the whole entry and discard healthy
|
|
117
|
+
// sibling loader data. On a fallback-render throw, fall back to the
|
|
118
|
+
// no-boundary result (fallback: null) so the client throws the ORIGINAL
|
|
119
|
+
// error, and the wrapped promise still resolves to a LoaderDataResult.
|
|
113
120
|
let renderedFallback: ReactNode;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
try {
|
|
122
|
+
if (typeof fallback === "function") {
|
|
123
|
+
// ErrorBoundaryHandler - call with error info
|
|
124
|
+
const props: ErrorBoundaryFallbackProps = {
|
|
125
|
+
error: errorInfo,
|
|
126
|
+
};
|
|
127
|
+
renderedFallback = fallback(props);
|
|
128
|
+
} else {
|
|
129
|
+
renderedFallback = fallback;
|
|
130
|
+
}
|
|
131
|
+
} catch (fallbackError) {
|
|
132
|
+
debugLog("loader", "error boundary fallback render threw", {
|
|
133
|
+
segmentId,
|
|
134
|
+
message: errorInfo.message,
|
|
135
|
+
fallbackError:
|
|
136
|
+
fallbackError instanceof Error
|
|
137
|
+
? fallbackError.message
|
|
138
|
+
: String(fallbackError),
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
__loaderResult: true,
|
|
142
|
+
ok: false,
|
|
117
143
|
error: errorInfo,
|
|
144
|
+
fallback: null,
|
|
118
145
|
};
|
|
119
|
-
renderedFallback = fallback(props);
|
|
120
|
-
} else {
|
|
121
|
-
renderedFallback = fallback;
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
debugLog("loader", "loader error wrapped with boundary fallback", {
|
|
@@ -542,18 +566,21 @@ export function setupBuildUse<TEnv>(ctx: HandlerContext<any, TEnv>): void {
|
|
|
542
566
|
);
|
|
543
567
|
}
|
|
544
568
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
)
|
|
548
|
-
|
|
569
|
+
// Wrap with withDefer so ctx.use(Handle).defer(...) works on the build /
|
|
570
|
+
// prerender path, matching production setupLoaderAccess. Without it a
|
|
571
|
+
// prerender handler calling .defer() throws "defer is not a function".
|
|
572
|
+
return withDefer(
|
|
573
|
+
(dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>)) => {
|
|
574
|
+
if (!store) return;
|
|
549
575
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
576
|
+
const valueOrPromise =
|
|
577
|
+
typeof dataOrFn === "function"
|
|
578
|
+
? (dataOrFn as () => Promise<unknown>)()
|
|
579
|
+
: dataOrFn;
|
|
554
580
|
|
|
555
|
-
|
|
556
|
-
|
|
581
|
+
store.push(handle.$$id, segmentId, valueOrPromise);
|
|
582
|
+
},
|
|
583
|
+
);
|
|
557
584
|
}
|
|
558
585
|
|
|
559
586
|
// Loader case: not available during pre-rendering
|
|
@@ -581,8 +608,11 @@ export function setupLoaderAccessSilent<TEnv>(
|
|
|
581
608
|
|
|
582
609
|
ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
583
610
|
if (isHandle(item)) {
|
|
584
|
-
// Silent mode - return a no-op so handle data is not pushed during caching
|
|
585
|
-
|
|
611
|
+
// Silent mode - return a no-op so handle data is not pushed during caching.
|
|
612
|
+
// Wrap with withDefer so ctx.use(Handle).defer(...) still resolves to a
|
|
613
|
+
// callable resolver (also a no-op here), matching production's push shape
|
|
614
|
+
// instead of throwing "defer is not a function".
|
|
615
|
+
return withDefer((_dataOrFn: unknown) => {});
|
|
586
616
|
}
|
|
587
617
|
|
|
588
618
|
return useLoader(item as LoaderDefinition<any, any>, null);
|
|
@@ -203,6 +203,12 @@ export function withBackgroundRevalidation<TEnv>(
|
|
|
203
203
|
ctx.matched.params,
|
|
204
204
|
freshHandlerContext,
|
|
205
205
|
true,
|
|
206
|
+
undefined,
|
|
207
|
+
// Skip intercept middleware: this is a post-response background
|
|
208
|
+
// re-render to refresh a stale cached route. The foreground
|
|
209
|
+
// already ran the middleware; re-running it would double its side
|
|
210
|
+
// effects and a short-circuit Response would abort the write.
|
|
211
|
+
{ skipMiddleware: true },
|
|
206
212
|
),
|
|
207
213
|
);
|
|
208
214
|
}
|
|
@@ -243,6 +243,12 @@ export function withCacheStore<TEnv>(
|
|
|
243
243
|
proactiveHandlerContext,
|
|
244
244
|
true, // belongsToRoute
|
|
245
245
|
// No revalidationContext = render fresh
|
|
246
|
+
undefined,
|
|
247
|
+
// Skip intercept middleware: the foreground already ran it
|
|
248
|
+
// before the response was sent. Re-running here (post-response,
|
|
249
|
+
// background) would fire side effects twice and a short-circuit
|
|
250
|
+
// Response would silently abort this cache write.
|
|
251
|
+
{ skipMiddleware: true },
|
|
246
252
|
),
|
|
247
253
|
);
|
|
248
254
|
}
|
package/src/router/middleware.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/// <reference types="vite/types/importMeta.d.ts" />
|
|
2
2
|
|
|
3
3
|
import { contextGet, contextSet } from "../context-var.js";
|
|
4
|
+
import { escapeRegExp } from "../regex-escape.js";
|
|
5
|
+
import { parsePattern as parseRoutePattern } from "./pattern-matching.js";
|
|
4
6
|
import { safeDecodeURIComponent } from "./url-params.js";
|
|
5
7
|
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
6
8
|
import type {
|
|
@@ -48,7 +50,43 @@ function getMiddlewareMetricLabel<TEnv>(
|
|
|
48
50
|
return `middleware:${scope}#${ordinal + 1}`;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Compile a middleware scope pattern to a regex + param names.
|
|
55
|
+
*
|
|
56
|
+
* Middleware scopes reuse the route pattern parser (`parsePattern` from
|
|
57
|
+
* pattern-matching.ts), so they support the same param forms as routes —
|
|
58
|
+
* optional (`:x?`), constrained (`:x(en|gb)`), and suffix (`:x.html`) — in
|
|
59
|
+
* addition to the trailing-`*` wildcard middleware relies on. Before this
|
|
60
|
+
* unification the middleware-side parser handled only static, bare `:param`,
|
|
61
|
+
* and trailing `*`, so e.g. `router.use("/:locale(en|gb)/*", mw)` silently
|
|
62
|
+
* named the param "locale(en|gb)" and never enforced the constraint.
|
|
63
|
+
*
|
|
64
|
+
* Middleware matching semantics deliberately differ from route matching, so we
|
|
65
|
+
* emit the regex here rather than route through `compilePattern`:
|
|
66
|
+
* - `*` alone matches every path (`/^.*$/`).
|
|
67
|
+
* - A trailing `*` segment is an OPTIONAL subtree match (`(?:/.*)?`): `/admin/*`
|
|
68
|
+
* matches `/admin`, `/admin/`, and `/admin/users`. It contributes no param
|
|
69
|
+
* name (unlike route wildcards, which capture `*`).
|
|
70
|
+
* - A NON-trailing `*` is also OPTIONAL (`(?:/.*)?`), matching zero-or-more
|
|
71
|
+
* intermediate segments: `/a/<star>/b` matches both `/a/b` and `/a/x/b`. This
|
|
72
|
+
* mirrors the pre-unification parser, which compiled every `*` part as
|
|
73
|
+
* optional regardless of position.
|
|
74
|
+
* - A pattern without a trailing `*` tolerates a trailing slash (`/?$`).
|
|
75
|
+
* - Constraints are baked into the regex as an alternation so `matchMiddleware`
|
|
76
|
+
* (a bare `regex.test`) enforces them without extra validation. Constraint
|
|
77
|
+
* values are matched against the raw (still URL-encoded) path segment, which
|
|
78
|
+
* matches the pre-unification middleware behavior (it never decoded for
|
|
79
|
+
* matching); the constraint string is regex-escaped so values like `en.gb`
|
|
80
|
+
* are treated literally.
|
|
81
|
+
*
|
|
82
|
+
* The route segment parser only recognizes `/`-prefixed segments, but the
|
|
83
|
+
* pre-unification middleware parser split on `/` and dropped empty parts, so a
|
|
84
|
+
* leading slash was irrelevant: `use("admin/*")` and `use("/admin/*")` scoped
|
|
85
|
+
* identically. Normalize a non-`*` pattern to have a leading slash before
|
|
86
|
+
* parsing so that behavior is preserved (without it, `parseRoutePattern("admin/*")`
|
|
87
|
+
* drops the static `admin` and the scope explodes to every path).
|
|
88
|
+
*/
|
|
89
|
+
export function compileMiddlewarePattern(pattern: string): {
|
|
52
90
|
regex: RegExp;
|
|
53
91
|
paramNames: string[];
|
|
54
92
|
} {
|
|
@@ -56,45 +94,47 @@ export function parsePattern(pattern: string): {
|
|
|
56
94
|
return { regex: /^.*$/, paramNames: [] };
|
|
57
95
|
}
|
|
58
96
|
|
|
97
|
+
const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`;
|
|
98
|
+
const segments = parseRoutePattern(normalizedPattern);
|
|
59
99
|
const paramNames: string[] = [];
|
|
60
100
|
let regexStr = "^";
|
|
101
|
+
let hasTrailingWildcard = false;
|
|
61
102
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
for (let i = 0; i < parts.length; i++) {
|
|
65
|
-
const part = parts[i];
|
|
103
|
+
for (let i = 0; i < segments.length; i++) {
|
|
104
|
+
const segment = segments[i];
|
|
66
105
|
|
|
67
|
-
if (
|
|
68
|
-
//
|
|
106
|
+
if (segment.type === "wildcard") {
|
|
107
|
+
// Optional subtree match (parity with the original middleware parser,
|
|
108
|
+
// which compiled every `*` as `(?:/.*)?`). A trailing `*` matches the
|
|
109
|
+
// subtree; a non-trailing `*` matches zero-or-more intermediate segments,
|
|
110
|
+
// so `/a/<star>/b` still matches `/a/b`.
|
|
69
111
|
regexStr += "(?:/.*)?";
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
112
|
+
if (i === segments.length - 1) {
|
|
113
|
+
hasTrailingWildcard = true;
|
|
114
|
+
}
|
|
115
|
+
} else if (segment.type === "param") {
|
|
116
|
+
paramNames.push(segment.value);
|
|
117
|
+
const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
|
|
118
|
+
const valuePattern = segment.constraint
|
|
119
|
+
? `(${segment.constraint.map(escapeRegExp).join("|")})`
|
|
120
|
+
: "([^/]+)";
|
|
121
|
+
if (segment.optional) {
|
|
122
|
+
regexStr += `(?:/${valuePattern}${suffixPattern})?`;
|
|
123
|
+
} else {
|
|
124
|
+
regexStr += `/${valuePattern}${suffixPattern}`;
|
|
125
|
+
}
|
|
75
126
|
} else {
|
|
76
|
-
//
|
|
77
|
-
regexStr += "/" +
|
|
127
|
+
// Static literal
|
|
128
|
+
regexStr += "/" + escapeRegExp(segment.value);
|
|
78
129
|
}
|
|
79
130
|
}
|
|
80
131
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
regexStr += "/?$";
|
|
84
|
-
} else {
|
|
85
|
-
regexStr += "$";
|
|
86
|
-
}
|
|
132
|
+
// Without a trailing `*`, match exactly with an optional trailing slash.
|
|
133
|
+
regexStr += hasTrailingWildcard ? "$" : "/?$";
|
|
87
134
|
|
|
88
135
|
return { regex: new RegExp(regexStr), paramNames };
|
|
89
136
|
}
|
|
90
137
|
|
|
91
|
-
/**
|
|
92
|
-
* Escape special regex characters
|
|
93
|
-
*/
|
|
94
|
-
function escapeRegex(str: string): string {
|
|
95
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
138
|
/**
|
|
99
139
|
* Extract params from a pathname using a pattern's regex and param names.
|
|
100
140
|
*
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { RouteEntry, TrailingSlashMode } from "../types";
|
|
8
8
|
import type { EntryData } from "../server/context";
|
|
9
9
|
import { debugLog, isRouterDebugEnabled } from "./logging.js";
|
|
10
|
+
import { escapeRegExp } from "../regex-escape.js";
|
|
10
11
|
import { safeDecodeURIComponent } from "./url-params.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -151,7 +152,7 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
151
152
|
regexPattern += "/(.*)";
|
|
152
153
|
} else if (segment.type === "param") {
|
|
153
154
|
paramNames.push(segment.value);
|
|
154
|
-
const suffixPattern = segment.suffix ?
|
|
155
|
+
const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
|
|
155
156
|
// Constrained params capture anything here; the allowed values are
|
|
156
157
|
// checked post-decode in findMatch so URL-encoded constraint values
|
|
157
158
|
// (e.g. `:lang(en GB)` via `/en%20GB`) still match.
|
|
@@ -169,7 +170,7 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
169
170
|
}
|
|
170
171
|
} else {
|
|
171
172
|
// Static segment
|
|
172
|
-
regexPattern += `/${
|
|
173
|
+
regexPattern += `/${escapeRegExp(segment.value)}`;
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
176
|
|
|
@@ -229,13 +230,6 @@ function satisfiesConstraints(
|
|
|
229
230
|
return true;
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
/**
|
|
233
|
-
* Escape special regex characters in a string
|
|
234
|
-
*/
|
|
235
|
-
function escapeRegex(str: string): string {
|
|
236
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
233
|
/**
|
|
240
234
|
* Build the named-params record from a regex match. Optional segments that
|
|
241
235
|
* didn't capture leave the corresponding group `undefined`; we skip those
|
|
@@ -231,29 +231,71 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
231
231
|
: undefined;
|
|
232
232
|
|
|
233
233
|
for (const { name, fn } of revalidations) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
234
|
+
let result: any;
|
|
235
|
+
try {
|
|
236
|
+
result = fn({
|
|
237
|
+
currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params
|
|
238
|
+
currentUrl: prevUrl,
|
|
239
|
+
nextParams,
|
|
240
|
+
nextUrl,
|
|
241
|
+
defaultShouldRevalidate: currentSuggestion,
|
|
242
|
+
context,
|
|
243
|
+
// Segment metadata (which segment is being evaluated)
|
|
244
|
+
segmentType: segment.type,
|
|
245
|
+
layoutName: segment.layoutName,
|
|
246
|
+
slotName: segment.slot,
|
|
247
|
+
// Action context (only populated when triggered by server action)
|
|
248
|
+
actionId: actionContext?.actionId,
|
|
249
|
+
isAction: makeIsAction(actionContext?.actionId),
|
|
250
|
+
actionUrl: actionContext?.actionUrl,
|
|
251
|
+
actionResult: actionContext?.actionResult,
|
|
252
|
+
formData: actionContext?.formData,
|
|
253
|
+
method: request.method,
|
|
254
|
+
routeName: toRouteName,
|
|
255
|
+
fromRouteName,
|
|
256
|
+
toRouteName,
|
|
257
|
+
stale,
|
|
258
|
+
});
|
|
259
|
+
} catch (error) {
|
|
260
|
+
// A thrown Response is control flow (e.g. `throw redirect(...)`), not a
|
|
261
|
+
// failure: re-throw it so the handler chokepoint (match-handlers.ts)
|
|
262
|
+
// turns it into the intended redirect/response. This mirrors how that
|
|
263
|
+
// catch special-cases `error instanceof Response`.
|
|
264
|
+
if (error instanceof Response) throw error;
|
|
265
|
+
// Fail open for genuine errors: a buggy user revalidate fn must not
|
|
266
|
+
// collapse the whole entry's loader batch into a failed partial render.
|
|
267
|
+
// Mirror the dynamic-tags fail-open in cache/cache-policy.ts: log and
|
|
268
|
+
// defer to the current default decision, leaving currentSuggestion
|
|
269
|
+
// unchanged. TODO: route through callOnError(phase "revalidation") once
|
|
270
|
+
// evaluateRevalidation is given the onError seam (today the error only
|
|
271
|
+
// reaches onError via the entry-collapse path in match-handlers.ts).
|
|
272
|
+
console.error(
|
|
273
|
+
`[revalidate] "${name}" threw for segment "${segment.id}"; using default decision:`,
|
|
274
|
+
error,
|
|
275
|
+
);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The revalidate fn contract (handler-context.ts) is SYNCHRONOUS: it must
|
|
280
|
+
// return a boolean, a { defaultShouldRevalidate } object, or null/undefined.
|
|
281
|
+
// A Promise-returning (async) fn matches none of the decision branches below
|
|
282
|
+
// and silently falls through keeping the current default — a hard-to-find
|
|
283
|
+
// misuse. We do NOT await it (that would change the sync contract); instead
|
|
284
|
+
// we surface it as a dev-mode warning so the silent drop is diagnosable.
|
|
285
|
+
// Mirrors defer.ts: gated to dev, stripped from production builds.
|
|
286
|
+
if (
|
|
287
|
+
process.env.NODE_ENV !== "production" &&
|
|
288
|
+
result != null &&
|
|
289
|
+
typeof (result as { then?: unknown }).then === "function"
|
|
290
|
+
) {
|
|
291
|
+
console.warn(
|
|
292
|
+
`[rango] revalidate fn "${name}" returned a Promise; revalidate ` +
|
|
293
|
+
`functions must be synchronous (return a boolean, ` +
|
|
294
|
+
`{ defaultShouldRevalidate }, or null/undefined). The async result ` +
|
|
295
|
+
`was IGNORED and the default (${currentSuggestion}) was kept. ` +
|
|
296
|
+
`Move async work into a loader instead.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
257
299
|
|
|
258
300
|
if (typeof result === "boolean") {
|
|
259
301
|
debugLog("revalidation", "hard decision", {
|
|
@@ -154,6 +154,7 @@ export interface RouterContext<TEnv = any> {
|
|
|
154
154
|
handlerContext: HandlerContext<any, TEnv>,
|
|
155
155
|
belongsToRoute: boolean,
|
|
156
156
|
revalidationContext?: RevalidationContext,
|
|
157
|
+
options?: { skipMiddleware?: boolean },
|
|
157
158
|
) => Promise<ResolvedSegment[]>;
|
|
158
159
|
|
|
159
160
|
collectWithMarkers?: <T>(
|
|
@@ -579,9 +579,9 @@ export interface RangoOptions<TEnv = any> {
|
|
|
579
579
|
* start/end/error, loader start/end/error, handler errors, cache decisions,
|
|
580
580
|
* revalidation decisions, timeouts, origin rejections.
|
|
581
581
|
*
|
|
582
|
-
* This is the EVENT surface. Phase-duration SPANS (request/
|
|
583
|
-
* timing wired into a tracing backend) come from the
|
|
584
|
-
* option below — a sink does not emit them, because async-context nesting
|
|
582
|
+
* This is the EVENT surface. Phase-duration SPANS (request/middleware/action/
|
|
583
|
+
* handler/loader/render/ssr timing wired into a tracing backend) come from the
|
|
584
|
+
* separate `tracing` option below — a sink does not emit them, because async-context nesting
|
|
585
585
|
* cannot be faithfully reconstructed from after-the-fact start/end events.
|
|
586
586
|
*
|
|
587
587
|
* No-op when not configured (zero overhead).
|
|
@@ -175,6 +175,19 @@ export function resolveLoaderData<TEnv>(
|
|
|
175
175
|
}
|
|
176
176
|
const runMiss = internal._loaderCacheOriginalUse!;
|
|
177
177
|
|
|
178
|
+
// Dedup the cache read-through across repeated resolutions of the SAME
|
|
179
|
+
// loaderId in one request. An orphan layout with parallel slots inherits its
|
|
180
|
+
// parent route's loaders, so resolveOrphanLayout (fresh.ts) re-resolves the
|
|
181
|
+
// parent's loaders under a different shortCode — calling resolveLoaderData
|
|
182
|
+
// again for the same loaderId. The cache key (loader:{loaderId}:{host}
|
|
183
|
+
// {pathname}:{sortedParams}) does not include the shortCode and ctx/params
|
|
184
|
+
// are identical, so both resolutions produce the same data. Reuse the already
|
|
185
|
+
// in-flight dataPromise instead of issuing a second getItem/setItem (e.g. a
|
|
186
|
+
// second KV round-trip) for one logical cached loader. The shortCode only
|
|
187
|
+
// affects the emitted segmentId in resolveLoaders, not the cached value.
|
|
188
|
+
const existing = overrides.get(loaderId);
|
|
189
|
+
if (existing) return existing;
|
|
190
|
+
|
|
178
191
|
const dataPromise = (async () => {
|
|
179
192
|
const codec = await getCodec();
|
|
180
193
|
const key = await resolveLoaderKey(
|
|
@@ -94,6 +94,7 @@ export interface SegmentWrappers<TEnv = any> {
|
|
|
94
94
|
context: HandlerContext<any, TEnv>,
|
|
95
95
|
belongsToRoute?: boolean,
|
|
96
96
|
revalidationContext?: any,
|
|
97
|
+
options?: { skipMiddleware?: boolean },
|
|
97
98
|
) => Promise<ResolvedSegment[]>;
|
|
98
99
|
resolveInterceptLoadersOnly: (
|
|
99
100
|
interceptEntry: InterceptEntry,
|
|
@@ -245,6 +246,7 @@ export function createSegmentWrappers<TEnv = any>(
|
|
|
245
246
|
context: HandlerContext<any, TEnv>,
|
|
246
247
|
belongsToRoute: boolean = true,
|
|
247
248
|
revalidationContext?: any,
|
|
249
|
+
options?: { skipMiddleware?: boolean },
|
|
248
250
|
): ReturnType<typeof _resolveInterceptEntry> {
|
|
249
251
|
return _resolveInterceptEntry(
|
|
250
252
|
interceptEntry,
|
|
@@ -254,6 +256,7 @@ export function createSegmentWrappers<TEnv = any>(
|
|
|
254
256
|
belongsToRoute,
|
|
255
257
|
segmentDeps,
|
|
256
258
|
revalidationContext,
|
|
259
|
+
options,
|
|
257
260
|
);
|
|
258
261
|
}
|
|
259
262
|
|
|
@@ -81,6 +81,7 @@ export function tryTrieMatch(
|
|
|
81
81
|
result.wildcardValue,
|
|
82
82
|
pathname,
|
|
83
83
|
pathnameHasTrailingSlash,
|
|
84
|
+
result.validatedParams,
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -91,6 +92,14 @@ interface WalkResult {
|
|
|
91
92
|
leaf: TrieLeaf;
|
|
92
93
|
paramValues: string[];
|
|
93
94
|
wildcardValue?: string;
|
|
95
|
+
/**
|
|
96
|
+
* For a constraint-bearing leaf (leaf.cv set), the params map that
|
|
97
|
+
* leafConstraintsPass already decoded AND validated during the walk. Carried
|
|
98
|
+
* forward so validateAndBuild reuses it instead of re-decoding paramValues and
|
|
99
|
+
* re-running constraintsSatisfied a second time on the winning leaf. Undefined
|
|
100
|
+
* for unconstrained leaves (no decode/validate happened in the walk).
|
|
101
|
+
*/
|
|
102
|
+
validatedParams?: Record<string, string>;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/**
|
|
@@ -115,17 +124,23 @@ function constraintsSatisfied(
|
|
|
115
124
|
/**
|
|
116
125
|
* Constraint check for a candidate terminal DURING the walk. Builds the named
|
|
117
126
|
* params from positional walk values (decoded the same way validateAndBuild
|
|
118
|
-
* does) and validates leaf.cv. Returning
|
|
127
|
+
* does) and validates leaf.cv. Returning null lets walkTrie unwind to a
|
|
119
128
|
* lower-priority sibling instead of committing to a leaf that would only be
|
|
120
129
|
* rejected post-walk — that post-walk rejection is what forced the regex
|
|
121
130
|
* fallback (and its false "trie gap" R3 warning) for perfectly valid configs.
|
|
131
|
+
*
|
|
132
|
+
* On success returns the built+validated params for a constraint-bearing leaf so
|
|
133
|
+
* walkTrie can carry them to validateAndBuild (avoiding a second decode + a
|
|
134
|
+
* second constraintsSatisfied pass on the winner); returns the shared EMPTY_PASS
|
|
135
|
+
* sentinel for an unconstrained leaf (no work was done, nothing to carry).
|
|
122
136
|
*/
|
|
137
|
+
const EMPTY_PASS: Record<string, string> = {};
|
|
123
138
|
function leafConstraintsPass(
|
|
124
139
|
leaf: TrieLeaf,
|
|
125
140
|
paramValues: string[],
|
|
126
141
|
wildcardValue: string | undefined,
|
|
127
|
-
):
|
|
128
|
-
if (!leaf.cv) return
|
|
142
|
+
): Record<string, string> | null {
|
|
143
|
+
if (!leaf.cv) return EMPTY_PASS;
|
|
129
144
|
const params: Record<string, string> = {};
|
|
130
145
|
if (leaf.pa) {
|
|
131
146
|
for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
|
|
@@ -136,7 +151,7 @@ function leafConstraintsPass(
|
|
|
136
151
|
params[(leaf as TrieLeaf & { pn: string }).pn] =
|
|
137
152
|
safeDecodeURIComponent(wildcardValue);
|
|
138
153
|
}
|
|
139
|
-
return constraintsSatisfied(leaf, params);
|
|
154
|
+
return constraintsSatisfied(leaf, params) ? params : null;
|
|
140
155
|
}
|
|
141
156
|
|
|
142
157
|
/**
|
|
@@ -154,8 +169,19 @@ function walkTrie(
|
|
|
154
169
|
paramValues: string[],
|
|
155
170
|
): WalkResult | null {
|
|
156
171
|
if (index === segments.length) {
|
|
157
|
-
if (node.r
|
|
158
|
-
|
|
172
|
+
if (node.r) {
|
|
173
|
+
const validatedParams = leafConstraintsPass(
|
|
174
|
+
node.r,
|
|
175
|
+
paramValues,
|
|
176
|
+
undefined,
|
|
177
|
+
);
|
|
178
|
+
if (validatedParams) {
|
|
179
|
+
return {
|
|
180
|
+
leaf: node.r,
|
|
181
|
+
paramValues: [...paramValues],
|
|
182
|
+
validatedParams,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
159
185
|
}
|
|
160
186
|
// A wildcard at this node matches the bare prefix with an empty remainder
|
|
161
187
|
// (e.g. "/files" against "/files/*"), mirroring the regex matcher's `*=""`.
|
|
@@ -163,8 +189,16 @@ function walkTrie(
|
|
|
163
189
|
// so without this a request to the wildcard's own prefix misses the trie
|
|
164
190
|
// and the regex fallback emits a corrupt redirect. A static terminal
|
|
165
191
|
// (node.r) still wins.
|
|
166
|
-
if (node.w
|
|
167
|
-
|
|
192
|
+
if (node.w) {
|
|
193
|
+
const validatedParams = leafConstraintsPass(node.w, paramValues, "");
|
|
194
|
+
if (validatedParams) {
|
|
195
|
+
return {
|
|
196
|
+
leaf: node.w,
|
|
197
|
+
paramValues: [...paramValues],
|
|
198
|
+
wildcardValue: "",
|
|
199
|
+
validatedParams,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
168
202
|
}
|
|
169
203
|
return null;
|
|
170
204
|
}
|
|
@@ -206,11 +240,13 @@ function walkTrie(
|
|
|
206
240
|
|
|
207
241
|
if (node.w) {
|
|
208
242
|
const rest = joinRemainingSegments(segments, index);
|
|
209
|
-
|
|
243
|
+
const validatedParams = leafConstraintsPass(node.w, paramValues, rest);
|
|
244
|
+
if (validatedParams) {
|
|
210
245
|
return {
|
|
211
246
|
leaf: node.w,
|
|
212
247
|
paramValues: [...paramValues],
|
|
213
248
|
wildcardValue: rest,
|
|
249
|
+
validatedParams,
|
|
214
250
|
};
|
|
215
251
|
}
|
|
216
252
|
}
|
|
@@ -230,6 +266,15 @@ function joinRemainingSegments(segments: string[], start: number): string {
|
|
|
230
266
|
|
|
231
267
|
/**
|
|
232
268
|
* Post-match: validate constraints and handle trailing slash logic.
|
|
269
|
+
*
|
|
270
|
+
* `validatedParams` is the params map walkTrie already decoded AND validated via
|
|
271
|
+
* leafConstraintsPass for a constraint-bearing winning leaf. When present (and
|
|
272
|
+
* non-empty) we reuse it verbatim and SKIP the second decode + the second
|
|
273
|
+
* constraintsSatisfied pass — both are byte-identical to the walk-time work.
|
|
274
|
+
* When absent (unconstrained leaf, or the root-path call sites that never walk)
|
|
275
|
+
* we still BUILD the params here (that is not redundant — they must be returned)
|
|
276
|
+
* and run constraintsSatisfied for safety; an unconstrained leaf's check is a
|
|
277
|
+
* cheap early `!leaf.cv` return.
|
|
233
278
|
*/
|
|
234
279
|
function validateAndBuild(
|
|
235
280
|
leaf: TrieLeaf,
|
|
@@ -237,21 +282,30 @@ function validateAndBuild(
|
|
|
237
282
|
wildcardValue: string | undefined,
|
|
238
283
|
originalPathname: string,
|
|
239
284
|
pathnameHasTrailingSlash: boolean,
|
|
285
|
+
validatedParams?: Record<string, string>,
|
|
240
286
|
): TrieMatchResult | null {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
287
|
+
let params: Record<string, string>;
|
|
288
|
+
// EMPTY_PASS (the unconstrained sentinel) and undefined both mean "nothing was
|
|
289
|
+
// pre-validated"; only a populated map carried from a constraint-bearing leaf
|
|
290
|
+
// lets us skip the rebuild + re-check.
|
|
291
|
+
if (validatedParams && validatedParams !== EMPTY_PASS) {
|
|
292
|
+
params = validatedParams;
|
|
293
|
+
} else {
|
|
294
|
+
params = {};
|
|
295
|
+
if (leaf.pa) {
|
|
296
|
+
for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
|
|
297
|
+
params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
|
|
298
|
+
}
|
|
245
299
|
}
|
|
246
|
-
}
|
|
247
300
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
301
|
+
if (wildcardValue !== undefined && "pn" in leaf) {
|
|
302
|
+
params[(leaf as TrieLeaf & { pn: string }).pn] =
|
|
303
|
+
safeDecodeURIComponent(wildcardValue);
|
|
304
|
+
}
|
|
252
305
|
|
|
253
|
-
|
|
254
|
-
|
|
306
|
+
if (!constraintsSatisfied(leaf, params)) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
255
309
|
}
|
|
256
310
|
|
|
257
311
|
const tsMode = leaf.ts as "never" | "always" | "ignore" | undefined;
|
package/src/router.ts
CHANGED
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
import { loadManifest } from "./router/manifest.js";
|
|
64
64
|
import { createMetricsStore } from "./router/metrics.js";
|
|
65
65
|
import {
|
|
66
|
-
|
|
66
|
+
compileMiddlewarePattern,
|
|
67
67
|
type MiddlewareEntry,
|
|
68
68
|
type MiddlewareFn,
|
|
69
69
|
} from "./router/middleware.js";
|
|
@@ -347,7 +347,7 @@ export function createRouter<TEnv = any>(
|
|
|
347
347
|
let regex: RegExp | null = null;
|
|
348
348
|
let paramNames: string[] = [];
|
|
349
349
|
if (fullPattern) {
|
|
350
|
-
const parsed =
|
|
350
|
+
const parsed = compileMiddlewarePattern(fullPattern);
|
|
351
351
|
regex = parsed.regex;
|
|
352
352
|
paramNames = parsed.paramNames;
|
|
353
353
|
}
|