@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133

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 (141) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +43 -2
  3. package/dist/bin/rango.js +92 -16
  4. package/dist/vite/index.js +166 -70
  5. package/package.json +19 -18
  6. package/skills/breadcrumbs/SKILL.md +1 -1
  7. package/skills/bundle-analysis/SKILL.md +2 -2
  8. package/skills/cache-guide/SKILL.md +2 -2
  9. package/skills/caching/SKILL.md +16 -9
  10. package/skills/debug-manifest/SKILL.md +4 -2
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +1 -1
  13. package/skills/hooks/SKILL.md +2 -2
  14. package/skills/host-router/SKILL.md +1 -1
  15. package/skills/intercept/SKILL.md +1 -1
  16. package/skills/loader/SKILL.md +2 -0
  17. package/skills/migrate-react-router/SKILL.md +4 -2
  18. package/skills/mime-routes/SKILL.md +1 -1
  19. package/skills/prerender/SKILL.md +2 -0
  20. package/skills/rango/SKILL.md +12 -11
  21. package/skills/response-routes/SKILL.md +2 -2
  22. package/skills/route/SKILL.md +4 -0
  23. package/skills/router-setup/SKILL.md +3 -0
  24. package/skills/scripts/SKILL.md +179 -0
  25. package/skills/testing/SKILL.md +1 -1
  26. package/skills/testing/bindings.md +20 -6
  27. package/skills/testing/cache-prerender.md +5 -2
  28. package/skills/testing/client-components.md +2 -0
  29. package/skills/testing/e2e-parity.md +1 -1
  30. package/skills/testing/flight.md +8 -9
  31. package/skills/testing/render-handler.md +1 -1
  32. package/skills/testing/response-routes.md +1 -1
  33. package/skills/testing/server-actions.md +11 -11
  34. package/skills/testing/setup.md +3 -0
  35. package/skills/typesafety/SKILL.md +3 -2
  36. package/skills/use-cache/SKILL.md +10 -9
  37. package/src/browser/event-controller.ts +109 -2
  38. package/src/browser/partial-update.ts +12 -0
  39. package/src/browser/prefetch/cache.ts +17 -0
  40. package/src/browser/prefetch/fetch.ts +69 -2
  41. package/src/browser/react/Link.tsx +30 -5
  42. package/src/browser/react/NavigationProvider.tsx +12 -2
  43. package/src/browser/react/location-state-shared.ts +14 -2
  44. package/src/browser/react/use-href.tsx +8 -1
  45. package/src/browser/react/use-link-status.ts +23 -2
  46. package/src/browser/response-adapter.ts +14 -3
  47. package/src/browser/rsc-router.tsx +3 -0
  48. package/src/browser/scroll-restoration.ts +8 -3
  49. package/src/browser/server-action-bridge.ts +46 -11
  50. package/src/browser/types.ts +6 -0
  51. package/src/build/generate-route-types.ts +0 -1
  52. package/src/build/route-trie.ts +33 -9
  53. package/src/build/route-types/include-resolution.ts +7 -1
  54. package/src/build/route-types/router-processing.ts +0 -6
  55. package/src/build/route-types/source-scan.ts +105 -7
  56. package/src/cache/cache-policy.ts +42 -8
  57. package/src/cache/cache-runtime.ts +65 -5
  58. package/src/cache/cache-scope.ts +71 -11
  59. package/src/cache/cache-tag.ts +7 -2
  60. package/src/cache/cf/cf-base64.ts +33 -0
  61. package/src/cache/cf/cf-cache-constants.ts +127 -0
  62. package/src/cache/cf/cf-cache-store.ts +85 -613
  63. package/src/cache/cf/cf-cache-types.ts +349 -0
  64. package/src/cache/cf/cf-kv-utils.ts +46 -0
  65. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  66. package/src/cache/document-cache.ts +11 -0
  67. package/src/cache/handle-snapshot.ts +8 -1
  68. package/src/cache/profile-registry.ts +25 -1
  69. package/src/cache/segment-codec.ts +9 -1
  70. package/src/cache/types.ts +4 -0
  71. package/src/client.rsc.tsx +38 -0
  72. package/src/client.tsx +11 -0
  73. package/src/components/DefaultDocument.tsx +8 -2
  74. package/src/context-var.ts +1 -1
  75. package/src/decode-loader-results.ts +7 -1
  76. package/src/escape-script.ts +52 -0
  77. package/src/handles/MetaTags.tsx +56 -5
  78. package/src/handles/Scripts.tsx +183 -0
  79. package/src/handles/breadcrumbs.ts +29 -11
  80. package/src/handles/is-thenable.ts +19 -0
  81. package/src/handles/meta.ts +46 -0
  82. package/src/handles/script.ts +244 -0
  83. package/src/host/cookie-handler.ts +7 -3
  84. package/src/host/pattern-matcher.ts +16 -2
  85. package/src/index.rsc.ts +5 -0
  86. package/src/index.ts +5 -0
  87. package/src/response-utils.ts +25 -0
  88. package/src/route-definition/dsl-helpers.ts +7 -0
  89. package/src/route-definition/redirect.ts +1 -2
  90. package/src/router/content-negotiation.ts +58 -10
  91. package/src/router/intercept-resolution.ts +9 -0
  92. package/src/router/match-middleware/cache-store.ts +10 -1
  93. package/src/router/middleware.ts +10 -3
  94. package/src/router/pattern-matching.ts +25 -23
  95. package/src/router/prefetch-cache-ttl.ts +51 -0
  96. package/src/router/router-interfaces.ts +7 -0
  97. package/src/router/router-options.ts +23 -0
  98. package/src/router/segment-resolution/fresh.ts +10 -0
  99. package/src/router/segment-resolution/helpers.ts +35 -1
  100. package/src/router/segment-resolution/loader-cache.ts +10 -6
  101. package/src/router/segment-resolution/revalidation.ts +6 -0
  102. package/src/router/segment-resolution.ts +1 -0
  103. package/src/router/trie-matching.ts +14 -9
  104. package/src/router.ts +18 -10
  105. package/src/rsc/handler.ts +52 -13
  106. package/src/rsc/helpers.ts +7 -1
  107. package/src/rsc/index.ts +1 -4
  108. package/src/rsc/loader-fetch.ts +107 -37
  109. package/src/rsc/progressive-enhancement.ts +18 -6
  110. package/src/rsc/response-cache-serve.ts +238 -0
  111. package/src/rsc/response-route-handler.ts +16 -133
  112. package/src/rsc/rsc-rendering.ts +13 -4
  113. package/src/rsc/server-action.ts +52 -6
  114. package/src/rsc/types.ts +7 -0
  115. package/src/search-params.ts +24 -5
  116. package/src/segment-loader-promise.ts +17 -2
  117. package/src/server/loader-registry.ts +16 -18
  118. package/src/server/request-context.ts +47 -20
  119. package/src/testing/dispatch.ts +108 -25
  120. package/src/testing/flight.ts +25 -0
  121. package/src/testing/internal/context.ts +25 -2
  122. package/src/testing/render-handler.ts +3 -1
  123. package/src/testing/render-route.tsx +15 -0
  124. package/src/testing/run-loader.ts +10 -3
  125. package/src/theme/ThemeProvider.tsx +20 -6
  126. package/src/theme/ThemeScript.tsx +7 -3
  127. package/src/theme/constants.ts +54 -3
  128. package/src/theme/theme-script.ts +22 -7
  129. package/src/types/request-scope.ts +8 -3
  130. package/src/vite/plugins/cjs-to-esm.ts +8 -1
  131. package/src/vite/plugins/expose-id-utils.ts +10 -1
  132. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
  133. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
  134. package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
  135. package/src/vite/plugins/expose-internal-ids.ts +0 -1
  136. package/src/vite/plugins/version-plugin.ts +5 -17
  137. package/src/vite/plugins/virtual-entries.ts +12 -2
  138. package/src/vite/rango.ts +15 -6
  139. package/src/vite/utils/ast-handler-extract.ts +11 -4
  140. package/src/vite/utils/directive-prologue.ts +40 -0
  141. package/src/vite/utils/prerender-utils.ts +17 -2
