@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.
- package/AGENTS.md +8 -0
- package/README.md +43 -2
- package/dist/bin/rango.js +92 -16
- package/dist/vite/index.js +166 -70
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/bundle-analysis/SKILL.md +2 -2
- package/skills/cache-guide/SKILL.md +2 -2
- package/skills/caching/SKILL.md +16 -9
- package/skills/debug-manifest/SKILL.md +4 -2
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/handler-use/SKILL.md +1 -1
- package/skills/hooks/SKILL.md +2 -2
- package/skills/host-router/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +1 -1
- package/skills/loader/SKILL.md +2 -0
- package/skills/migrate-react-router/SKILL.md +4 -2
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/prerender/SKILL.md +2 -0
- package/skills/rango/SKILL.md +12 -11
- package/skills/response-routes/SKILL.md +2 -2
- package/skills/route/SKILL.md +4 -0
- package/skills/router-setup/SKILL.md +3 -0
- package/skills/scripts/SKILL.md +179 -0
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +20 -6
- package/skills/testing/cache-prerender.md +5 -2
- package/skills/testing/client-components.md +2 -0
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +8 -9
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/server-actions.md +11 -11
- package/skills/testing/setup.md +3 -0
- package/skills/typesafety/SKILL.md +3 -2
- package/skills/use-cache/SKILL.md +10 -9
- package/src/browser/event-controller.ts +109 -2
- package/src/browser/partial-update.ts +12 -0
- package/src/browser/prefetch/cache.ts +17 -0
- package/src/browser/prefetch/fetch.ts +69 -2
- package/src/browser/react/Link.tsx +30 -5
- package/src/browser/react/NavigationProvider.tsx +12 -2
- package/src/browser/react/location-state-shared.ts +14 -2
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +23 -2
- package/src/browser/response-adapter.ts +14 -3
- package/src/browser/rsc-router.tsx +3 -0
- package/src/browser/scroll-restoration.ts +8 -3
- package/src/browser/server-action-bridge.ts +46 -11
- package/src/browser/types.ts +6 -0
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-trie.ts +33 -9
- package/src/build/route-types/include-resolution.ts +7 -1
- package/src/build/route-types/router-processing.ts +0 -6
- package/src/build/route-types/source-scan.ts +105 -7
- package/src/cache/cache-policy.ts +42 -8
- package/src/cache/cache-runtime.ts +65 -5
- package/src/cache/cache-scope.ts +71 -11
- package/src/cache/cache-tag.ts +7 -2
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +85 -613
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/document-cache.ts +11 -0
- package/src/cache/handle-snapshot.ts +8 -1
- package/src/cache/profile-registry.ts +25 -1
- package/src/cache/segment-codec.ts +9 -1
- package/src/cache/types.ts +4 -0
- package/src/client.rsc.tsx +38 -0
- package/src/client.tsx +11 -0
- package/src/components/DefaultDocument.tsx +8 -2
- package/src/context-var.ts +1 -1
- package/src/decode-loader-results.ts +7 -1
- package/src/escape-script.ts +52 -0
- package/src/handles/MetaTags.tsx +56 -5
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +29 -11
- package/src/handles/is-thenable.ts +19 -0
- package/src/handles/meta.ts +46 -0
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +7 -3
- package/src/host/pattern-matcher.ts +16 -2
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +5 -0
- package/src/response-utils.ts +25 -0
- package/src/route-definition/dsl-helpers.ts +7 -0
- package/src/route-definition/redirect.ts +1 -2
- package/src/router/content-negotiation.ts +58 -10
- package/src/router/intercept-resolution.ts +9 -0
- package/src/router/match-middleware/cache-store.ts +10 -1
- package/src/router/middleware.ts +10 -3
- package/src/router/pattern-matching.ts +25 -23
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +23 -0
- package/src/router/segment-resolution/fresh.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +35 -1
- package/src/router/segment-resolution/loader-cache.ts +10 -6
- package/src/router/segment-resolution/revalidation.ts +6 -0
- package/src/router/segment-resolution.ts +1 -0
- package/src/router/trie-matching.ts +14 -9
- package/src/router.ts +18 -10
- package/src/rsc/handler.ts +52 -13
- package/src/rsc/helpers.ts +7 -1
- package/src/rsc/index.ts +1 -4
- package/src/rsc/loader-fetch.ts +107 -37
- package/src/rsc/progressive-enhancement.ts +18 -6
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-route-handler.ts +16 -133
- package/src/rsc/rsc-rendering.ts +13 -4
- package/src/rsc/server-action.ts +52 -6
- package/src/rsc/types.ts +7 -0
- package/src/search-params.ts +24 -5
- package/src/segment-loader-promise.ts +17 -2
- package/src/server/loader-registry.ts +16 -18
- package/src/server/request-context.ts +47 -20
- package/src/testing/dispatch.ts +108 -25
- package/src/testing/flight.ts +25 -0
- package/src/testing/internal/context.ts +25 -2
- package/src/testing/render-handler.ts +3 -1
- package/src/testing/render-route.tsx +15 -0
- package/src/testing/run-loader.ts +10 -3
- package/src/theme/ThemeProvider.tsx +20 -6
- package/src/theme/ThemeScript.tsx +7 -3
- package/src/theme/constants.ts +54 -3
- package/src/theme/theme-script.ts +22 -7
- package/src/types/request-scope.ts +8 -3
- package/src/vite/plugins/cjs-to-esm.ts +8 -1
- package/src/vite/plugins/expose-id-utils.ts +10 -1
- package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
- package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
- package/src/vite/plugins/expose-internal-ids.ts +0 -1
- package/src/vite/plugins/version-plugin.ts +5 -17
- package/src/vite/plugins/virtual-entries.ts +12 -2
- package/src/vite/rango.ts +15 -6
- package/src/vite/utils/ast-handler-extract.ts +11 -4
- package/src/vite/utils/directive-prologue.ts +40 -0
- 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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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(
|
package/src/browser/types.ts
CHANGED
|
@@ -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
|
package/src/build/route-trie.ts
CHANGED
|
@@ -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?:
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
17
|
-
// (
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
36
|
-
if (
|
|
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
|
-
|
|
50
|
-
if (
|
|
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
|
-
|
|
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
|
-
*
|
|
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(
|
|
90
|
+
export async function replyToCacheKey(
|
|
91
|
+
encoded: string | FormData,
|
|
92
|
+
): Promise<string> {
|
|
57
93
|
if (typeof encoded === "string") return encoded;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|