@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.
Files changed (54) hide show
  1. package/dist/bin/rango.js +69 -24
  2. package/dist/vite/index.js +182 -41
  3. package/package.json +6 -3
  4. package/src/browser/connection-warmup.ts +134 -0
  5. package/src/browser/event-controller.ts +5 -4
  6. package/src/browser/partial-update.ts +32 -16
  7. package/src/browser/react/NavigationProvider.tsx +6 -83
  8. package/src/browser/react/filter-segment-order.ts +17 -0
  9. package/src/browser/react/use-link-status.ts +10 -2
  10. package/src/browser/react/use-navigation.ts +10 -2
  11. package/src/build/route-types/ast-route-extraction.ts +15 -8
  12. package/src/build/route-types/include-resolution.ts +109 -21
  13. package/src/build/route-types/per-module-writer.ts +15 -2
  14. package/src/cache/cache-key-utils.ts +29 -13
  15. package/src/cache/cf/cf-cache-store.ts +129 -5
  16. package/src/decode-loader-results.ts +11 -1
  17. package/src/encode-kv.ts +49 -0
  18. package/src/handles/meta.ts +5 -1
  19. package/src/host/cookie-handler.ts +2 -21
  20. package/src/prerender/param-hash.ts +6 -5
  21. package/src/regex-escape.ts +8 -0
  22. package/src/route-definition/dsl-helpers.ts +6 -2
  23. package/src/router/error-handling.ts +32 -1
  24. package/src/router/handler-context.ts +6 -1
  25. package/src/router/instrument.ts +56 -14
  26. package/src/router/intercept-resolution.ts +16 -1
  27. package/src/router/loader-resolution.ts +49 -19
  28. package/src/router/match-middleware/background-revalidation.ts +6 -0
  29. package/src/router/match-middleware/cache-store.ts +6 -0
  30. package/src/router/middleware.ts +67 -27
  31. package/src/router/pattern-matching.ts +3 -9
  32. package/src/router/revalidation.ts +65 -23
  33. package/src/router/router-context.ts +1 -0
  34. package/src/router/router-options.ts +3 -3
  35. package/src/router/segment-resolution/fresh.ts +8 -9
  36. package/src/router/segment-resolution/helpers.ts +11 -10
  37. package/src/router/segment-resolution/loader-cache.ts +13 -0
  38. package/src/router/segment-resolution/revalidation.ts +4 -4
  39. package/src/router/segment-wrappers.ts +3 -0
  40. package/src/router/trie-matching.ts +74 -20
  41. package/src/router.ts +2 -2
  42. package/src/rsc/progressive-enhancement.ts +20 -0
  43. package/src/rsc/server-action.ts +124 -47
  44. package/src/search-params.ts +8 -6
  45. package/src/segment-system.tsx +7 -1
  46. package/src/server/cookie-parse.ts +32 -0
  47. package/src/server/handle-store.ts +14 -14
  48. package/src/server/request-context.ts +5 -26
  49. package/src/ssr/index.tsx +5 -4
  50. package/src/testing/render-handler.ts +11 -0
  51. package/src/vite/plugins/expose-id-utils.ts +77 -2
  52. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
  53. package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
  54. 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 { buildCombinedRouteMapWithSearch } from "./include-resolution.js";
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(filePath, varName);
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 internal _rsc* and __* params.
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 (!k.startsWith("_rsc") && !k.startsWith("__")) {
43
+ if (!isReservedSearchParam(k)) {
19
44
  pairs.push([k, v]);
20
45
  }
21
46
  }
22
- if (pairs.length === 0) return "";
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
- const entries = Object.entries(params);
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 isStale = staleAt > 0 && Date.now() > staleAt;
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: isStale,
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.toKVKey(`doc:${key}`);
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: Number(rawTaggedAt),
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.toKVKey(`doc:${key}`);
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
- throw new Error(result.error.message);
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
 
@@ -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
+ }
@@ -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.replace("%s", titleValue as string)
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
- const cookieHeader = request.headers.get("cookie");
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
- const sorted = entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
13
- const str = sorted
14
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
15
- .join("&");
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
- const name = `$${store.getNextIndex("transition")}`;
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}.${store.getNextIndex("transition")}`;
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
- const MUTATING_HEADERS_METHODS = new Set(["set", "append", "delete"]);
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) {
@@ -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 segment id, carried
139
- * as the rango.segment_id attribute to match the handler:<id> perf row. */
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.segment_id": id },
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, so skip the wrapper when only the perf
235
- // surface is active (traceSpan would apply them to NOOP_TRACE_SPAN for nothing).
236
- // `lazyAttributes` resolve AFTER fn runs (e.g. rango.route, known post-match).
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) && tracing
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((value) => {
247
- const late = lazy();
248
- if (late) applyAttributes(span, late);
249
- return value;
250
- }) as T;
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
- const late = lazy();
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