@@ -12,7 +12,7 @@ import {
12
12
  reconcileErrorSegments,
13
13
  } from "./segment-reconciler.js";
14
14
  import { startTransition } from "react";
15
- import type { EventController } from "./event-controller.js";
15
+ import type { EventController, ActionHandle } from "./event-controller.js";
16
16
  import {
17
17
  toNetworkError,
18
18
  emitNetworkError,
@@ -58,6 +58,27 @@ export interface ServerActionBridgeConfigWithController extends ServerActionBrid
58
58
  ) => Promise<void>;
59
59
  }
60
60
 
61
+ /**
62
+ * Merge an action's location-state payload into history.state, restricted to
63
+ * the keys this action is entitled to write. claimLocationState enforces
64
+ * last-initiated-wins for same-key concurrent writes (a later-initiated sibling
65
+ * keeps its value even if this action's response settles afterward); distinct
66
+ * keys from every concurrent action survive. Arbitration is scoped to the
67
+ * action's cohort (captured at startAction), so a newer action on another entry
68
+ * cannot suppress this one. No-op when nothing was set or all keys were already
69
+ * claimed by a later-initiated sibling in the cohort.
70
+ */
71
+ function applyActionLocationState(
72
+ handle: ActionHandle,
73
+ locationState: Record<string, unknown> | undefined,
74
+ ): void {
75
+ if (!locationState) return;
76
+ const winning = handle.claimLocationState(locationState);
77
+ if (Object.keys(winning).length > 0) {
78
+ mergeLocationState(winning);
79
+ }
80
+ }
81
+
61
82
  /**
62
83
  * Create a server action bridge for handling RSC server actions
63
84
  *
@@ -165,8 +186,11 @@ export function createServerActionBridge(
165
186
  const locationKey = window.history.state?.key;
166
187
  log("action start", { id, argsCount: args.length });
167
188
 
168
- // Start action in event controller - handles lifecycle tracking
169
- const handle = eventController.startAction(id, args);
189
+ // Start action in event controller - handles lifecycle tracking. The
190
+ // current history key is the action's cohort: location-state arbitration is
191
+ // scoped to it so a later action on a different entry cannot suppress this
192
+ // one (and vice versa).
193
+ const handle = eventController.startAction(id, args, locationKey);
170
194
  // Whether the action's response carried the keepClientCache() directive.
171
195
  // Set when the response arrives; gates the deferred invalidation below.
172
196
  let keepCache = false;
@@ -627,6 +651,20 @@ export function createServerActionBridge(
627
651
  currentInterceptSource: store.getInterceptSourceUrl(),
628
652
  });
629
653
 
654
+ // Apply server-set location state exhaustively here, as a successful-
655
+ // response effect — the terminal switch below decides rendering/refetch,
656
+ // not whether this metadata survives (every refetch path is storeOnly and
657
+ // never writes history.state). Gated on NOT navigated-away: the classifier
658
+ // treats either a pathname OR history-key change as diversion, so this
659
+ // both honors the "diverted state is dropped" contract AND prevents a
660
+ // cross-entry write (mergeLocationState writes the CURRENT entry, which a
661
+ // navigated-away action no longer owns). Done before the switch (and
662
+ // before the normal branch's async renderSegments) so a slow render racing
663
+ // a navigation cannot drop it.
664
+ if (scenario.type !== "navigated-away") {
665
+ applyActionLocationState(handle, metadata?.locationState);
666
+ }
667
+
630
668
  switch (scenario.type) {
631
669
  case "navigated-away": {
632
670
  log("user navigated away during action", {
@@ -679,7 +717,8 @@ export function createServerActionBridge(
679
717
  log("consolidation fetch needed", {
680
718
  segmentIds: scenario.segmentIds,
681
719
  });
682
- // Calculate segments to send (exclude the ones we want fresh)
720
+ // Location state already applied above (pre-switch). Calculate
721
+ // segments to send (exclude the ones we want fresh).
683
722
  const currentSegmentIds = store.getSegmentState().currentSegmentIds;
684
723
  const segmentsToSend = currentSegmentIds.filter(
685
724
  (sid) => !scenario.segmentIds.includes(sid),
@@ -703,6 +742,8 @@ export function createServerActionBridge(
703
742
  // Only update store if history key hasn't changed (user didn't navigate away)
704
743
  const currentKeyNow = store.getHistoryKey();
705
744
  if (currentKeyNow === currentKey) {
745
+ // Location state already applied above (pre-switch); this action's
746
+ // UI render is skipped because a later sibling consolidates.
706
747
  store.setSegmentIds(matched);
707
748
  const currentHandleData = eventController.getHandleState().data;
708
749
  store.cacheSegmentsForHistory(
@@ -742,13 +783,7 @@ export function createServerActionBridge(
742
783
  onUpdate({ root: newTree, metadata: metadata! });
743
784
  });
744
785
 
745
- // Apply server-set location state to history.state (non-redirect flow)
746
- const actionLocationState = metadata?.locationState;
747
- if (actionLocationState) {
748
- mergeLocationState(actionLocationState);
749
- }
750
-
751
- // Update store state
786
+ // Location state already applied above (pre-switch). Update store.
752
787
  store.setSegmentIds(matched);
753
788
  const currentHandleData = eventController.getHandleState().data;
754
789
  store.cacheSegmentsForHistory(
@@ -90,6 +90,12 @@ export interface RscMetadata {
90
90
  basename?: string;
91
91
  /** Whether connection warmup is enabled */
