@rangojs/router 0.0.0-experimental.28 → 0.0.0-experimental.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/router-setup/SKILL.md +5 -0
- package/src/browser/navigation-client.ts +97 -72
- package/src/browser/prefetch/cache.ts +104 -25
- package/src/browser/prefetch/fetch.ts +28 -30
- package/src/browser/rsc-router.tsx +9 -1
- package/src/browser/types.ts +5 -0
- package/src/router/middleware-types.ts +34 -0
- package/src/router/middleware.ts +24 -54
- package/src/router/router-interfaces.ts +8 -1
- package/src/router/router-options.ts +12 -7
- package/src/router.ts +14 -7
- package/src/rsc/rsc-rendering.ts +1 -0
- package/src/rsc/types.ts +2 -0
package/dist/vite/index.js
CHANGED
|
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
|
|
|
1745
1745
|
// package.json
|
|
1746
1746
|
var package_default = {
|
|
1747
1747
|
name: "@rangojs/router",
|
|
1748
|
-
version: "0.0.0-experimental.
|
|
1748
|
+
version: "0.0.0-experimental.29",
|
|
1749
1749
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
1750
1750
|
keywords: [
|
|
1751
1751
|
"react",
|
package/package.json
CHANGED
|
@@ -108,6 +108,11 @@ interface RSCRouterOptions<TEnv> {
|
|
|
108
108
|
// Connection warmup (default: true)
|
|
109
109
|
warmup?: boolean;
|
|
110
110
|
|
|
111
|
+
// Prefetch cache TTL in seconds (default: 300)
|
|
112
|
+
// Controls in-memory cache duration and Cache-Control max-age for prefetch responses.
|
|
113
|
+
// Set to false to disable prefetch caching.
|
|
114
|
+
prefetchCacheTTL?: number | false;
|
|
115
|
+
|
|
111
116
|
// CSP nonce provider (for router.fetch)
|
|
112
117
|
nonce?: (
|
|
113
118
|
request: Request,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
emptyResponse,
|
|
18
18
|
teeWithCompletion,
|
|
19
19
|
} from "./response-adapter.js";
|
|
20
|
+
import { buildPrefetchKey, consumePrefetch } from "./prefetch/cache.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Create a navigation client for fetching RSC payloads
|
|
@@ -24,21 +25,12 @@ import {
|
|
|
24
25
|
* The client handles building URLs with RSC parameters and
|
|
25
26
|
* deserializing the response using the RSC runtime.
|
|
26
27
|
*
|
|
28
|
+
* Checks the in-memory prefetch cache before making a network request.
|
|
29
|
+
* The cache key is source-dependent (includes the previous URL) so
|
|
30
|
+
* prefetch responses match the exact diff the server would produce.
|
|
31
|
+
*
|
|
27
32
|
* @param deps - RSC browser dependencies (createFromFetch)
|
|
28
33
|
* @returns NavigationClient instance
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```typescript
|
|
32
|
-
* import { createFromFetch } from "@vitejs/plugin-rsc/browser";
|
|
33
|
-
*
|
|
34
|
-
* const client = createNavigationClient({ createFromFetch });
|
|
35
|
-
*
|
|
36
|
-
* const payload = await client.fetchPartial({
|
|
37
|
-
* targetUrl: "/shop/products",
|
|
38
|
-
* segmentIds: ["root", "shop"],
|
|
39
|
-
* previousUrl: "/",
|
|
40
|
-
* });
|
|
41
|
-
* ```
|
|
42
34
|
*/
|
|
43
35
|
export function createNavigationClient(
|
|
44
36
|
deps: Pick<RscBrowserDependencies, "createFromFetch">,
|
|
@@ -47,8 +39,9 @@ export function createNavigationClient(
|
|
|
47
39
|
/**
|
|
48
40
|
* Fetch a partial RSC payload for navigation
|
|
49
41
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
42
|
+
* First checks the in-memory prefetch cache for a matching entry.
|
|
43
|
+
* If found, uses the cached response instantly. Otherwise sends
|
|
44
|
+
* current segment IDs to the server for diff-based rendering.
|
|
52
45
|
*
|
|
53
46
|
* @param options - Fetch options
|
|
54
47
|
* @returns RSC payload with segments and metadata, plus stream completion promise
|
|
@@ -80,7 +73,8 @@ export function createNavigationClient(
|
|
|
80
73
|
});
|
|
81
74
|
}
|
|
82
75
|
|
|
83
|
-
// Build fetch URL with partial rendering params
|
|
76
|
+
// Build fetch URL with partial rendering params (used for both
|
|
77
|
+
// cache key lookup and actual fetch if cache misses)
|
|
84
78
|
const fetchUrl = new URL(targetUrl, window.location.origin);
|
|
85
79
|
fetchUrl.searchParams.set("_rsc_partial", "true");
|
|
86
80
|
fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
|
|
@@ -90,11 +84,17 @@ export function createNavigationClient(
|
|
|
90
84
|
if (version) {
|
|
91
85
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
92
86
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
87
|
+
|
|
88
|
+
// Check in-memory prefetch cache before making a network request.
|
|
89
|
+
// The cache key includes the source URL (previousUrl) because the
|
|
90
|
+
// server's diff response depends on the source page context.
|
|
91
|
+
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
92
|
+
// fresh modules), and intercept contexts (source-dependent responses).
|
|
93
|
+
const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
|
|
94
|
+
const cachedResponse =
|
|
95
|
+
!staleRevalidation && !hmr && !interceptSourceUrl
|
|
96
|
+
? consumePrefetch(cacheKey)
|
|
97
|
+
: null;
|
|
98
98
|
|
|
99
99
|
// Track when the stream completes
|
|
100
100
|
let resolveStreamComplete: () => void;
|
|
@@ -102,63 +102,88 @@ export function createNavigationClient(
|
|
|
102
102
|
resolveStreamComplete = resolve;
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
const responsePromise = fetch(fetchUrl, {
|
|
107
|
-
headers: {
|
|
108
|
-
"X-RSC-Router-Client-Path": previousUrl,
|
|
109
|
-
"X-Rango-State": getRangoState(),
|
|
110
|
-
...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
|
|
111
|
-
...(interceptSourceUrl && {
|
|
112
|
-
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
113
|
-
}),
|
|
114
|
-
...(hmr && { "X-RSC-HMR": "1" }),
|
|
115
|
-
},
|
|
116
|
-
signal,
|
|
117
|
-
}).then((response) => {
|
|
118
|
-
// Check for version mismatch - server wants us to reload
|
|
119
|
-
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
120
|
-
if (reload === "blocked") {
|
|
121
|
-
resolveStreamComplete();
|
|
122
|
-
return emptyResponse();
|
|
123
|
-
}
|
|
124
|
-
if (reload) {
|
|
125
|
-
if (tx) {
|
|
126
|
-
browserDebugLog(tx, "version mismatch, reloading", {
|
|
127
|
-
reloadUrl: reload.url,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
window.location.href = reload.url;
|
|
131
|
-
return new Promise<Response>(() => {});
|
|
132
|
-
}
|
|
105
|
+
let responsePromise: Promise<Response>;
|
|
133
106
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// navigation bridge catches it and re-navigates with _skipCache.
|
|
138
|
-
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
139
|
-
if (redirect === "blocked") {
|
|
140
|
-
resolveStreamComplete();
|
|
141
|
-
return emptyResponse();
|
|
107
|
+
if (cachedResponse) {
|
|
108
|
+
if (tx) {
|
|
109
|
+
browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
|
|
142
110
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
111
|
+
// Cached response body is already fully buffered (arrayBuffer),
|
|
112
|
+
// so stream completion is immediate.
|
|
113
|
+
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
114
|
+
return teeWithCompletion(
|
|
115
|
+
response,
|
|
116
|
+
() => {
|
|
117
|
+
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
118
|
+
resolveStreamComplete();
|
|
119
|
+
},
|
|
120
|
+
signal,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
if (tx) {
|
|
125
|
+
browserDebugLog(tx, "fetching", {
|
|
126
|
+
path: `${fetchUrl.pathname}${fetchUrl.search}`,
|
|
127
|
+
});
|
|
151
128
|
}
|
|
152
129
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
130
|
+
responsePromise = fetch(fetchUrl, {
|
|
131
|
+
headers: {
|
|
132
|
+
"X-RSC-Router-Client-Path": previousUrl,
|
|
133
|
+
"X-Rango-State": getRangoState(),
|
|
134
|
+
...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
|
|
135
|
+
...(interceptSourceUrl && {
|
|
136
|
+
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
137
|
+
}),
|
|
138
|
+
...(hmr && { "X-RSC-HMR": "1" }),
|
|
158
139
|
},
|
|
159
140
|
signal,
|
|
160
|
-
)
|
|
161
|
-
|
|
141
|
+
}).then((response) => {
|
|
142
|
+
// Check for version mismatch - server wants us to reload
|
|
143
|
+
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
144
|
+
if (reload === "blocked") {
|
|
145
|
+
resolveStreamComplete();
|
|
146
|
+
return emptyResponse();
|
|
147
|
+
}
|
|
148
|
+
if (reload) {
|
|
149
|
+
if (tx) {
|
|
150
|
+
browserDebugLog(tx, "version mismatch, reloading", {
|
|
151
|
+
reloadUrl: reload.url,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
window.location.href = reload.url;
|
|
155
|
+
return new Promise<Response>(() => {});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Server-side redirect without state: the server returned 204 with
|
|
159
|
+
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
160
|
+
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
161
|
+
// navigation bridge catches it and re-navigates with _skipCache.
|
|
162
|
+
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
163
|
+
if (redirect === "blocked") {
|
|
164
|
+
resolveStreamComplete();
|
|
165
|
+
return emptyResponse();
|
|
166
|
+
}
|
|
167
|
+
if (redirect) {
|
|
168
|
+
if (tx) {
|
|
169
|
+
browserDebugLog(tx, "server redirect", {
|
|
170
|
+
redirectUrl: redirect.url,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
resolveStreamComplete();
|
|
174
|
+
throw new ServerRedirect(redirect.url, undefined);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return teeWithCompletion(
|
|
178
|
+
response,
|
|
179
|
+
() => {
|
|
180
|
+
if (tx) browserDebugLog(tx, "stream complete");
|
|
181
|
+
resolveStreamComplete();
|
|
182
|
+
},
|
|
183
|
+
signal,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
162
187
|
|
|
163
188
|
try {
|
|
164
189
|
// Deserialize RSC payload
|
|
@@ -1,47 +1,127 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prefetch
|
|
2
|
+
* Prefetch Cache
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* In-memory cache storing prefetch Response objects for instant cache hits
|
|
5
|
+
* on subsequent navigation. Cache key is source-dependent (includes the
|
|
6
|
+
* current page URL) because the server's diff-based response depends on
|
|
7
|
+
* where the user navigates from.
|
|
8
|
+
*
|
|
9
|
+
* Replaces the previous browser HTTP cache approach which was unreliable
|
|
10
|
+
* due to response draining race conditions and browser inconsistencies.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import { cancelAllPrefetches } from "./queue.js";
|
|
10
14
|
import { invalidateRangoState } from "../rango-state.js";
|
|
11
15
|
|
|
16
|
+
// Default TTL: 5 minutes. Overridden by initPrefetchCache() with
|
|
17
|
+
// the server-configured prefetchCacheTTL from router options.
|
|
18
|
+
// 0 disables the in-memory cache entirely.
|
|
19
|
+
let cacheTTL = 300_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the prefetch cache with the configured TTL.
|
|
23
|
+
* Called once at app startup with the value from server metadata.
|
|
24
|
+
* A TTL of 0 disables the in-memory cache.
|
|
25
|
+
*/
|
|
26
|
+
export function initPrefetchCache(ttlMs: number): void {
|
|
27
|
+
cacheTTL = ttlMs;
|
|
28
|
+
}
|
|
29
|
+
const MAX_PREFETCH_CACHE_SIZE = 50;
|
|
30
|
+
|
|
31
|
+
interface PrefetchCacheEntry {
|
|
32
|
+
response: Response;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cache = new Map<string, PrefetchCacheEntry>();
|
|
12
37
|
const inflight = new Set<string>();
|
|
13
|
-
const prefetched = new Set<string>();
|
|
14
38
|
|
|
15
39
|
// Generation counter incremented on each clearPrefetchCache(). Fetches that
|
|
16
|
-
// started before a clear carry a stale generation and must not
|
|
17
|
-
//
|
|
18
|
-
// due to Rango-State rotation).
|
|
40
|
+
// started before a clear carry a stale generation and must not store their
|
|
41
|
+
// response (the data may be stale due to a server action invalidation).
|
|
19
42
|
let generation = 0;
|
|
20
43
|
|
|
21
44
|
/**
|
|
22
|
-
*
|
|
45
|
+
* Build a source-dependent cache key.
|
|
46
|
+
* Includes the source page href so the same target prefetched from
|
|
47
|
+
* different pages gets separate entries — the server response varies
|
|
48
|
+
* based on the source page context (diff-based rendering).
|
|
49
|
+
*/
|
|
50
|
+
export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
|
|
51
|
+
return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a prefetch is already cached, in-flight, or queued for the given key.
|
|
23
56
|
*/
|
|
24
57
|
export function hasPrefetch(key: string): boolean {
|
|
25
|
-
|
|
58
|
+
if (inflight.has(key)) return true;
|
|
59
|
+
if (cacheTTL <= 0) return false;
|
|
60
|
+
const entry = cache.get(key);
|
|
61
|
+
if (!entry) return false;
|
|
62
|
+
if (Date.now() - entry.timestamp > cacheTTL) {
|
|
63
|
+
cache.delete(key);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
26
67
|
}
|
|
27
68
|
|
|
28
69
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
70
|
+
* Consume a cached prefetch response. Returns null if not found or expired.
|
|
71
|
+
* One-time consumption: the entry is deleted after retrieval.
|
|
72
|
+
* Returns null when caching is disabled (TTL <= 0).
|
|
31
73
|
*/
|
|
32
|
-
export function
|
|
33
|
-
return
|
|
74
|
+
export function consumePrefetch(key: string): Response | null {
|
|
75
|
+
if (cacheTTL <= 0) return null;
|
|
76
|
+
const entry = cache.get(key);
|
|
77
|
+
if (!entry) return null;
|
|
78
|
+
if (Date.now() - entry.timestamp > cacheTTL) {
|
|
79
|
+
cache.delete(key);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
cache.delete(key);
|
|
83
|
+
return entry.response;
|
|
34
84
|
}
|
|
35
85
|
|
|
36
86
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
87
|
+
* Store a prefetch response in the in-memory cache.
|
|
88
|
+
* The response body must be fully buffered (e.g. via arrayBuffer()) before
|
|
89
|
+
* storing, so the cached Response is self-contained and network-independent.
|
|
90
|
+
*
|
|
91
|
+
* Skips storage if the generation has changed since the fetch started
|
|
92
|
+
* (a server action invalidated the cache mid-flight).
|
|
40
93
|
*/
|
|
41
|
-
export function
|
|
42
|
-
|
|
43
|
-
|
|
94
|
+
export function storePrefetch(
|
|
95
|
+
key: string,
|
|
96
|
+
response: Response,
|
|
97
|
+
fetchGeneration: number,
|
|
98
|
+
): void {
|
|
99
|
+
if (cacheTTL <= 0) return;
|
|
100
|
+
if (fetchGeneration !== generation) return;
|
|
101
|
+
|
|
102
|
+
// Evict expired entries
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
for (const [k, entry] of cache) {
|
|
105
|
+
if (now - entry.timestamp > cacheTTL) {
|
|
106
|
+
cache.delete(k);
|
|
107
|
+
}
|
|
44
108
|
}
|
|
109
|
+
|
|
110
|
+
// FIFO eviction if at capacity
|
|
111
|
+
if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
|
|
112
|
+
const oldest = cache.keys().next().value;
|
|
113
|
+
if (oldest) cache.delete(oldest);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
cache.set(key, { response, timestamp: now });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Capture the current generation. The returned value is passed to
|
|
121
|
+
* storePrefetch so it can detect stale completions.
|
|
122
|
+
*/
|
|
123
|
+
export function currentGeneration(): number {
|
|
124
|
+
return generation;
|
|
45
125
|
}
|
|
46
126
|
|
|
47
127
|
export function markPrefetchInflight(key: string): void {
|
|
@@ -53,15 +133,14 @@ export function clearPrefetchInflight(key: string): void {
|
|
|
53
133
|
}
|
|
54
134
|
|
|
55
135
|
/**
|
|
56
|
-
* Invalidate prefetch state. Called when server actions mutate data.
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* Also cancels any in-flight or queued speculative prefetches.
|
|
136
|
+
* Invalidate all prefetch state. Called when server actions mutate data.
|
|
137
|
+
* Clears the in-memory cache, cancels in-flight prefetches, and rotates
|
|
138
|
+
* the Rango state key so CDN-cached responses are also invalidated.
|
|
60
139
|
*/
|
|
61
140
|
export function clearPrefetchCache(): void {
|
|
62
141
|
generation++;
|
|
63
142
|
inflight.clear();
|
|
64
|
-
|
|
143
|
+
cache.clear();
|
|
65
144
|
cancelAllPrefetches();
|
|
66
145
|
invalidateRangoState();
|
|
67
146
|
}
|
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
* Prefetch Fetch
|
|
3
3
|
*
|
|
4
4
|
* Fetch-based prefetch logic used by Link (hover/viewport/render strategies)
|
|
5
|
-
* and useRouter().prefetch(). Sends
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* and useRouter().prefetch(). Sends the same headers and segment IDs as a
|
|
6
|
+
* real navigation so the server returns a proper diff. The Response is fully
|
|
7
|
+
* buffered and stored in an in-memory cache for instant consumption on
|
|
8
|
+
* subsequent navigation.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import {
|
|
12
|
+
buildPrefetchKey,
|
|
11
13
|
hasPrefetch,
|
|
12
14
|
markPrefetchInflight,
|
|
13
|
-
|
|
15
|
+
storePrefetch,
|
|
14
16
|
clearPrefetchInflight,
|
|
15
17
|
currentGeneration,
|
|
16
18
|
} from "./cache.js";
|
|
@@ -20,9 +22,9 @@ import { shouldPrefetch } from "./policy.js";
|
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Build an RSC partial URL for prefetching.
|
|
23
|
-
* Includes
|
|
24
|
-
*
|
|
25
|
-
*
|
|
25
|
+
* Includes _rsc_segments so the server can diff against currently mounted
|
|
26
|
+
* segments, and _rsc_v for version mismatch detection.
|
|
27
|
+
* Returns null for malformed or cross-origin URLs.
|
|
26
28
|
*/
|
|
27
29
|
function buildPrefetchUrl(
|
|
28
30
|
url: string,
|
|
@@ -49,18 +51,9 @@ function buildPrefetchUrl(
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* X-RSC-Router-Client-Path (source page context).
|
|
56
|
-
*/
|
|
57
|
-
function buildPrefetchKey(targetUrl: URL): string {
|
|
58
|
-
return window.location.href + "\0" + targetUrl.pathname + targetUrl.search;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Core prefetch fetch logic. Returns a Promise and accepts an optional
|
|
63
|
-
* AbortSignal for cancellation by the prefetch queue.
|
|
54
|
+
* Core prefetch fetch logic. Fetches the response, fully buffers the body,
|
|
55
|
+
* and stores it in the in-memory cache. Returns a Promise and accepts an
|
|
56
|
+
* optional AbortSignal for cancellation by the prefetch queue.
|
|
64
57
|
*/
|
|
65
58
|
function executePrefetchFetch(
|
|
66
59
|
key: string,
|
|
@@ -79,14 +72,19 @@ function executePrefetchFetch(
|
|
|
79
72
|
"X-Rango-Prefetch": "1",
|
|
80
73
|
},
|
|
81
74
|
})
|
|
82
|
-
.then((response) => {
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
75
|
+
.then(async (response) => {
|
|
76
|
+
if (!response.ok) return;
|
|
77
|
+
// Fully buffer the response body so the cached Response is
|
|
78
|
+
// self-contained and doesn't depend on the network connection.
|
|
79
|
+
// This eliminates the race condition where the user clicks before
|
|
80
|
+
// the response body has been fully downloaded.
|
|
81
|
+
const buffer = await response.arrayBuffer();
|
|
82
|
+
const cachedResponse = new Response(buffer, {
|
|
83
|
+
headers: response.headers,
|
|
84
|
+
status: response.status,
|
|
85
|
+
statusText: response.statusText,
|
|
86
|
+
});
|
|
87
|
+
storePrefetch(key, cachedResponse, gen);
|
|
90
88
|
})
|
|
91
89
|
.catch(() => {
|
|
92
90
|
// Silently ignore prefetch failures (including abort)
|
|
@@ -97,7 +95,7 @@ function executePrefetchFetch(
|
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
/**
|
|
100
|
-
* Prefetch (direct): fetch with low priority and store in
|
|
98
|
+
* Prefetch (direct): fetch with low priority and store in in-memory cache.
|
|
101
99
|
* Used by hover strategy -- fires immediately without queueing.
|
|
102
100
|
*/
|
|
103
101
|
export function prefetchDirect(
|
|
@@ -109,7 +107,7 @@ export function prefetchDirect(
|
|
|
109
107
|
|
|
110
108
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version);
|
|
111
109
|
if (!targetUrl) return;
|
|
112
|
-
const key = buildPrefetchKey(targetUrl);
|
|
110
|
+
const key = buildPrefetchKey(window.location.href, targetUrl);
|
|
113
111
|
if (hasPrefetch(key)) return;
|
|
114
112
|
executePrefetchFetch(key, targetUrl.toString());
|
|
115
113
|
}
|
|
@@ -127,7 +125,7 @@ export function prefetchQueued(
|
|
|
127
125
|
if (!shouldPrefetch()) return "";
|
|
128
126
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version);
|
|
129
127
|
if (!targetUrl) return "";
|
|
130
|
-
const key = buildPrefetchKey(targetUrl);
|
|
128
|
+
const key = buildPrefetchKey(window.location.href, targetUrl);
|
|
131
129
|
if (hasPrefetch(key)) return key;
|
|
132
130
|
const fetchUrlStr = targetUrl.toString();
|
|
133
131
|
enqueuePrefetch(key, (signal) =>
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
import type { EventController } from "./event-controller.js";
|
|
23
23
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
24
24
|
import { initRangoState } from "./rango-state.js";
|
|
25
|
+
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
25
26
|
import {
|
|
26
27
|
isInterceptSegment,
|
|
27
28
|
splitInterceptSegments,
|
|
@@ -201,10 +202,17 @@ export async function initBrowserApp(
|
|
|
201
202
|
const rootLayout = initialPayload.metadata?.rootLayout;
|
|
202
203
|
const version = initialPayload.metadata?.version;
|
|
203
204
|
|
|
204
|
-
// Initialize the localStorage state key for
|
|
205
|
+
// Initialize the localStorage state key for cache invalidation.
|
|
205
206
|
// Uses the build version so a new deploy automatically busts all cached prefetches.
|
|
206
207
|
initRangoState(version ?? "0");
|
|
207
208
|
|
|
209
|
+
// Initialize the in-memory prefetch cache TTL from server config.
|
|
210
|
+
// A value of 0 disables the cache; undefined falls back to the module default.
|
|
211
|
+
const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
|
|
212
|
+
if (prefetchCacheTTL !== undefined) {
|
|
213
|
+
initPrefetchCache(prefetchCacheTTL);
|
|
214
|
+
}
|
|
215
|
+
|
|
208
216
|
// Create a bound renderSegments that includes rootLayout
|
|
209
217
|
const renderSegments = (
|
|
210
218
|
segments: ResolvedSegment[],
|
package/src/browser/types.ts
CHANGED
|
@@ -55,6 +55,11 @@ export interface RscMetadata {
|
|
|
55
55
|
* Used to detect version mismatches after HMR/deployment.
|
|
56
56
|
*/
|
|
57
57
|
version?: string;
|
|
58
|
+
/**
|
|
59
|
+
* TTL in milliseconds for the client-side in-memory prefetch cache.
|
|
60
|
+
* Sent on initial render so the browser can configure its cache duration.
|
|
61
|
+
*/
|
|
62
|
+
prefetchCacheTTL?: number;
|
|
58
63
|
/**
|
|
59
64
|
* Theme configuration from router.
|
|
60
65
|
* Included when theme is enabled in router config.
|
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
DefaultVars,
|
|
13
13
|
} from "../types/global-namespace.js";
|
|
14
14
|
import type { ScopedReverseFunction } from "../reverse.js";
|
|
15
|
+
import type { Theme } from "../theme/types.js";
|
|
16
|
+
import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Get variable function type
|
|
@@ -79,12 +81,25 @@ export interface MiddlewareContext<
|
|
|
79
81
|
*/
|
|
80
82
|
readonly res: Response;
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Shorthand for ctx.res.headers — response headers.
|
|
86
|
+
* Before `next()`, returns headers from the shared response stub.
|
|
87
|
+
* After `next()`, returns headers from the downstream response.
|
|
88
|
+
*/
|
|
89
|
+
readonly headers: Headers;
|
|
90
|
+
|
|
82
91
|
/** Get a context variable (shared with route handlers) */
|
|
83
92
|
get: GetVariableFn;
|
|
84
93
|
|
|
85
94
|
/** Set a context variable (shared with route handlers) */
|
|
86
95
|
set: SetVariableFn;
|
|
87
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Middleware-injected variables.
|
|
99
|
+
* Same shared dictionary as `ctx.get()`/`ctx.set()`.
|
|
100
|
+
*/
|
|
101
|
+
var: DefaultVars;
|
|
102
|
+
|
|
88
103
|
/**
|
|
89
104
|
* Set a response header - can be called before or after `next()`
|
|
90
105
|
*
|
|
@@ -113,6 +128,25 @@ export interface MiddlewareContext<
|
|
|
113
128
|
*/
|
|
114
129
|
debugPerformance(): void;
|
|
115
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Current theme (from cookie or default).
|
|
133
|
+
* Only available when theme is enabled in router config.
|
|
134
|
+
*/
|
|
135
|
+
theme?: Theme;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Set the theme (only available when theme is enabled in router config).
|
|
139
|
+
* Sets a cookie with the new theme value.
|
|
140
|
+
*/
|
|
141
|
+
setTheme?: (theme: Theme) => void;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Attach location state entries to this response.
|
|
145
|
+
* State is delivered to the client via history.pushState and accessible
|
|
146
|
+
* through the useLocationState() hook.
|
|
147
|
+
*/
|
|
148
|
+
setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void;
|
|
149
|
+
|
|
116
150
|
/**
|
|
117
151
|
* Generate URLs from route names.
|
|
118
152
|
* - `name` — global route, from the named-routes definition
|
package/src/router/middleware.ts
CHANGED
|
@@ -34,22 +34,6 @@ export type {
|
|
|
34
34
|
} from "./middleware-types.js";
|
|
35
35
|
export { parseCookies, serializeCookie } from "./middleware-cookies.js";
|
|
36
36
|
|
|
37
|
-
// W5: Deduplicate by function reference so each distinct middleware warns once,
|
|
38
|
-
// regardless of whether it is named or anonymous.
|
|
39
|
-
let warnedRedirectMiddleware = new WeakSet<Function>();
|
|
40
|
-
|
|
41
|
-
function warnCtxSetBeforeRedirect(handler: Function): void {
|
|
42
|
-
if (warnedRedirectMiddleware.has(handler)) return;
|
|
43
|
-
warnedRedirectMiddleware.add(handler);
|
|
44
|
-
const label = handler.name || "(anonymous)";
|
|
45
|
-
console.warn(
|
|
46
|
-
`[rango] Route middleware "${label}" called ctx.set() then returned a ` +
|
|
47
|
-
`redirect. Context variables are per-request and won't be available ` +
|
|
48
|
-
`on the redirect target. Use cookies to persist state across ` +
|
|
49
|
-
`redirects, or move ctx.set() to the target route's middleware.`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
37
|
const MIDDLEWARE_METRIC_DEPTH = 1;
|
|
54
38
|
/** Ignore post-next() durations below this threshold (measurement noise). */
|
|
55
39
|
const POST_METRIC_MIN_DURATION_MS = 0.01;
|
|
@@ -75,11 +59,6 @@ function getMiddlewareMetricLabel<TEnv>(
|
|
|
75
59
|
return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
|
|
76
60
|
}
|
|
77
61
|
|
|
78
|
-
/** Reset W5 deduplication state (for tests only). */
|
|
79
|
-
export function _resetW5Warnings(): void {
|
|
80
|
-
warnedRedirectMiddleware = new WeakSet();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
62
|
/**
|
|
84
63
|
* Parse a route pattern into regex and param names
|
|
85
64
|
* Supports: *, /path, /path/*, /path/:param, /path/:param/*
|
|
@@ -221,6 +200,10 @@ export function createMiddlewareContext<TEnv>(
|
|
|
221
200
|
);
|
|
222
201
|
},
|
|
223
202
|
|
|
203
|
+
get headers(): Headers {
|
|
204
|
+
return this.res.headers;
|
|
205
|
+
},
|
|
206
|
+
|
|
224
207
|
get: ((keyOrVar: any) =>
|
|
225
208
|
contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
|
|
226
209
|
|
|
@@ -228,6 +211,8 @@ export function createMiddlewareContext<TEnv>(
|
|
|
228
211
|
contextSet(variables, keyOrVar, value);
|
|
229
212
|
}) as MiddlewareContext<TEnv>["set"],
|
|
230
213
|
|
|
214
|
+
var: variables as MiddlewareContext<TEnv>["var"],
|
|
215
|
+
|
|
231
216
|
header(name: string, value: string): void {
|
|
232
217
|
// Before next(): delegate to shared RequestContext stub
|
|
233
218
|
if (isPreNext()) {
|
|
@@ -246,6 +231,24 @@ export function createMiddlewareContext<TEnv>(
|
|
|
246
231
|
responseHolder.response.headers.set(name, value);
|
|
247
232
|
},
|
|
248
233
|
|
|
234
|
+
get theme(): MiddlewareContext<TEnv>["theme"] {
|
|
235
|
+
return _getRequestContext()?.theme;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
|
|
239
|
+
return _getRequestContext()?.setTheme;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
setLocationState(entries) {
|
|
243
|
+
const reqCtx = _getRequestContext();
|
|
244
|
+
if (!reqCtx) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
"setLocationState() is not available outside a request context",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
reqCtx.setLocationState(entries);
|
|
250
|
+
},
|
|
251
|
+
|
|
249
252
|
reverse:
|
|
250
253
|
reverse ??
|
|
251
254
|
((name: string) => {
|
|
@@ -425,16 +428,6 @@ export async function executeMiddleware<TEnv>(
|
|
|
425
428
|
return nextPromise;
|
|
426
429
|
};
|
|
427
430
|
|
|
428
|
-
// W5: track whether ctx.set() is called during this middleware
|
|
429
|
-
let ctxSetCalled = false;
|
|
430
|
-
if (process.env.NODE_ENV !== "production") {
|
|
431
|
-
const originalSet = ctx.set;
|
|
432
|
-
ctx.set = ((...args: any[]) => {
|
|
433
|
-
ctxSetCalled = true;
|
|
434
|
-
return (originalSet as Function).apply(ctx, args);
|
|
435
|
-
}) as typeof ctx.set;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
431
|
let result: Response | void;
|
|
439
432
|
try {
|
|
440
433
|
result = await entry.handler(ctx, wrappedNext);
|
|
@@ -464,16 +457,6 @@ export async function executeMiddleware<TEnv>(
|
|
|
464
457
|
// RequestContext stub headers (from ctx.setCookie) into the
|
|
465
458
|
// returned Response so they are not lost.
|
|
466
459
|
if (result instanceof Response) {
|
|
467
|
-
// W5: warn if ctx.set() was called but middleware returned a redirect
|
|
468
|
-
if (
|
|
469
|
-
process.env.NODE_ENV !== "production" &&
|
|
470
|
-
ctxSetCalled &&
|
|
471
|
-
result.status >= 300 &&
|
|
472
|
-
result.status < 400
|
|
473
|
-
) {
|
|
474
|
-
warnCtxSetBeforeRedirect(entry.handler);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
460
|
const mergedHeaders = new Headers(result.headers);
|
|
478
461
|
stubResponse.headers.forEach((value, name) => {
|
|
479
462
|
if (name.toLowerCase() === "set-cookie") {
|
|
@@ -524,19 +507,6 @@ export async function executeMiddleware<TEnv>(
|
|
|
524
507
|
// If middleware called next(), await it and return the response
|
|
525
508
|
if (nextPromise) {
|
|
526
509
|
await nextPromise;
|
|
527
|
-
|
|
528
|
-
// W5: warn if ctx.set() was called but the downstream response is a redirect.
|
|
529
|
-
// The ctx.set() values will be lost because the redirect navigates away.
|
|
530
|
-
if (
|
|
531
|
-
process.env.NODE_ENV !== "production" &&
|
|
532
|
-
ctxSetCalled &&
|
|
533
|
-
responseHolder.response &&
|
|
534
|
-
responseHolder.response.status >= 300 &&
|
|
535
|
-
responseHolder.response.status < 400
|
|
536
|
-
) {
|
|
537
|
-
warnCtxSetBeforeRedirect(entry.handler);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
510
|
return responseHolder.response!;
|
|
541
511
|
}
|
|
542
512
|
|
|
@@ -258,10 +258,17 @@ export interface RSCRouterInternal<
|
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
260
|
* Cache-Control header value for prefetch responses.
|
|
261
|
-
* False means no
|
|
261
|
+
* False means no caching of prefetch responses.
|
|
262
|
+
* Derived from prefetchCacheTTL.
|
|
262
263
|
*/
|
|
263
264
|
readonly prefetchCacheControl: string | false;
|
|
264
265
|
|
|
266
|
+
/**
|
|
267
|
+
* TTL in milliseconds for the client-side in-memory prefetch cache.
|
|
268
|
+
* 0 means caching is disabled.
|
|
269
|
+
*/
|
|
270
|
+
readonly prefetchCacheTTL: number;
|
|
271
|
+
|
|
265
272
|
/**
|
|
266
273
|
* Whether connection warmup is enabled.
|
|
267
274
|
* When true, the client sends HEAD /?_rsc_warmup after idle periods
|
|
@@ -415,16 +415,21 @@ export interface RSCRouterOptions<TEnv = any> {
|
|
|
415
415
|
version?: string;
|
|
416
416
|
|
|
417
417
|
/**
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* `X-Rango-Prefetch` header (sent by the Link component's prefetch fetch).
|
|
421
|
-
* Navigation responses are never cached by the browser.
|
|
418
|
+
* TTL (in seconds) for the in-memory prefetch cache and the
|
|
419
|
+
* Cache-Control header on prefetch responses.
|
|
422
420
|
*
|
|
423
|
-
*
|
|
421
|
+
* Controls how long prefetch responses are kept in the client-side
|
|
422
|
+
* in-memory cache and sets `Cache-Control: private, max-age=<ttl>`
|
|
423
|
+
* on server responses for CDN/edge caching.
|
|
424
424
|
*
|
|
425
|
-
*
|
|
425
|
+
* The cache is automatically invalidated on server actions regardless
|
|
426
|
+
* of TTL, so this is primarily a staleness safety net.
|
|
427
|
+
*
|
|
428
|
+
* Set to `false` to disable prefetch caching entirely.
|
|
429
|
+
*
|
|
430
|
+
* @default 300 (5 minutes)
|
|
426
431
|
*/
|
|
427
|
-
|
|
432
|
+
prefetchCacheTTL?: number | false;
|
|
428
433
|
|
|
429
434
|
/**
|
|
430
435
|
* Enable connection warmup to keep TCP+TLS alive after idle periods.
|
package/src/router.ts
CHANGED
|
@@ -147,7 +147,7 @@ export function createRouter<TEnv = any>(
|
|
|
147
147
|
$$sourceFile: injectedSourceFile,
|
|
148
148
|
nonce,
|
|
149
149
|
version,
|
|
150
|
-
|
|
150
|
+
prefetchCacheTTL: prefetchCacheTTLOption,
|
|
151
151
|
warmup: warmupOption,
|
|
152
152
|
allowDebugManifest: allowDebugManifestOption = false,
|
|
153
153
|
telemetry: telemetrySink,
|
|
@@ -200,11 +200,17 @@ export function createRouter<TEnv = any>(
|
|
|
200
200
|
const routerId =
|
|
201
201
|
userProvidedId ?? injectedId ?? `router_${nextRouterAutoId()}`;
|
|
202
202
|
|
|
203
|
-
// Resolve prefetch cache
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
203
|
+
// Resolve prefetch cache TTL (default: 300 seconds / 5 minutes)
|
|
204
|
+
// Clamp to a non-negative integer for valid Cache-Control max-age.
|
|
205
|
+
const rawTTL =
|
|
206
|
+
prefetchCacheTTLOption !== undefined ? prefetchCacheTTLOption : 300;
|
|
207
|
+
const prefetchCacheTTLSeconds =
|
|
208
|
+
rawTTL === false ? 0 : Math.max(0, Math.floor(rawTTL));
|
|
209
|
+
const prefetchCacheTTL = prefetchCacheTTLSeconds * 1000;
|
|
210
|
+
const prefetchCacheControl: string | false =
|
|
211
|
+
prefetchCacheTTLSeconds === 0
|
|
212
|
+
? false
|
|
213
|
+
: `private, max-age=${prefetchCacheTTLSeconds}`;
|
|
208
214
|
|
|
209
215
|
// Resolve warmup enabled flag (default: true)
|
|
210
216
|
const warmupEnabled = warmupOption !== false;
|
|
@@ -879,8 +885,9 @@ export function createRouter<TEnv = any>(
|
|
|
879
885
|
// Expose resolved cache profiles for per-request resolution
|
|
880
886
|
cacheProfiles: resolvedCacheProfiles,
|
|
881
887
|
|
|
882
|
-
// Expose prefetch cache
|
|
888
|
+
// Expose prefetch cache settings
|
|
883
889
|
prefetchCacheControl,
|
|
890
|
+
prefetchCacheTTL,
|
|
884
891
|
|
|
885
892
|
// Expose warmup enabled flag for handler and client
|
|
886
893
|
warmupEnabled,
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -62,6 +62,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
62
62
|
rootLayout: ctx.router.rootLayout,
|
|
63
63
|
handles: handleStore.stream(),
|
|
64
64
|
version: ctx.version,
|
|
65
|
+
prefetchCacheTTL: ctx.router.prefetchCacheTTL,
|
|
65
66
|
themeConfig: ctx.router.themeConfig,
|
|
66
67
|
initialTheme: reqCtx.theme,
|
|
67
68
|
},
|
package/src/rsc/types.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface RscPayload {
|
|
|
32
32
|
handles?: AsyncGenerator<HandleData, void, unknown>;
|
|
33
33
|
/** RSC version string for cache invalidation */
|
|
34
34
|
version?: string;
|
|
35
|
+
/** TTL in milliseconds for the client-side in-memory prefetch cache */
|
|
36
|
+
prefetchCacheTTL?: number;
|
|
35
37
|
/** Theme configuration for FOUC prevention */
|
|
36
38
|
themeConfig?: ResolvedThemeConfig | null;
|
|
37
39
|
/** Initial theme from cookie (for SSR hydration) */
|