@rangojs/router 0.0.0-experimental.130 → 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 +56 -14
- 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/fresh.ts +8 -9
- package/src/router/segment-resolution/helpers.ts +11 -10
- package/src/router/segment-resolution/loader-cache.ts +13 -0
- package/src/router/segment-resolution/revalidation.ts +4 -4
- 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
|
@@ -3,7 +3,10 @@ import ts from "typescript";
|
|
|
3
3
|
import { extractParamsFromPattern } from "./param-extraction.js";
|
|
4
4
|
import { extractRoutesFromSource } from "./ast-route-extraction.js";
|
|
5
5
|
import { generatePerModuleTypesSource } from "./codegen.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildCombinedRouteMapWithSearch,
|
|
8
|
+
createScanMemo,
|
|
9
|
+
} from "./include-resolution.js";
|
|
7
10
|
import type { ScanFilter } from "./scan-filter.js";
|
|
8
11
|
import { findTsFiles } from "./scan-filter.js";
|
|
9
12
|
|
|
@@ -78,10 +81,20 @@ export function writePerModuleRouteTypesForFile(filePath: string): void {
|
|
|
78
81
|
if (varNames.length > 0) {
|
|
79
82
|
// Follow includes recursively via the combined route map builder.
|
|
80
83
|
// The visited set in buildCombinedRouteMapWithSearch prevents infinite loops.
|
|
84
|
+
// Share one per-file scan memo across all urls() variables so a shared
|
|
85
|
+
// include target is read+parsed once for this file, not once per variable.
|
|
81
86
|
routes = [];
|
|
87
|
+
const memo = createScanMemo();
|
|
82
88
|
for (const varName of varNames) {
|
|
83
89
|
const { routes: routeMap, searchSchemas } =
|
|
84
|
-
buildCombinedRouteMapWithSearch(
|
|
90
|
+
buildCombinedRouteMapWithSearch(
|
|
91
|
+
filePath,
|
|
92
|
+
varName,
|
|
93
|
+
undefined,
|
|
94
|
+
undefined,
|
|
95
|
+
undefined,
|
|
96
|
+
memo,
|
|
97
|
+
);
|
|
85
98
|
for (const [name, pattern] of Object.entries(routeMap)) {
|
|
86
99
|
const params = extractParamsFromPattern(pattern);
|
|
87
100
|
routes.push({
|
|
@@ -6,24 +6,45 @@
|
|
|
6
6
|
* document-cache, and loader-cache.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { encodeKV } from "../encode-kv.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reserved URL query params that the router owns and must never key the cache
|
|
13
|
+
* on. `_rsc*` is the router's internal navigation/action/loader prefix (matched
|
|
14
|
+
* by prefix). `__no_cache` is the single `__`-prefixed param the router reads
|
|
15
|
+
* (handler.ts / testing dispatch.ts use it to bypass the store); it and the
|
|
16
|
+
* other router-internal `__`-prefixed request params are matched by an EXACT
|
|
17
|
+
* allowlist, not a blanket `__` prefix. A blanket `__` filter would silently
|
|
18
|
+
* collapse consumer params like `__variant=a` vs `__variant=b` onto one cache
|
|
19
|
+
* slot; an allowlist keeps the router's own params out of the key while leaving
|
|
20
|
+
* consumer `__` params intact.
|
|
21
|
+
*/
|
|
22
|
+
const RESERVED_SEARCH_PARAMS = new Set([
|
|
23
|
+
"__no_cache",
|
|
24
|
+
"__rsc",
|
|
25
|
+
"__html",
|
|
26
|
+
"__debug_manifest",
|
|
27
|
+
"__prerender_collect",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function isReservedSearchParam(key: string): boolean {
|
|
31
|
+
return key.startsWith("_rsc") || RESERVED_SEARCH_PARAMS.has(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
/**
|
|
10
35
|
* Build a sorted, deterministic query string from URLSearchParams,
|
|
11
|
-
* excluding
|
|
36
|
+
* excluding the router's reserved params (see isReservedSearchParam).
|
|
12
37
|
*
|
|
13
38
|
* Returns empty string when no user-facing params exist.
|
|
14
39
|
*/
|
|
15
40
|
export function sortedSearchString(searchParams: URLSearchParams): string {
|
|
16
41
|
const pairs: [string, string][] = [];
|
|
17
42
|
for (const [k, v] of searchParams) {
|
|
18
|
-
if (!
|
|
43
|
+
if (!isReservedSearchParam(k)) {
|
|
19
44
|
pairs.push([k, v]);
|
|
20
45
|
}
|
|
21
46
|
}
|
|
22
|
-
|
|
23
|
-
pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
24
|
-
return pairs
|
|
25
|
-
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
26
|
-
.join("&");
|
|
47
|
+
return encodeKV(pairs, { sort: true });
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
/**
|
|
@@ -35,10 +56,5 @@ export function sortedRouteParams(
|
|
|
35
56
|
params: Record<string, string> | undefined,
|
|
36
57
|
): string {
|
|
37
58
|
if (!params) return "";
|
|
38
|
-
|
|
39
|
-
if (entries.length === 0) return "";
|
|
40
|
-
return entries
|
|
41
|
-
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
42
|
-
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
43
|
-
.join("&");
|
|
59
|
+
return encodeKV(Object.entries(params), { sort: true });
|
|
44
60
|
}
|
|
@@ -1177,6 +1177,45 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1177
1177
|
else void write();
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
|
+
/**
|
|
1181
|
+
* Document-tier counterpart of markRevalidating for getResponse's herd guard.
|
|
1182
|
+
* The segment/item tiers JSON-parse the body, so they re-put with a string
|
|
1183
|
+
* body; document bodies are streamed verbatim, so we re-put with a CLONED
|
|
1184
|
+
* response body (`response.clone()`) supplied by the caller -- the original
|
|
1185
|
+
* body still streams to the client while the marker carries the clone. Same
|
|
1186
|
+
* REVALIDATING status header, same revalidating-at stamp, same
|
|
1187
|
+
* remainingCacheControl re-put math as markRevalidating, so the document tier
|
|
1188
|
+
* suppresses concurrent revalidation for the identical MAX_REVALIDATION_INTERVAL
|
|
1189
|
+
* window the segment tier does. Best-effort and non-blocking: a failed marker
|
|
1190
|
+
* write must not affect the served stale read.
|
|
1191
|
+
* @internal
|
|
1192
|
+
*/
|
|
1193
|
+
private markResponseRevalidating(
|
|
1194
|
+
cache: Cache,
|
|
1195
|
+
request: Request,
|
|
1196
|
+
clonedResponse: Response,
|
|
1197
|
+
): void {
|
|
1198
|
+
const reputNow = Date.now();
|
|
1199
|
+
const headers = new Headers(clonedResponse.headers);
|
|
1200
|
+
headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
|
|
1201
|
+
headers.set(CACHE_REVALIDATING_AT_HEADER, String(reputNow));
|
|
1202
|
+
headers.set("Cache-Control", remainingCacheControl(headers, reputNow));
|
|
1203
|
+
const markerResponse = new Response(clonedResponse.body, {
|
|
1204
|
+
status: clonedResponse.status,
|
|
1205
|
+
statusText: clonedResponse.statusText,
|
|
1206
|
+
headers,
|
|
1207
|
+
});
|
|
1208
|
+
const write = async (): Promise<void> => {
|
|
1209
|
+
try {
|
|
1210
|
+
await cache.put(request, markerResponse);
|
|
1211
|
+
} catch {
|
|
1212
|
+
// Best-effort: see markRevalidating.
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
if (this.waitUntil) this.waitUntil(write);
|
|
1216
|
+
else void write();
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1180
1219
|
// ============================================================================
|
|
1181
1220
|
// Segment Cache Methods
|
|
1182
1221
|
// ============================================================================
|
|
@@ -1592,7 +1631,23 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1592
1631
|
|
|
1593
1632
|
// Check staleness
|
|
1594
1633
|
const staleAt = Number(response.headers.get(CACHE_STALE_AT_HEADER) || 0);
|
|
1595
|
-
const
|
|
1634
|
+
const now = Date.now();
|
|
1635
|
+
const isStale = staleAt > 0 && now > staleAt;
|
|
1636
|
+
|
|
1637
|
+
// Thundering-herd guard, mirroring the segment (get) and item (getItem)
|
|
1638
|
+
// tiers. Without it, every concurrent stale reader returned
|
|
1639
|
+
// shouldRevalidate=true and document-cache.ts scheduled a fresh render for
|
|
1640
|
+
// each one. Recency comes from our own revalidating-at stamp, not CF's Age
|
|
1641
|
+
// header (see CACHE_REVALIDATING_AT_HEADER); an absent/zero stamp counts as
|
|
1642
|
+
// "not recent" so a dropped revalidation re-arms instead of pinning.
|
|
1643
|
+
const status = response.headers.get(CACHE_STATUS_HEADER);
|
|
1644
|
+
const revalidatingAt = Number(
|
|
1645
|
+
response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
|
|
1646
|
+
);
|
|
1647
|
+
const isRevalidating =
|
|
1648
|
+
status === "REVALIDATING" &&
|
|
1649
|
+
revalidatingAt > 0 &&
|
|
1650
|
+
now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
|
|
1596
1651
|
|
|
1597
1652
|
// L1 document bodies are streamed through verbatim - unlike the segment/
|
|
1598
1653
|
// item tiers (which JSON-parse and so structurally detect corruption) and
|
|
@@ -1601,9 +1656,25 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1601
1656
|
// response atomically or fails, so a truncated body is not served back. We
|
|
1602
1657
|
// deliberately do NOT buffer+hash the body to re-verify it: that would
|
|
1603
1658
|
// defeat streaming the document and add a full read to every cache hit.
|
|
1659
|
+
|
|
1660
|
+
if (isStale && !isRevalidating) {
|
|
1661
|
+
// First stale reader within the window: mark REVALIDATING (non-blocking,
|
|
1662
|
+
// best-effort) so concurrent readers below see the guard and suppress,
|
|
1663
|
+
// then return shouldRevalidate=true so this caller revalidates. Clone the
|
|
1664
|
+
// matched response for the marker since its original body must still
|
|
1665
|
+
// stream to the client.
|
|
1666
|
+
this.markResponseRevalidating(cache, request, response.clone());
|
|
1667
|
+
return {
|
|
1668
|
+
response: this.toClientResponse(response),
|
|
1669
|
+
shouldRevalidate: true,
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Fresh, or stale-but-already-REVALIDATING: serve without scheduling a
|
|
1674
|
+
// (re-)revalidation. A recent marker already has a render in flight.
|
|
1604
1675
|
return {
|
|
1605
1676
|
response: this.toClientResponse(response),
|
|
1606
|
-
shouldRevalidate:
|
|
1677
|
+
shouldRevalidate: false,
|
|
1607
1678
|
};
|
|
1608
1679
|
} catch (error) {
|
|
1609
1680
|
reportCacheError(error, "cache-read", "[CFCacheStore] getResponse");
|
|
@@ -1630,6 +1701,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1630
1701
|
headers.delete(CACHE_STATUS_HEADER);
|
|
1631
1702
|
headers.delete(CACHE_TAGS_HEADER);
|
|
1632
1703
|
headers.delete(CACHE_TAGGED_AT_HEADER);
|
|
1704
|
+
// Internal stale-path bookkeeping (hard-expiry deadline + REVALIDATING
|
|
1705
|
+
// stamp). Carried on doc L1 entries for the herd guard; never serve them.
|
|
1706
|
+
headers.delete(CACHE_EXPIRES_AT_HEADER);
|
|
1707
|
+
headers.delete(CACHE_REVALIDATING_AT_HEADER);
|
|
1633
1708
|
// Finding #3 (read side): strip per-client signals a pre-fix or
|
|
1634
1709
|
// pinned-version L1 entry may carry. See the read-side note in the design doc.
|
|
1635
1710
|
stripPerClientSignals(headers);
|
|
@@ -1683,6 +1758,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1683
1758
|
}
|
|
1684
1759
|
headers.set("Cache-Control", `public, max-age=${totalTtl}`);
|
|
1685
1760
|
headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
|
|
1761
|
+
// Absolute hard-expiry deadline so a stale-path REVALIDATING re-put can
|
|
1762
|
+
// recompute a shrinking max-age (remainingCacheControl) instead of
|
|
1763
|
+
// restarting retention. Mirrors set()/setItem(). Stripped by
|
|
1764
|
+
// toClientResponse before serving.
|
|
1765
|
+
headers.set(CACHE_EXPIRES_AT_HEADER, String(staleAt + swrWindow * 1000));
|
|
1686
1766
|
// Internal tag headers (stripped by toClientResponse before serving).
|
|
1687
1767
|
this.setTagHeaders(headers, tags, taggedAt);
|
|
1688
1768
|
|
|
@@ -1710,7 +1790,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1710
1790
|
|
|
1711
1791
|
// L2: persist to KV (KV requires expirationTtl >= 60s)
|
|
1712
1792
|
if (this.kv && this.waitUntil && totalTtl >= 60) {
|
|
1713
|
-
const kvKey = this.
|
|
1793
|
+
const kvKey = this.toDocKVKey(key);
|
|
1714
1794
|
// Finding #3: never persist a per-client signal in the KV envelope.
|
|
1715
1795
|
const headersArray: [string, string][] = [];
|
|
1716
1796
|
response.headers.forEach((v, k) => {
|
|
@@ -2039,6 +2119,37 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2039
2119
|
return `${versionPath}${key}`;
|
|
2040
2120
|
}
|
|
2041
2121
|
|
|
2122
|
+
/**
|
|
2123
|
+
* Host token for the current request, used to namespace the document KV key.
|
|
2124
|
+
* Derived from the same resolveBaseUrl() that namespaces the L1 (Cache API)
|
|
2125
|
+
* tier, so a doc entry's KV twin lands under the identical host bucket.
|
|
2126
|
+
* Falls back to "_" if the base URL cannot be parsed (it always carries a
|
|
2127
|
+
* trailing-slash origin, so parsing succeeds in practice).
|
|
2128
|
+
* @internal
|
|
2129
|
+
*/
|
|
2130
|
+
private docKVHost(): string {
|
|
2131
|
+
try {
|
|
2132
|
+
return new URL(this.resolveBaseUrl()).host || "_";
|
|
2133
|
+
} catch {
|
|
2134
|
+
return "_";
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/**
|
|
2139
|
+
* Convert a document key to its host-namespaced KV key. The L1 tier already
|
|
2140
|
+
* namespaces document entries by host via keyToRequest/resolveBaseUrl, but the
|
|
2141
|
+
* KV fallback keyed only on `doc:{key}`, so two hosts serving the same path
|
|
2142
|
+
* could collide on the KV tier (one host serving another's cached document).
|
|
2143
|
+
* Prefixing the host closes that cross-host collision. Deterministic per
|
|
2144
|
+
* (host, key). Segment/fn/tag-marker KV keys keep toKVKey unchanged: tag
|
|
2145
|
+
* markers are intentionally global (invalidation must cross hosts), and the
|
|
2146
|
+
* document tier is the one with a request-host context here.
|
|
2147
|
+
* @internal
|
|
2148
|
+
*/
|
|
2149
|
+
private toDocKVKey(key: string): string {
|
|
2150
|
+
return this.toKVKey(`h/${this.docKVHost()}/doc:${key}`);
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2042
2153
|
/**
|
|
2043
2154
|
* Best-effort delete of a single KV key, reporting (not swallowing) a delete
|
|
2044
2155
|
* failure as cache-delete. Used by the corrupt-entry self-heal paths.
|
|
@@ -2203,9 +2314,17 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2203
2314
|
const rawTaggedAt = headers.get(CACHE_TAGGED_AT_HEADER);
|
|
2204
2315
|
if (!rawTags || !rawTaggedAt) return {};
|
|
2205
2316
|
try {
|
|
2317
|
+
const taggedAt = Number(rawTaggedAt);
|
|
2318
|
+
// A corrupt/non-numeric tagged-at header yields NaN. isGloballyInvalidated
|
|
2319
|
+
// short-circuits on a falsy taggedAt (NaN is falsy), so returning
|
|
2320
|
+
// { taggedAt: NaN } would make the entry permanently NON-invalidatable -
|
|
2321
|
+
// a revalidateTag could never evict it. Treat a non-finite stamp the same
|
|
2322
|
+
// as the missing-header case (untagged): drop both tags and taggedAt so the
|
|
2323
|
+
// entry is re-rendered/re-tagged rather than silently un-invalidatable.
|
|
2324
|
+
if (!Number.isFinite(taggedAt)) return {};
|
|
2206
2325
|
return {
|
|
2207
2326
|
tags: JSON.parse(decodeURIComponent(rawTags)) as string[],
|
|
2208
|
-
taggedAt
|
|
2327
|
+
taggedAt,
|
|
2209
2328
|
};
|
|
2210
2329
|
} catch {
|
|
2211
2330
|
return {};
|
|
@@ -2858,7 +2977,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2858
2977
|
if (!this.kv) return null;
|
|
2859
2978
|
|
|
2860
2979
|
try {
|
|
2861
|
-
const kvKey = this.
|
|
2980
|
+
const kvKey = this.toDocKVKey(key);
|
|
2862
2981
|
// The document path is debug-silent (op is only get/getItem): a KV-read
|
|
2863
2982
|
// timeout here is bounded for resilience parity (kvGetOrEvict applies the
|
|
2864
2983
|
// budget) but emits no kv-timeout event, so its absence from the debug
|
|
@@ -2951,6 +3070,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2951
3070
|
}
|
|
2952
3071
|
headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
|
|
2953
3072
|
headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
|
|
3073
|
+
// Carry the hard-expiry deadline so the document herd guard's
|
|
3074
|
+
// markResponseRevalidating re-put can compute the remaining window
|
|
3075
|
+
// (matches promoteSegmentToL1/promoteItemToL1); without it a stale
|
|
3076
|
+
// re-put would floor to max-age=1 and churn the KV-promoted twin.
|
|
3077
|
+
headers.set(CACHE_EXPIRES_AT_HEADER, String(envelope.e));
|
|
2954
3078
|
// Re-attach the internal tag headers (envelope.hd is client-facing
|
|
2955
3079
|
// and intentionally excludes them) so the promoted entry stays
|
|
2956
3080
|
// invalidatable.
|
|
@@ -28,7 +28,17 @@ export function decodeLoaderResults(
|
|
|
28
28
|
if (result.fallback) {
|
|
29
29
|
errorFallback = result.fallback;
|
|
30
30
|
} else {
|
|
31
|
-
|
|
31
|
+
// No boundary: rethrow preserving the ErrorInfo identity (name/stack/
|
|
32
|
+
// code/cause) instead of a stripped generic Error.
|
|
33
|
+
const info = result.error;
|
|
34
|
+
const err = new Error(
|
|
35
|
+
info.message,
|
|
36
|
+
info.cause !== undefined ? { cause: info.cause } : undefined,
|
|
37
|
+
);
|
|
38
|
+
if (info.name) err.name = info.name;
|
|
39
|
+
if (info.stack) err.stack = info.stack;
|
|
40
|
+
if (info.code !== undefined) (err as { code?: string }).code = info.code;
|
|
41
|
+
throw err;
|
|
32
42
|
}
|
|
33
43
|
}
|
|
34
44
|
|
package/src/encode-kv.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared key=value serialization core.
|
|
3
|
+
*
|
|
4
|
+
* One encode+join rule shared by every deterministic query-string serializer
|
|
5
|
+
* in the codebase (cache keys, route-param strings, search-param strings,
|
|
6
|
+
* prerender param hashes). Each call site differs only in how it pre-filters
|
|
7
|
+
* and orders the pairs it hands in, plus whether it sorts; the encoding itself
|
|
8
|
+
* (`encodeURIComponent(k)=encodeURIComponent(v)` joined by `&`) is identical.
|
|
9
|
+
*
|
|
10
|
+
* Dependency-free leaf module so any layer (build-time prerender hashing,
|
|
11
|
+
* runtime cache keys, client search-param serialization) can import it without
|
|
12
|
+
* pulling in cache/router internals.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface EncodeKVOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Sort pairs by key in codepoint (byte) order before joining. Byte order via
|
|
18
|
+
* the `<` operator, NOT localeCompare, so the result is identical across
|
|
19
|
+
* Node, Workers, and browsers regardless of runtime locale.
|
|
20
|
+
*
|
|
21
|
+
* Defaults to false (insertion order preserved).
|
|
22
|
+
*/
|
|
23
|
+
sort?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Serialize an array of [key, value] pairs to a deterministic query string
|
|
28
|
+
* (no leading `?`). Both key and value are passed through encodeURIComponent
|
|
29
|
+
* so a key/value containing `&` or `=` cannot collide with a structurally
|
|
30
|
+
* different pair set.
|
|
31
|
+
*
|
|
32
|
+
* Callers are responsible for any filtering (e.g. dropping reserved params) and
|
|
33
|
+
* any value coercion (e.g. String(v), skipping null/undefined) BEFORE calling.
|
|
34
|
+
* This function never inspects or skips values; it encodes exactly what it is
|
|
35
|
+
* given so each call site keeps its own existing output.
|
|
36
|
+
*/
|
|
37
|
+
export function encodeKV(
|
|
38
|
+
pairs: Iterable<readonly [string, string]>,
|
|
39
|
+
options: EncodeKVOptions = {},
|
|
40
|
+
): string {
|
|
41
|
+
const list = [...pairs];
|
|
42
|
+
if (list.length === 0) return "";
|
|
43
|
+
if (options.sort) {
|
|
44
|
+
list.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
45
|
+
}
|
|
46
|
+
return list
|
|
47
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
48
|
+
.join("&");
|
|
49
|
+
}
|
package/src/handles/meta.ts
CHANGED
|
@@ -193,8 +193,12 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
|
|
|
193
193
|
continue;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// Insert the title literally. String.prototype.replace treats the
|
|
197
|
+
// replacement string specially ($&, $`, $', $$, $n), so a title like
|
|
198
|
+
// "Save $5" or one containing "$&" would be mangled. split/join inserts
|
|
199
|
+
// the raw value with no special-character interpretation.
|
|
196
200
|
const finalTitle = titleTemplate
|
|
197
|
-
? titleTemplate.
|
|
201
|
+
? titleTemplate.split("%s").join(titleValue as string)
|
|
198
202
|
: titleValue;
|
|
199
203
|
addOrReplace(
|
|
200
204
|
result,
|
|
@@ -6,29 +6,10 @@ import {
|
|
|
6
6
|
InvalidHostnameError,
|
|
7
7
|
HostValidationError,
|
|
8
8
|
} from "./errors.js";
|
|
9
|
+
import { parseCookiesFromHeader } from "../server/cookie-parse.js";
|
|
9
10
|
|
|
10
11
|
export function parseCookies(request: Request): Record<string, string> {
|
|
11
|
-
|
|
12
|
-
if (!cookieHeader) {
|
|
13
|
-
return {};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const cookies: Record<string, string> = {};
|
|
17
|
-
const pairs = cookieHeader.split(";");
|
|
18
|
-
|
|
19
|
-
for (const pair of pairs) {
|
|
20
|
-
const [name, ...rest] = pair.trim().split("=");
|
|
21
|
-
if (name && rest.length > 0) {
|
|
22
|
-
const value = rest.join("=");
|
|
23
|
-
try {
|
|
24
|
-
cookies[name] = decodeURIComponent(value);
|
|
25
|
-
} catch {
|
|
26
|
-
cookies[name] = value;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return cookies;
|
|
12
|
+
return parseCookiesFromHeader(request.headers.get("cookie"));
|
|
32
13
|
}
|
|
33
14
|
|
|
34
15
|
export function getCookie(request: Request, name: string): string | undefined {
|
|
@@ -4,16 +4,17 @@
|
|
|
4
4
|
* DJB2-based; works in all JS environments without crypto imports.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { encodeKV } from "../encode-kv.js";
|
|
8
|
+
|
|
7
9
|
// For static routes (no params), returns "_".
|
|
8
10
|
export function hashParams(params: Record<string, string>): string {
|
|
9
11
|
const entries = Object.entries(params);
|
|
10
12
|
if (entries.length === 0) return "_";
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return djb2Hex(str);
|
|
14
|
+
// Byte-order sort + encodeURIComponent join (see encodeKV); output is
|
|
15
|
+
// byte-identical to the prior inline implementation, so build-time and
|
|
16
|
+
// runtime hashes stay stable.
|
|
17
|
+
return djb2Hex(encodeKV(entries, { sort: true }));
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape a string for literal use inside a RegExp. Single source of truth for
|
|
3
|
+
* the router runtime (matching) and the vite build (transform/scan); a pure,
|
|
4
|
+
* dependency-free leaf so both environments can share it.
|
|
5
|
+
*/
|
|
6
|
+
export function escapeRegExp(input: string): string {
|
|
7
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
8
|
+
}
|
|
@@ -897,7 +897,11 @@ const transition = (
|
|
|
897
897
|
"transition() must be called inside urls()",
|
|
898
898
|
);
|
|
899
899
|
|
|
900
|
-
|
|
900
|
+
// Allocate a single index for this transition() call (used in all paths),
|
|
901
|
+
// mirroring cache() — the child form uses it for the name, the wrapper form
|
|
902
|
+
// reuses it for the namespace, so no index is burned.
|
|
903
|
+
const transitionIndex = store.getNextIndex("transition");
|
|
904
|
+
const name = `$${transitionIndex}`;
|
|
901
905
|
|
|
902
906
|
if (!children) {
|
|
903
907
|
// Position 1: child of path() — attach to parent entry
|
|
@@ -910,7 +914,7 @@ const transition = (
|
|
|
910
914
|
}
|
|
911
915
|
|
|
912
916
|
// Position 2: wrapper — create a transparent layout with transition config
|
|
913
|
-
const namespace = `${ctx.namespace}.${
|
|
917
|
+
const namespace = `${ctx.namespace}.${transitionIndex}`;
|
|
914
918
|
const entry = {
|
|
915
919
|
...emptySegmentBase(),
|
|
916
920
|
id: namespace,
|
|
@@ -169,6 +169,37 @@ export function findNearestNotFoundBoundary(
|
|
|
169
169
|
return defaultNotFoundBoundary || null;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Normalize an error's cause into a Flight-serializable shape.
|
|
174
|
+
* ErrorInfo.cause crosses the RSC serialization boundary (via
|
|
175
|
+
* LoaderDataResult.error + error-segment fallback props); a non-serializable
|
|
176
|
+
* cause (function, class instance, circular object) would make Flight
|
|
177
|
+
* serialization throw and mask the original loader error.
|
|
178
|
+
*/
|
|
179
|
+
function normalizeCause(cause: unknown): unknown {
|
|
180
|
+
if (cause == null) return undefined;
|
|
181
|
+
const t = typeof cause;
|
|
182
|
+
if (t === "string" || t === "number" || t === "boolean") return cause;
|
|
183
|
+
// The whole body is guarded: even `instanceof`/clone can run user code (a
|
|
184
|
+
// Proxy trap, a throwing getter), and this helper must never throw —
|
|
185
|
+
// throwing here would mask the original loader error it exists to protect.
|
|
186
|
+
try {
|
|
187
|
+
if (cause instanceof Error) {
|
|
188
|
+
return { name: cause.name, message: cause.message, stack: cause.stack };
|
|
189
|
+
}
|
|
190
|
+
// Prefer preserving a serializable object/array intact (Flight uses
|
|
191
|
+
// structured-clone-like semantics); fall back to a string when the value
|
|
192
|
+
// is circular, host-bound, or otherwise non-serializable.
|
|
193
|
+
return structuredClone(cause);
|
|
194
|
+
} catch {
|
|
195
|
+
try {
|
|
196
|
+
return String(cause);
|
|
197
|
+
} catch {
|
|
198
|
+
return "[unstringifiable cause]";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
172
203
|
/**
|
|
173
204
|
* Create ErrorInfo from an error object
|
|
174
205
|
* Sanitizes error details in production
|
|
@@ -186,7 +217,7 @@ export function createErrorInfo(
|
|
|
186
217
|
name: error.name,
|
|
187
218
|
code: (error as any).code,
|
|
188
219
|
stack: isDev ? error.stack : undefined,
|
|
189
|
-
cause: isDev ? error.cause : undefined,
|
|
220
|
+
cause: isDev ? normalizeCause(error.cause) : undefined,
|
|
190
221
|
segmentId,
|
|
191
222
|
segmentType,
|
|
192
223
|
};
|
|
@@ -21,6 +21,11 @@ import { PRERENDER_PASSTHROUGH } from "../prerender.js";
|
|
|
21
21
|
import { substitutePatternParams } from "./substitute-pattern-params.js";
|
|
22
22
|
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
23
23
|
|
|
24
|
+
// Mutating Headers methods guarded so they throw inside "use cache" / cache()
|
|
25
|
+
// scope. Module-level constant (read-only, .has() lookups) so it is allocated
|
|
26
|
+
// once at module load instead of per createHandlerContext (per-request) call.
|
|
27
|
+
const MUTATING_HEADERS_METHODS = new Set(["set", "append", "delete"]);
|
|
28
|
+
|
|
24
29
|
/**
|
|
25
30
|
* Strip internal _rsc* query params from a URL.
|
|
26
31
|
* Returns a new URL with only user-facing params.
|
|
@@ -212,7 +217,7 @@ export function createHandlerContext<TEnv>(
|
|
|
212
217
|
// Guard mutating Headers methods so they throw inside "use cache" or cache() scope.
|
|
213
218
|
// Uses lazy `ctx` reference (assigned below) — only the specific handler ctx
|
|
214
219
|
// is stamped by cache-runtime, not the shared request context.
|
|
215
|
-
|
|
220
|
+
// MUTATING_HEADERS_METHODS is hoisted to module scope (constant, read-only).
|
|
216
221
|
let ctx: InternalHandlerContext<any, TEnv>;
|
|
217
222
|
const guardedHeaders = new Proxy(stubResponse.headers, {
|
|
218
223
|
get(target, prop, receiver) {
|
package/src/router/instrument.ts
CHANGED
|
@@ -135,13 +135,15 @@ export const PHASES = {
|
|
|
135
135
|
/** One segment route/layout handler execution (the component/handler that
|
|
136
136
|
* produces a segment). Span only — the perf metric (handler:<id>) is owned by
|
|
137
137
|
* the legacy track() at the same call site, so observePhase here adds the
|
|
138
|
-
* rango.handler span without double-recording. `id` is the
|
|
139
|
-
*
|
|
138
|
+
* rango.handler span without double-recording. `id` is the HANDLER id (the
|
|
139
|
+
* entry.id used in the handler:<id> perf row), carried as rango.handler_id —
|
|
140
|
+
* NOT the emitted segment's id (shortCode), which differs; the *_id naming
|
|
141
|
+
* mirrors rango.loader_id / rango.action_id. */
|
|
140
142
|
handler: (id: string): PhaseSpec => ({
|
|
141
143
|
metric: false,
|
|
142
144
|
tracePhase: "handler",
|
|
143
145
|
spanName: "rango.handler",
|
|
144
|
-
attributes: { "rango.
|
|
146
|
+
attributes: { "rango.handler_id": id },
|
|
145
147
|
}),
|
|
146
148
|
|
|
147
149
|
/** Whole render phase: match + serialize + SSR. The metric label is resolved
|
|
@@ -231,26 +233,46 @@ export function observePhase<T>(
|
|
|
231
233
|
// Neither surface active: direct call, zero overhead.
|
|
232
234
|
if (!store && !tracing) return fn(NOOP_TRACE_SPAN);
|
|
233
235
|
|
|
234
|
-
// Attributes only land on a real span
|
|
235
|
-
//
|
|
236
|
-
//
|
|
236
|
+
// Attributes only land on a real span. Build the attribute/lazy wrapper only
|
|
237
|
+
// when this phase's span is actually enabled (not toggled off via `spans`), and
|
|
238
|
+
// short-circuit inside when the runner hands back the no-op span (tracing
|
|
239
|
+
// configured but off at runtime — e.g. no executionContext.tracing). That keeps
|
|
240
|
+
// the "configured but effectively off" path free of per-call attribute loops
|
|
241
|
+
// and lazy `.then()` allocations. `lazyAttributes` resolve AFTER fn runs (e.g.
|
|
242
|
+
// rango.route, known post-match) and apply on BOTH success and failure so an
|
|
243
|
+
// errored phase span is still tagged.
|
|
237
244
|
const attributes = spec.attributes;
|
|
238
245
|
const lazy = spec.lazyAttributes;
|
|
246
|
+
const spanEnabled =
|
|
247
|
+
tracing !== undefined && tracing.phases[spec.tracePhase] !== false;
|
|
239
248
|
const wrapped: (span: TraceSpan) => T =
|
|
240
|
-
(attributes || lazy) &&
|
|
249
|
+
(attributes || lazy) && spanEnabled
|
|
241
250
|
? (span) => {
|
|
251
|
+
if (span === NOOP_TRACE_SPAN) return fn(span);
|
|
242
252
|
if (attributes) applyAttributes(span, attributes);
|
|
253
|
+
// A SYNCHRONOUS throw from fn skips applyLate — fine by design: the only
|
|
254
|
+
// lazyAttributes phase (render) is always async, so any internal throw
|
|
255
|
+
// surfaces as a rejection that the onReject branch below DOES tag. If a
|
|
256
|
+
// sync lazyAttributes phase is ever added, wrap this in try/catch.
|
|
243
257
|
const out = fn(span);
|
|
244
258
|
if (!lazy) return out;
|
|
259
|
+
const applyLate = () => {
|
|
260
|
+
const late = lazy();
|
|
261
|
+
if (late) applyAttributes(span, late);
|
|
262
|
+
};
|
|
245
263
|
if (out instanceof Promise) {
|
|
246
|
-
return out.then(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
264
|
+
return out.then(
|
|
265
|
+
(value) => {
|
|
266
|
+
applyLate();
|
|
267
|
+
return value;
|
|
268
|
+
},
|
|
269
|
+
(error) => {
|
|
270
|
+
applyLate();
|
|
271
|
+
throw error;
|
|
272
|
+
},
|
|
273
|
+
) as T;
|
|
251
274
|
}
|
|
252
|
-
|
|
253
|
-
if (late) applyAttributes(span, late);
|
|
275
|
+
applyLate();
|
|
254
276
|
return out;
|
|
255
277
|
}
|
|
256
278
|
: fn;
|
|
@@ -269,6 +291,26 @@ export function observePhase<T>(
|
|
|
269
291
|
return runThenSettle(runSpan, () => recordPhaseMetric(store, metric, start));
|
|
270
292
|
}
|
|
271
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Open a rango.handler span around one segment route/layout handler call. The
|
|
296
|
+
* segment-resolution hot path runs this PER SEGMENT, so it gates on the SPAN
|
|
297
|
+
* surface alone and calls the handler directly otherwise — building neither the
|
|
298
|
+
* PhaseSpec (PHASES.handler allocates) nor the wrapper closure on the off path.
|
|
299
|
+
* The handler:<id> perf metric is owned by the track() at the call site, so the
|
|
300
|
+
* span is the only surface this adds (metric:false); a debugPerformance-only
|
|
301
|
+
* request (no tracing) or a disabled handler phase (spans:{handler:false}) has
|
|
302
|
+
* nothing to record here and short-circuits.
|
|
303
|
+
*/
|
|
304
|
+
export function observeHandler<C, R>(
|
|
305
|
+
id: string,
|
|
306
|
+
handler: (ctx: C) => R,
|
|
307
|
+
ctx: C,
|
|
308
|
+
): R {
|
|
309
|
+
const tracing = _getRequestContext()?._tracing;
|
|
310
|
+
if (!tracing || tracing.phases.handler === false) return handler(ctx);
|
|
311
|
+
return observePhase(PHASES.handler(id), () => handler(ctx));
|
|
312
|
+
}
|
|
313
|
+
|
|
272
314
|
/**
|
|
273
315
|
* Emit one discrete telemetry event (the event-shaped counterpart to
|
|
274
316
|
* observePhase). Resolves the sink from the active router context and stamps the
|