92
92
  warmupEnabled?: boolean;
93
+ /**
94
+ * Whether the client should hydrate inside React.StrictMode. Carried on the
95
+ * initial full-render payload only; the browser entry reads it once at
96
+ * hydration. Defaults to true on the client when omitted.
97
+ */
98
+ strictMode?: boolean;
93
99
  /**
94
100
  * Server-side redirect with optional state (for partial requests).
95
101
  * `external: true` (from redirect(url, { external: true })) tells the client
@@ -24,7 +24,6 @@ export {
24
24
  extractIncludesWithDiagnostics,
25
25
  } from "./route-types/include-resolution.js";
26
26
  export {
27
- extractUrlsVariableFromRouter,
28
27
  extractUrlsFromRouter,
29
28
  extractBasenameFromRouter,
30
29
  type UrlsExtractionResult,
@@ -15,6 +15,19 @@ import type { FullManifest } from "./generate-manifest.js";
15
15
 
16
16
  // -- Trie data structures (compact keys for JSON serialization) --
17
17
 
18
+ /**
19
+ * A response-type variant folded into a primary leaf's negotiate list. `pa` is
20
+ * the variant's own positional param-name array, carried so the runtime can
21
+ * re-key the matched params under the variant's names when it wins negotiation
22
+ * (the trie match extracts params under the PRIMARY leaf's pa). Omitted when the
23
+ * variant has no params; absent/identical pa means no re-key is needed.
24
+ */
25
+ export interface NegotiateVariant {
26
+ routeKey: string;
27
+ responseType: string;
28
+ pa?: string[];
29
+ }
30
+
18
31
  export interface TrieLeaf {
19
32
  /** Route name (e.g., "site.l1_500") */
20
33
  n: string;
@@ -35,7 +48,7 @@ export interface TrieLeaf {
35
48
  /** Response type for non-RSC routes (json, text, image, any) */
36
49
  rt?: string;
37
50
  /** Negotiate variants: response-type routes sharing this path */
38
- nv?: Array<{ routeKey: string; responseType: string }>;
51
+ nv?: NegotiateVariant[];
39
52
  /** RSC-first: RSC route was defined before response-type variants */
40
53
  rf?: true;
41
54
  }
@@ -124,6 +137,9 @@ function sortSuffixParams(node: TrieNode): void {
124
137
  sorted[suffix] = node.xp[suffix];
125
138
  }
126
139
  node.xp = sorted;
140
+ for (const child of Object.values(node.xp)) {
141
+ sortSuffixParams(child.c);
142
+ }
127
143
  }
128
144
  if (node.s) {
129
145
  for (const child of Object.values(node.s)) {
@@ -133,11 +149,6 @@ function sortSuffixParams(node: TrieNode): void {
133
149
  if (node.p) {
134
150
  sortSuffixParams(node.p.c);
135
151
  }
136
- if (node.xp) {
137
- for (const child of Object.values(node.xp)) {
138
- sortSuffixParams(child.c);
139
- }
140
- }
141
152
  }
142
153
 
143
154
  /**
@@ -259,6 +270,19 @@ export function extractAncestryFromTrie(
259
270
  * appended to the nv (negotiate variants) array.
260
271
  * Multiple response types on the same path are supported (json + text + xml).
261
272
  */
273
+ /**
274
+ * Build a negotiate-variant entry from a leaf being folded into another leaf's
275
+ * nv list. Carries the variant's positional param names (`pa`) so the runtime
276
+ * can re-key matched params under the variant's names; omitted when the variant
277
+ * has none (the common case where primary and variant share the same names is a
278
+ * no-op re-key regardless).
279
+ */
280
+ function toVariant(leaf: TrieLeaf, responseType: string): NegotiateVariant {
281
+ return leaf.pa
282
+ ? { routeKey: leaf.n, responseType, pa: leaf.pa }
283
+ : { routeKey: leaf.n, responseType };
284
+ }
285
+
262
286
  function mergeLeaves(existing: TrieLeaf | undefined, leaf: TrieLeaf): TrieLeaf {
263
287
  if (!existing) return leaf;
264
288
 
@@ -266,7 +290,7 @@ function mergeLeaves(existing: TrieLeaf | undefined, leaf: TrieLeaf): TrieLeaf {
266
290
  // Both are response-type: preserve old as variant
267
291
  const merged = leaf;
268
292
  merged.nv = existing.nv || [];
269
- merged.nv.push({ routeKey: existing.n, responseType: existing.rt });
293
+ merged.nv.push(toVariant(existing, existing.rt));
270
294
  return merged;
271
295
  }
272
296
  if (leaf.rt && !existing.rt) {
@@ -276,7 +300,7 @@ function mergeLeaves(existing: TrieLeaf | undefined, leaf: TrieLeaf): TrieLeaf {
276
300
  existing.nv = [];
277
301
  existing.rf = true;
278
302
  }
279
- existing.nv.push({ routeKey: leaf.n, responseType: leaf.rt });
303
+ existing.nv.push(toVariant(leaf, leaf.rt));
280
304
  return existing;
281
305
  }
282
306
  if (!leaf.rt && existing.rt) {
@@ -284,7 +308,7 @@ function mergeLeaves(existing: TrieLeaf | undefined, leaf: TrieLeaf): TrieLeaf {
284
308
  // RSC was defined second (response-type was already the existing leaf)
285
309
  if (!leaf.nv) leaf.nv = [];
286
310
  if (existing.nv) leaf.nv.push(...existing.nv);
287
- leaf.nv.push({ routeKey: existing.n, responseType: existing.rt });
311
+ leaf.nv.push(toVariant(existing, existing.rt));
288
312
  // rf intentionally not set — RSC came after response-type variants
289
313
  return leaf;
290
314
  }
@@ -201,7 +201,13 @@ export function resolveImportedVariable(
201
201
  code: string,
202
202
  localName: string,
203
203
  ): { specifier: string; exportedName: string } | null {
204
- const importRegex = /import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g;
204
+ // Allow an optional leading default binding before the named-import brace so
205
+ // a combined `import Foo, { bar } from "..."` is matched (the named members
206
+ // are the only part we resolve; the default binding is skipped). Without the
207
+ // optional `(?:[\w$]+\s*,\s*)?` segment, the `Foo, ` prefix breaks the match
208
+ // and a legitimate static named import surfaces as `unresolvable-import`.
209
+ const importRegex =
210
+ /import\s*(?:[\w$]+\s*,\s*)?\{([^}]+)\}\s*from\s*["']([^"']+)["']/g;
205
211
  let match;
206
212
 
207
213
  while ((match = importRegex.exec(code)) !== null) {
@@ -322,12 +322,6 @@ export function extractBasenameFromRouter(code: string): string | undefined {
322
322
  return result;
323
323
  }
324
324
 
325
- /** @deprecated Use extractUrlsFromRouter instead */
326
- export function extractUrlsVariableFromRouter(code: string): string | null {
327
- const result = extractUrlsFromRouter(code);
328
- return result?.kind === "variable" ? result.name : null;
329
- }
330
-
331
325
  /** Apply a basename prefix to all route patterns in a result set. */
332
326
  function applyBasenameToRoutes(
333
327
  result: {
@@ -13,12 +13,19 @@
13
13
  // Memory: O(1) for the boolean check; O(#matches) for the index list. No
14
14
  // stripped copy and no per-char array are ever materialized.
15
15
  //
16
- // Pragmatic scanner, not a full tokenizer: regex literals are not special-cased
17
- // (a target token inside one is implausible) and template interpolations are
18
- // treated as opaque string content. One intentional consequence: a token whose
19
- // match would only complete by treating an interleaved comment as whitespace
20
- // (e.g. `createRouter /* x */ (`) is not detected — real calls never interleave
21
- // a comment between the callee and its arguments.
16
+ // Pragmatic scanner, not a full tokenizer: regex literals ARE coarsely skipped
17
+ // (see below) and template interpolations are treated as opaque string content.
18
+ // One intentional consequence: a token whose match would only complete by
19
+ // treating an interleaved comment as whitespace (e.g. `createRouter /* x */ (`)
20
+ // is not detected — real calls never interleave a comment between the callee
21
+ // and its arguments.
22
+ //
23
+ // Regex literals are skipped because a literal containing a quote or comment
24
+ // char (e.g. `const re = /it's a "x"/g;`) would otherwise open a phantom string
25
+ // at the inner quote and swallow the following REAL code — dropping a router
26
+ // file from discovery. We only treat a `/` as a regex start when it is in
27
+ // "regex position" (the previous significant code char is not value-producing),
28
+ // so genuine division (`a / b`) is left untouched.
22
29
 
23
30
  // JS line terminators end a `//` comment: LF, CR, LS (U+2028), PS (U+2029).
24
31
  function isLineTerminator(ch: string): boolean {
@@ -27,6 +34,56 @@ function isLineTerminator(ch: string): boolean {
27
34
  return c === 10 || c === 13 || c === 0x2028 || c === 0x2029;
28
35
  }
29
36
 
37
+ // Identifier-position keywords after which a `/` begins a regex literal, not
38
+ // division: the keyword cannot be the left operand of a division, so `return
39
+ // /re/`, `typeof /re/`, `case /re/`, etc. are regexes. After any OTHER identifier
40
+ // or number (a value), `/` is division.
41
+ const REGEX_PRECEDING_KEYWORDS = new Set([
42
+ "return",
43
+ "typeof",
44
+ "instanceof",
45
+ "in",
46
+ "of",
47
+ "new",
48
+ "delete",
49
+ "void",
50
+ "do",
51
+ "else",
52
+ "yield",
53
+ "await",
54
+ "case",
55
+ "throw",
56
+ ]);
57
+
58
+ // A `/` at `slashPos` is a regex-literal start (not division) when the previous
59
+ // significant code char cannot end an expression. A closing `)`/`]`/`}` and an
60
+ // identifier/digit/`$`/`_` are value-producing (division); everything else
61
+ // (operators, `(`, `,`, `=`, `:`, `{`, `;`, `<`, `>`, ...) and the start-of-file
62
+ // put `/` in regex position. The one subtlety: an identifier that is actually a
63
+ // regex-preceding KEYWORD (`return /re/`) ends in a word char, so the
64
+ // previous-char-only test misread it as division and then let the regex body's
65
+ // inner quotes open a phantom string — dropping a later real `createRouter()`.
66
+ // So when the previous char is a word char we walk back over any whitespace and
67
+ // the identifier and treat `/` as a regex iff that identifier is such a keyword.
68
+ // `}` stays value-producing to avoid swallowing an object/block followed by
69
+ // division; the cost is only that a regex right after a block isn't skipped.
70
+ function isRegexPositionAt(
71
+ code: string,
72
+ slashPos: number,
73
+ prevChar: string | undefined,
74
+ ): boolean {
75
+ if (prevChar === undefined) return true; // start of file
76
+ if (prevChar === ")" || prevChar === "]" || prevChar === "}") return false;
77
+ if (!/[\w$]/.test(prevChar)) return true; // operator / `(` / `,` / `=` / ...
78
+ // Previous char ends an identifier or number: regex only after a keyword that
79
+ // expects an expression. Walk back over whitespace + the identifier run.
80
+ let k = slashPos - 1;
81
+ while (k >= 0 && /\s/.test(code[k])) k--;
82
+ const wordEnd = k + 1;
83
+ while (k >= 0 && /[\w$]/.test(code[k])) k--;
84
+ return REGEX_PRECEDING_KEYWORDS.has(code.slice(k + 1, wordEnd));
85
+ }
86
+
30
87
  /**
31
88
  * Build a classifier that answers "is offset `q` in code (not a comment or
32
89
  * string)?" for STRICTLY INCREASING `q`. The internal cursor only moves forward,
@@ -37,6 +94,9 @@ function makeCodeClassifier(code: string): (q: number) => boolean {
37
94
  let i = 0; // forward cursor: everything before `i` is already classified
38
95
  let skipStart = -1; // last detected comment/string region (cache)
39
96
  let skipEnd = -1;
97
+ // Last significant code char, used to disambiguate `/` (regex vs division).
98
+ // Comments are transparent (don't update it); strings/regex are value-producing.
99
+ let lastSig: string | undefined;
40
100
 
41
101
  return (q: number): boolean => {
42
102
  if (q >= skipStart && q < skipEnd) return false; // q in the cached region
@@ -44,14 +104,17 @@ function makeCodeClassifier(code: string): (q: number) => boolean {
44
104
  const c = code[i];
45
105
  const d = i + 1 < n ? code[i + 1] : "";
46
106
  let end = -1;
107
+ let transparent = false; // comment: skipped but does not set lastSig
47
108
  if (c === "/" && d === "/") {
48
109
  let j = i + 2;
49
110
  while (j < n && !isLineTerminator(code[j])) j++;
50
111
  end = j;
112
+ transparent = true;
51
113
  } else if (c === "/" && d === "*") {
52
114
  let j = i + 2;
53
115
  while (j < n && !(code[j] === "*" && code[j + 1] === "/")) j++;
54
116
  end = Math.min(n, j + 2);
117
+ transparent = true;
55
118
  } else if (c === '"' || c === "'" || c === "`") {
56
119
  let j = i + 1;
57
120
  while (j < n) {
@@ -66,16 +129,51 @@ function makeCodeClassifier(code: string): (q: number) => boolean {
66
129
  j++;
67
130
  }
68
131
  end = j;
132
+ } else if (
133
+ c === "/" &&
134
+ d !== "/" &&
135
+ d !== "*" &&
136
+ isRegexPositionAt(code, i, lastSig)
137
+ ) {
138
+ // Coarse regex-literal skip. A regex literal cannot span a raw newline;
139
+ // `/` inside a `[...]` character class is literal (not a terminator).
140
+ // Bail (treat the `/` as a normal char) if no closing `/` on the line
141
+ // so a stray division-looking `/` never swallows the rest of the line.
142
+ let j = i + 1;
143
+ let inClass = false;
144
+ let closed = false;
145
+ while (j < n && !isLineTerminator(code[j])) {
146
+ const r = code[j];
147
+ if (r === "\\") {
148
+ j += 2;
149
+ continue;
150
+ }
151
+ if (r === "[") inClass = true;
152
+ else if (r === "]") inClass = false;
153
+ else if (r === "/" && !inClass) {
154
+ j++;
155
+ closed = true;
156
+ break;
157
+ }
158
+ j++;
159
+ }
160
+ if (closed) {
161
+ while (j < n && /[a-z]/.test(code[j])) j++; // flags
162
+ end = j;
163
+ }
69
164
  }
70
165
  if (end >= 0) {
71
- // Comment/string region [i, end). `q >= i` here (loop condition).
166
+ // Comment/string/regex region [i, end). `q >= i` here (loop condition).
72
167
  if (q < end) {
73
168
  skipStart = i;
74
169
  skipEnd = end;
75
170
  return false;
76
171
  }
77
172
  i = end;
173
+ // Strings and regex literals are value-producing; comments are not.
174
+ if (!transparent) lastSig = "x";
78
175
  } else {
176
+ if (!/\s/.test(c)) lastSig = c;
79
177
  i++;
80
178
  }
81
179
  }
@@ -10,6 +10,7 @@ import type { CacheDefaults, SegmentCacheStore } from "./types.js";
10
10
  import { _getRequestContext } from "../server/request-context.js";
11
11
  import type { RequestContext } from "../server/request-context.js";
12
12
  import { normalizeTags } from "./cache-tag.js";
13
+ import { reportCacheError } from "./cache-error.js";
13
14
 
14
15
  /**
15
16
  * Default TTL for route-level cache() DSL and loader cache.
@@ -23,31 +24,62 @@ export const DEFAULT_ROUTE_TTL = 60;
23
24
  */
24
25
  export const DEFAULT_FUNCTION_TTL = 900;
25
26
 
27
+ /**
28
+ * A finite, non-negative seconds value? A NaN/Infinity ttl/swr (from a bad
29
+ * cache() option or store defaults) flows into computeExpiration ->
30
+ * staleAt/expiresAt = NaN, where every `now > NaN` is false so the entry never
31
+ * evicts and is served fresh forever; a negative value makes every read a miss.
32
+ * Shared with cache-scope.ts (the segment getters) so every cache path validates
33
+ * the same way. profile-registry.ts uses the same predicate but fails fast at
34
+ * config time; the resolvers here degrade because they run on the live path.
35
+ */
36
+ export function isFiniteNonNegativeSeconds(value: number): boolean {
37
+ return Number.isFinite(value) && value >= 0;
38
+ }
39
+
40
+ function warnInvalidSeconds(label: string, value: number): void {
41
+ if (process.env.NODE_ENV !== "production") {
42
+ console.warn(`[cache] Invalid ${label} ${value}; falling back to default`);
43
+ }
44
+ }
45
+
26
46
  /**
27
47
  * Resolve effective TTL from the 3-tier cascade:
28
- * explicit → store defaults → fallback.
48
+ * explicit → store defaults → fallback. A non-finite/negative resolved value
49
+ * degrades to the fallback (loader cache and "use cache" setItem both resolve
50
+ * ttl here, so this is where those paths are guarded; the segment cache is
51
+ * guarded in cache-scope.ts).
29
52
  */
30
53
  export function resolveTtl(
31
54
  explicit: number | undefined,
32
55
  defaults: CacheDefaults | undefined,
33
56
  fallback: number,
34
57
  ): number {
35
- if (explicit !== undefined) return explicit;
36
- if (defaults?.ttl !== undefined) return defaults.ttl;
58
+ let value: number;
59
+ if (explicit !== undefined) value = explicit;
60
+ else if (defaults?.ttl !== undefined) value = defaults.ttl;
61
+ else return fallback;
62
+ if (isFiniteNonNegativeSeconds(value)) return value;
63
+ warnInvalidSeconds("ttl", value);
37
64
  return fallback;
38
65
  }
39
66
 
40
67
  /**
41
68
  * Resolve effective SWR window from the 2-tier cascade:
42
69
  * explicit → store defaults.
43
- * Returns 0 when unset (no SWR window).
70
+ * Returns 0 when unset (no SWR window) or when the resolved value is
71
+ * non-finite/negative (degrade rather than feed bad math into expiry).
44
72
  */
45
73
  export function resolveSwrWindow(
46
74
  explicit: number | undefined,
47
75
  defaults: CacheDefaults | undefined,
48
76
  ): number {
49
- if (explicit !== undefined) return explicit;
50
- if (defaults?.swr !== undefined) return defaults.swr;
77
+ let value: number;
78
+ if (explicit !== undefined) value = explicit;
79
+ else if (defaults?.swr !== undefined) value = defaults.swr;
80
+ else return 0;
81
+ if (isFiniteNonNegativeSeconds(value)) return value;
82
+ warnInvalidSeconds("swr", value);
51
83
  return 0;
52
84
  }
53
85
 
@@ -105,9 +137,11 @@ export function resolveTagsOption<TEnv>(
105
137
  try {
106
138
  return normalizeTagList(tags(ctx));
107
139
  } catch (error) {
108
- console.error(
109
- `[${label}] Tags function failed, caching without tags:`,
140
+ reportCacheError(
110
141
  error,
142
+ "cache-write",
143
+ `[${label}] Tags function failed, caching without tags`,
144
+ ctx,
111
145
  );
112
146
  return undefined;
113
147
  }
@@ -40,6 +40,7 @@ import {
40
40
  } from "./handle-snapshot.js";
41
41
  import { startHandleCapture, type HandleCapture } from "./handle-capture.js";
42
42
  import { sortedSearchString } from "./cache-key-utils.js";
43
+ import { encodeKV } from "../encode-kv.js";
43
44
  import { runBackground } from "./background-task.js";
44
45
  import {
45
46
  normalizeTags,
@@ -49,15 +50,74 @@ import {
49
50
  import { reportCacheError } from "./cache-error.js";
50
51
  import type { CacheItemResult } from "./types.js";
51
52
 
53
+ /**
54
+ * DJB2 hash returning an 8-char hex string. Deterministic across runtimes
55
+ * (no crypto import — cache-runtime runs on the edge). Mirrors prerender's
56
+ * param-hash djb2Hex so binary key parts hash consistently.
57
+ */
58
+ function djb2HexBytes(bytes: Uint8Array): string {
59
+ let hash = 5381;
60
+ for (let i = 0; i < bytes.length; i++) {
61
+ hash = ((hash << 5) + hash + bytes[i]!) >>> 0;
62
+ }
63
+ return hash.toString(16).padStart(8, "0");
64
+ }
65
+
52
66
  /**
53
67
  * Convert encodeReply result to a stable string key.
54
- * encodeReply may return string or FormData — normalize to string.
68
+ *
69
+ * encodeReply may return a string or FormData. A plain string is already
70
+ * deterministic for a given arg set, so return it verbatim. FormData (emitted
71
+ * whenever a key arg is a typed array / Blob / File / a large object React
72
+ * lazily chunks) carries a per-call RANDOM multipart boundary
73
+ * (`formdata-undici-<random>`); stringifying the whole body via
74
+ * `new Response(formData).text()` would therefore produce a DIFFERENT key on
75
+ * every call, so the cached function would always miss and the store would
76
+ * accumulate one duplicate entry per call (unbounded growth).
77
+ *
78
+ * Instead derive the key from the entries themselves, independent of the
79
+ * boundary: iterate in sorted-key order and, for each value, emit a
80
+ * boundary-free token — `s:<value>` for strings, `b:<size>:<type>:<name>:<hash>`
81
+ * for Blob/File (bytes folded via djb2 so distinct payloads of equal
82
+ * size/type/name still differ). Strings carry an `s:` type tag so a string whose
83
+ * value happens to equal a blob token (e.g. the literal `b:4::a:b:<hash>`) cannot
84
+ * collide with an actual Blob/File entry under the same FormData key. The
85
+ * user-controlled `type`/`name` are percent-encoded before joining so an embedded
86
+ * `:` cannot shift the field boundaries and collide two distinct files (e.g.
87
+ * {name:"a:b",type:""} vs {name:"b",type:":a"}). The result is stable across
88
+ * identical arg sets.
55
89
  */
56
- async function replyToCacheKey(encoded: string | FormData): Promise<string> {
90
+ export async function replyToCacheKey(
91
+ encoded: string | FormData,
92
+ ): Promise<string> {
57
93
  if (typeof encoded === "string") return encoded;
58
- // FormData: convert to Response body, then to string for deterministic key
59
- const text = await new Response(encoded).text();
60
- return text;
94
+
95
+ // Snapshot entries synchronously (forEach avoids relying on FormData's
96
+ // iterator typings), then fold any Blob/File bytes asynchronously.
97
+ const raw: [string, FormDataEntryValue][] = [];
98
+ encoded.forEach((value, key) => {
99
+ raw.push([key, value]);
100
+ });
101
+ const pairs: [string, string][] = [];
102
+ for (const [key, value] of raw) {
103
+ if (typeof value === "string") {
104
+ // Type-tag strings with `s:` so a string equal to a blob token (e.g.
105
+ // `b:4::a:b:<hash>`) cannot collide with a Blob/File entry under the same
106
+ // key (which carries the `b:` tag below).
107
+ pairs.push([key, "s:" + value]);
108
+ } else {
109
+ // Blob/File: fold the bytes into a deterministic, boundary-free token.
110
+ // Percent-encode the user-controlled type/name so an embedded `:` cannot
111
+ // shift the `:`-delimited field boundaries and collide distinct files.
112
+ const buf = await value.arrayBuffer();
113
+ const hash = djb2HexBytes(new Uint8Array(buf));
114
+ const name = "name" in value ? value.name : "";
115
+ const encType = encodeURIComponent(value.type);
116
+ const encName = encodeURIComponent(name);
117
+ pairs.push([key, `b:${value.size}:${encType}:${encName}:${hash}`]);
118
+ }
119
+ }
120
+ return encodeKV(pairs, { sort: true });
61
121
  }
62
122
 
63
123
  // Cached-fn ids already warned about running uncached under a test runner, so