@timber-js/app 0.1.52 → 0.1.54
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/client/index.js +300 -48
- package/dist/client/index.js.map +1 -1
- package/dist/client/router.d.ts +6 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +80 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -0
- package/dist/client/segment-cache.d.ts +8 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts +8 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts +119 -0
- package/dist/client/segment-merger.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +1 -1
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +7 -0
- package/src/client/router.ts +72 -209
- package/src/client/rsc-fetch.ts +234 -0
- package/src/client/segment-cache.ts +16 -5
- package/src/client/segment-context.ts +8 -1
- package/src/client/segment-merger.ts +345 -0
- package/src/server/route-element-builder.ts +56 -6
- package/src/server/rsc-entry/index.ts +3 -2
- package/src/server/rsc-entry/rsc-payload.ts +8 -1
- package/src/server/state-tree-diff.ts +18 -19
package/src/client/router.ts
CHANGED
|
@@ -7,6 +7,13 @@ import { HistoryStack } from './history';
|
|
|
7
7
|
import type { HeadElement } from './head';
|
|
8
8
|
import { setCurrentParams } from './use-params.js';
|
|
9
9
|
import { setNavigationState } from './navigation-context.js';
|
|
10
|
+
import {
|
|
11
|
+
SegmentElementCache,
|
|
12
|
+
cacheSegmentElements,
|
|
13
|
+
mergeSegmentTree,
|
|
14
|
+
} from './segment-merger.js';
|
|
15
|
+
import { fetchRscPayload, RedirectError } from './rsc-fetch.js';
|
|
16
|
+
import type { FetchResult } from './rsc-fetch.js';
|
|
10
17
|
|
|
11
18
|
// ─── Types ───────────────────────────────────────────────────────
|
|
12
19
|
|
|
@@ -71,16 +78,6 @@ export interface RouterDeps {
|
|
|
71
78
|
) => Promise<void>;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
/** Result of fetching an RSC payload — includes head elements and segment metadata. */
|
|
75
|
-
interface FetchResult {
|
|
76
|
-
payload: unknown;
|
|
77
|
-
headElements: HeadElement[] | null;
|
|
78
|
-
/** Segment metadata from X-Timber-Segments header for populating the segment cache. */
|
|
79
|
-
segmentInfo: SegmentInfo[] | null;
|
|
80
|
-
/** Route params from X-Timber-Params header for populating useParams(). */
|
|
81
|
-
params: Record<string, string | string[]> | null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
81
|
export interface RouterInstance {
|
|
85
82
|
/** Navigate to a new URL (forward navigation) */
|
|
86
83
|
navigate(url: string, options?: NavigationOptions): Promise<void>;
|
|
@@ -107,6 +104,12 @@ export interface RouterInstance {
|
|
|
107
104
|
* Called on initial hydration with segment info embedded in the HTML.
|
|
108
105
|
*/
|
|
109
106
|
initSegmentCache(segments: SegmentInfo[]): void;
|
|
107
|
+
/**
|
|
108
|
+
* Cache segment elements from a decoded RSC element tree.
|
|
109
|
+
* Called on initial hydration to populate the element cache so the
|
|
110
|
+
* first client navigation can use partial payloads.
|
|
111
|
+
*/
|
|
112
|
+
cacheElementTree(element: unknown): void;
|
|
110
113
|
/** The segment cache (exposed for tests and <Link> prefetch) */
|
|
111
114
|
segmentCache: SegmentCache;
|
|
112
115
|
/** The prefetch cache (exposed for tests and <Link> prefetch) */
|
|
@@ -115,18 +118,6 @@ export interface RouterInstance {
|
|
|
115
118
|
historyStack: HistoryStack;
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
/**
|
|
119
|
-
* Thrown when an RSC payload response contains X-Timber-Redirect header.
|
|
120
|
-
* Caught in navigate() to trigger a soft router navigation to the redirect target.
|
|
121
|
-
*/
|
|
122
|
-
class RedirectError extends Error {
|
|
123
|
-
readonly redirectUrl: string;
|
|
124
|
-
constructor(url: string) {
|
|
125
|
-
super(`Server redirect to ${url}`);
|
|
126
|
-
this.redirectUrl = url;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
121
|
/**
|
|
131
122
|
* Check if an error is an abort error (connection closed / fetch aborted).
|
|
132
123
|
* Browsers throw DOMException with name 'AbortError' when a fetch is aborted.
|
|
@@ -137,168 +128,6 @@ function isAbortError(error: unknown): boolean {
|
|
|
137
128
|
return false;
|
|
138
129
|
}
|
|
139
130
|
|
|
140
|
-
// ─── RSC Fetch ───────────────────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
const RSC_CONTENT_TYPE = 'text/x-component';
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Generate a short random cache-busting ID (5 chars, a-z0-9).
|
|
146
|
-
* Matches the format Next.js uses for _rsc params.
|
|
147
|
-
*/
|
|
148
|
-
function generateCacheBustId(): string {
|
|
149
|
-
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
150
|
-
let id = '';
|
|
151
|
-
for (let i = 0; i < 5; i++) {
|
|
152
|
-
id += chars[(Math.random() * 36) | 0];
|
|
153
|
-
}
|
|
154
|
-
return id;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Append a `_rsc=<id>` query parameter to the URL.
|
|
159
|
-
* Follows Next.js's pattern — prevents CDN/browser from serving cached HTML
|
|
160
|
-
* for RSC navigation requests and signals that this is an RSC fetch.
|
|
161
|
-
*/
|
|
162
|
-
function appendRscParam(url: string): string {
|
|
163
|
-
const separator = url.includes('?') ? '&' : '?';
|
|
164
|
-
return `${url}${separator}_rsc=${generateCacheBustId()}`;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function buildRscHeaders(
|
|
168
|
-
stateTree: { segments: string[] } | undefined,
|
|
169
|
-
currentUrl?: string
|
|
170
|
-
): Record<string, string> {
|
|
171
|
-
const headers: Record<string, string> = {
|
|
172
|
-
Accept: RSC_CONTENT_TYPE,
|
|
173
|
-
};
|
|
174
|
-
if (stateTree) {
|
|
175
|
-
headers['X-Timber-State-Tree'] = JSON.stringify(stateTree);
|
|
176
|
-
}
|
|
177
|
-
// Send current URL for intercepting route resolution.
|
|
178
|
-
// The server uses this to determine if an intercepting route should
|
|
179
|
-
// render instead of the actual target route (modal pattern).
|
|
180
|
-
// See design/07-routing.md §"Intercepting Routes"
|
|
181
|
-
if (currentUrl) {
|
|
182
|
-
headers['X-Timber-URL'] = currentUrl;
|
|
183
|
-
}
|
|
184
|
-
return headers;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Extract head elements from the X-Timber-Head response header.
|
|
189
|
-
* Returns null if the header is missing or malformed.
|
|
190
|
-
*/
|
|
191
|
-
function extractHeadElements(response: Response): HeadElement[] | null {
|
|
192
|
-
const header = response.headers.get('X-Timber-Head');
|
|
193
|
-
if (!header) return null;
|
|
194
|
-
try {
|
|
195
|
-
return JSON.parse(decodeURIComponent(header));
|
|
196
|
-
} catch {
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Extract segment metadata from the X-Timber-Segments response header.
|
|
203
|
-
* Returns null if the header is missing or malformed.
|
|
204
|
-
*
|
|
205
|
-
* Format: JSON array of {path, isAsync} objects describing the rendered
|
|
206
|
-
* segment chain from root to leaf. Used to populate the client-side
|
|
207
|
-
* segment cache for state tree diffing on subsequent navigations.
|
|
208
|
-
*/
|
|
209
|
-
function extractSegmentInfo(response: Response): SegmentInfo[] | null {
|
|
210
|
-
const header = response.headers.get('X-Timber-Segments');
|
|
211
|
-
if (!header) return null;
|
|
212
|
-
try {
|
|
213
|
-
return JSON.parse(header);
|
|
214
|
-
} catch {
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Extract route params from the X-Timber-Params response header.
|
|
221
|
-
* Returns null if the header is missing or malformed.
|
|
222
|
-
*
|
|
223
|
-
* Used to populate useParams() after client-side navigation.
|
|
224
|
-
*/
|
|
225
|
-
function extractParams(response: Response): Record<string, string | string[]> | null {
|
|
226
|
-
const header = response.headers.get('X-Timber-Params');
|
|
227
|
-
if (!header) return null;
|
|
228
|
-
try {
|
|
229
|
-
return JSON.parse(header);
|
|
230
|
-
} catch {
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Fetch an RSC payload from the server. If a decodeRsc function is provided,
|
|
237
|
-
* the response is decoded into a React element tree via createFromFetch.
|
|
238
|
-
* Otherwise, the raw response text is returned (test mode).
|
|
239
|
-
*
|
|
240
|
-
* Also extracts head elements from the X-Timber-Head response header
|
|
241
|
-
* so the client can update document.title and <meta> tags after navigation.
|
|
242
|
-
*/
|
|
243
|
-
async function fetchRscPayload(
|
|
244
|
-
url: string,
|
|
245
|
-
deps: RouterDeps,
|
|
246
|
-
stateTree?: { segments: string[] },
|
|
247
|
-
currentUrl?: string
|
|
248
|
-
): Promise<FetchResult> {
|
|
249
|
-
const rscUrl = appendRscParam(url);
|
|
250
|
-
const headers = buildRscHeaders(stateTree, currentUrl);
|
|
251
|
-
if (deps.decodeRsc) {
|
|
252
|
-
// Production path: use createFromFetch for streaming RSC decoding.
|
|
253
|
-
// createFromFetch takes a Promise<Response> and progressively parses
|
|
254
|
-
// the RSC Flight stream as chunks arrive.
|
|
255
|
-
//
|
|
256
|
-
// Intercept the response to read X-Timber-Head before createFromFetch
|
|
257
|
-
// consumes the body. Reading headers does NOT consume the body stream.
|
|
258
|
-
const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
259
|
-
let headElements: HeadElement[] | null = null;
|
|
260
|
-
let segmentInfo: SegmentInfo[] | null = null;
|
|
261
|
-
let params: Record<string, string | string[]> | null = null;
|
|
262
|
-
const wrappedPromise = fetchPromise.then((response) => {
|
|
263
|
-
// Detect server-side redirects. The server returns 204 + X-Timber-Redirect
|
|
264
|
-
// for RSC payload requests instead of a raw 302, because fetch with
|
|
265
|
-
// redirect: "manual" turns 302s into opaque redirects (status 0, null body)
|
|
266
|
-
// which crashes createFromFetch when it tries to read the body stream.
|
|
267
|
-
const redirectLocation =
|
|
268
|
-
response.headers.get('X-Timber-Redirect') ||
|
|
269
|
-
(response.status >= 300 && response.status < 400 ? response.headers.get('Location') : null);
|
|
270
|
-
if (redirectLocation) {
|
|
271
|
-
throw new RedirectError(redirectLocation);
|
|
272
|
-
}
|
|
273
|
-
headElements = extractHeadElements(response);
|
|
274
|
-
segmentInfo = extractSegmentInfo(response);
|
|
275
|
-
params = extractParams(response);
|
|
276
|
-
return response;
|
|
277
|
-
});
|
|
278
|
-
// Await so headElements/segmentInfo/params are populated before we return.
|
|
279
|
-
// Also await the decoded payload — createFromFetch returns a thenable
|
|
280
|
-
// that resolves to the React element tree.
|
|
281
|
-
await wrappedPromise;
|
|
282
|
-
const payload = await deps.decodeRsc(wrappedPromise);
|
|
283
|
-
return { payload, headElements, segmentInfo, params };
|
|
284
|
-
}
|
|
285
|
-
// Test/fallback path: return raw text
|
|
286
|
-
const response = await deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
287
|
-
// Check for redirect in test path too
|
|
288
|
-
if (response.status >= 300 && response.status < 400) {
|
|
289
|
-
const location = response.headers.get('Location');
|
|
290
|
-
if (location) {
|
|
291
|
-
throw new RedirectError(location);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
return {
|
|
295
|
-
payload: await response.text(),
|
|
296
|
-
headElements: extractHeadElements(response),
|
|
297
|
-
segmentInfo: extractSegmentInfo(response),
|
|
298
|
-
params: extractParams(response),
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
|
|
302
131
|
// ─── Router Factory ──────────────────────────────────────────────
|
|
303
132
|
|
|
304
133
|
/**
|
|
@@ -309,6 +138,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
309
138
|
const segmentCache = new SegmentCache();
|
|
310
139
|
const prefetchCache = new PrefetchCache();
|
|
311
140
|
const historyStack = new HistoryStack();
|
|
141
|
+
const segmentElementCache = new SegmentElementCache();
|
|
312
142
|
|
|
313
143
|
let pending = false;
|
|
314
144
|
let pendingUrl: string | null = null;
|
|
@@ -343,6 +173,28 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
343
173
|
}
|
|
344
174
|
}
|
|
345
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Merge a partial RSC payload with cached segment elements if segments
|
|
178
|
+
* were skipped, then cache segments from the (merged) payload.
|
|
179
|
+
* Returns the merged payload ready for rendering.
|
|
180
|
+
*/
|
|
181
|
+
function mergeAndCachePayload(
|
|
182
|
+
payload: unknown,
|
|
183
|
+
skippedSegments: string[] | null | undefined
|
|
184
|
+
): unknown {
|
|
185
|
+
let merged = payload;
|
|
186
|
+
|
|
187
|
+
// If segments were skipped, merge the partial payload with cached segments
|
|
188
|
+
if (skippedSegments && skippedSegments.length > 0) {
|
|
189
|
+
merged = mergeSegmentTree(payload, skippedSegments, segmentElementCache);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Cache segment elements from the (merged) payload for future merges
|
|
193
|
+
cacheSegmentElements(merged, segmentElementCache);
|
|
194
|
+
|
|
195
|
+
return merged;
|
|
196
|
+
}
|
|
197
|
+
|
|
346
198
|
/**
|
|
347
199
|
* Update navigation state (params + pathname) for the next render.
|
|
348
200
|
*
|
|
@@ -370,21 +222,38 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
370
222
|
* useOptimistic for the pending URL. In tests, the payload is rendered directly.
|
|
371
223
|
*/
|
|
372
224
|
async function renderViaTransition(
|
|
373
|
-
|
|
225
|
+
url: string,
|
|
374
226
|
perform: () => Promise<FetchResult>
|
|
375
227
|
): Promise<HeadElement[] | null> {
|
|
376
228
|
if (deps.navigateTransition) {
|
|
377
229
|
let headElements: HeadElement[] | null = null;
|
|
378
|
-
await deps.navigateTransition(
|
|
230
|
+
await deps.navigateTransition(url, async (wrapPayload) => {
|
|
379
231
|
const result = await perform();
|
|
380
232
|
headElements = result.headElements;
|
|
381
|
-
|
|
233
|
+
// Merge partial payload with cached segments before wrapping
|
|
234
|
+
const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
|
|
235
|
+
// Store the MERGED payload in history — not the partial pre-merge tree.
|
|
236
|
+
// This ensures handlePopState replays the complete tree on back/forward.
|
|
237
|
+
historyStack.push(url, {
|
|
238
|
+
payload: merged,
|
|
239
|
+
headElements: result.headElements,
|
|
240
|
+
params: result.params,
|
|
241
|
+
});
|
|
242
|
+
return wrapPayload(merged);
|
|
382
243
|
});
|
|
383
244
|
return headElements;
|
|
384
245
|
}
|
|
385
246
|
// Fallback: no transition (tests, no React tree)
|
|
386
247
|
const result = await perform();
|
|
387
|
-
|
|
248
|
+
// Merge partial payload with cached segments before rendering
|
|
249
|
+
const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
|
|
250
|
+
// Store merged payload in history
|
|
251
|
+
historyStack.push(url, {
|
|
252
|
+
payload: merged,
|
|
253
|
+
headElements: result.headElements,
|
|
254
|
+
params: result.params,
|
|
255
|
+
});
|
|
256
|
+
renderPayload(merged);
|
|
388
257
|
return result.headElements;
|
|
389
258
|
}
|
|
390
259
|
|
|
@@ -421,13 +290,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
421
290
|
headElements: prefetched.headElements,
|
|
422
291
|
segmentInfo: prefetched.segmentInfo ?? null,
|
|
423
292
|
params: prefetched.params ?? null,
|
|
293
|
+
skippedSegments: prefetched.skippedSegments ?? null,
|
|
424
294
|
}
|
|
425
295
|
: undefined;
|
|
426
296
|
|
|
427
297
|
if (result === undefined) {
|
|
428
298
|
// Fetch RSC payload with state tree for partial rendering.
|
|
429
299
|
// Send current URL for intercepting route resolution (modal pattern).
|
|
430
|
-
const stateTree = segmentCache.serializeStateTree();
|
|
300
|
+
const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
|
|
431
301
|
const rawCurrentUrl = deps.getCurrentUrl();
|
|
432
302
|
const currentUrl = rawCurrentUrl.startsWith('http')
|
|
433
303
|
? new URL(rawCurrentUrl).pathname
|
|
@@ -442,12 +312,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
442
312
|
deps.pushState({ timber: true, scrollY: 0 }, '', url);
|
|
443
313
|
}
|
|
444
314
|
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
params: result.params,
|
|
450
|
-
});
|
|
315
|
+
// NOTE: History push is deferred — the merged payload (after segment
|
|
316
|
+
// merging in renderViaTransition) is stored by the caller, not here.
|
|
317
|
+
// Storing result.payload here would record the partial (pre-merge)
|
|
318
|
+
// RSC tree, causing handlePopState to replay an incomplete tree.
|
|
451
319
|
|
|
452
320
|
// Update the segment cache with the new route's segment tree.
|
|
453
321
|
updateSegmentCache(result.segmentInfo);
|
|
@@ -518,11 +386,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
518
386
|
const headElements = await renderViaTransition(currentUrl, async () => {
|
|
519
387
|
// No state tree sent — server renders the complete RSC payload
|
|
520
388
|
const result = await fetchRscPayload(currentUrl, deps);
|
|
521
|
-
|
|
522
|
-
payload: result.payload,
|
|
523
|
-
headElements: result.headElements,
|
|
524
|
-
params: result.params,
|
|
525
|
-
});
|
|
389
|
+
// History push handled by renderViaTransition (stores merged payload)
|
|
526
390
|
updateSegmentCache(result.segmentInfo);
|
|
527
391
|
updateNavigationState(result.params, currentUrl);
|
|
528
392
|
return result;
|
|
@@ -557,15 +421,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
557
421
|
setPending(true, url);
|
|
558
422
|
try {
|
|
559
423
|
const headElements = await renderViaTransition(url, async () => {
|
|
560
|
-
const stateTree = segmentCache.serializeStateTree();
|
|
424
|
+
const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
|
|
561
425
|
const result = await fetchRscPayload(url, deps, stateTree);
|
|
562
426
|
updateSegmentCache(result.segmentInfo);
|
|
563
427
|
updateNavigationState(result.params, url);
|
|
564
|
-
|
|
565
|
-
payload: result.payload,
|
|
566
|
-
headElements: result.headElements,
|
|
567
|
-
params: result.params,
|
|
568
|
-
});
|
|
428
|
+
// History push handled by renderViaTransition (stores merged payload)
|
|
569
429
|
return result;
|
|
570
430
|
});
|
|
571
431
|
|
|
@@ -590,7 +450,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
590
450
|
if (historyStack.has(url)) return;
|
|
591
451
|
|
|
592
452
|
// Fire-and-forget fetch
|
|
593
|
-
const stateTree = segmentCache.serializeStateTree();
|
|
453
|
+
const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
|
|
594
454
|
void fetchRscPayload(url, deps, stateTree).then(
|
|
595
455
|
(result) => {
|
|
596
456
|
prefetchCache.set(url, result);
|
|
@@ -616,15 +476,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
616
476
|
// Render the piggybacked element tree from a server action response.
|
|
617
477
|
// Updates the current history entry with the fresh payload and applies
|
|
618
478
|
// head elements — same as refresh() but without a server fetch.
|
|
479
|
+
// Cache segment elements for future partial merges.
|
|
619
480
|
const currentUrl = deps.getCurrentUrl();
|
|
481
|
+
const merged = mergeAndCachePayload(element, null);
|
|
620
482
|
historyStack.push(currentUrl, {
|
|
621
|
-
payload:
|
|
483
|
+
payload: merged,
|
|
622
484
|
headElements,
|
|
623
485
|
});
|
|
624
|
-
renderPayload(
|
|
486
|
+
renderPayload(merged);
|
|
625
487
|
applyHead(headElements);
|
|
626
488
|
},
|
|
627
489
|
initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
|
|
490
|
+
cacheElementTree: (element: unknown) => cacheSegmentElements(element, segmentElementCache),
|
|
628
491
|
segmentCache,
|
|
629
492
|
prefetchCache,
|
|
630
493
|
historyStack,
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSC Fetch — handles fetching and parsing RSC Flight payloads.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from router.ts to keep both files under the 500-line limit.
|
|
5
|
+
* This module handles:
|
|
6
|
+
* - Cache-busting URL generation for RSC requests
|
|
7
|
+
* - Building RSC request headers (Accept, X-Timber-State-Tree)
|
|
8
|
+
* - Extracting metadata from RSC response headers
|
|
9
|
+
* - Fetching and decoding RSC payloads
|
|
10
|
+
*
|
|
11
|
+
* See design/19-client-navigation.md §"RSC Payload Handling"
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { SegmentInfo } from './segment-cache';
|
|
15
|
+
import type { HeadElement } from './head';
|
|
16
|
+
import type { RouterDeps } from './router';
|
|
17
|
+
|
|
18
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Result of fetching an RSC payload — includes head elements and segment metadata. */
|
|
21
|
+
export interface FetchResult {
|
|
22
|
+
payload: unknown;
|
|
23
|
+
headElements: HeadElement[] | null;
|
|
24
|
+
/** Segment metadata from X-Timber-Segments header for populating the segment cache. */
|
|
25
|
+
segmentInfo: SegmentInfo[] | null;
|
|
26
|
+
/** Route params from X-Timber-Params header for populating useParams(). */
|
|
27
|
+
params: Record<string, string | string[]> | null;
|
|
28
|
+
/** Segment paths that were skipped by the server (for client-side merging). */
|
|
29
|
+
skippedSegments: string[] | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Constants ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export const RSC_CONTENT_TYPE = 'text/x-component';
|
|
35
|
+
|
|
36
|
+
// ─── URL Helpers ─────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generate a short random cache-busting ID (5 chars, a-z0-9).
|
|
40
|
+
* Matches the format Next.js uses for _rsc params.
|
|
41
|
+
*/
|
|
42
|
+
function generateCacheBustId(): string {
|
|
43
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
44
|
+
let id = '';
|
|
45
|
+
for (let i = 0; i < 5; i++) {
|
|
46
|
+
id += chars[(Math.random() * 36) | 0];
|
|
47
|
+
}
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Append a `_rsc=<id>` query parameter to the URL.
|
|
53
|
+
* Follows Next.js's pattern — prevents CDN/browser from serving cached HTML
|
|
54
|
+
* for RSC navigation requests and signals that this is an RSC fetch.
|
|
55
|
+
*/
|
|
56
|
+
function appendRscParam(url: string): string {
|
|
57
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
58
|
+
return `${url}${separator}_rsc=${generateCacheBustId()}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildRscHeaders(
|
|
62
|
+
stateTree: { segments: string[] } | undefined,
|
|
63
|
+
currentUrl?: string
|
|
64
|
+
): Record<string, string> {
|
|
65
|
+
const headers: Record<string, string> = {
|
|
66
|
+
Accept: RSC_CONTENT_TYPE,
|
|
67
|
+
};
|
|
68
|
+
if (stateTree) {
|
|
69
|
+
headers['X-Timber-State-Tree'] = JSON.stringify(stateTree);
|
|
70
|
+
}
|
|
71
|
+
// Send current URL for intercepting route resolution.
|
|
72
|
+
// The server uses this to determine if an intercepting route should
|
|
73
|
+
// render instead of the actual target route (modal pattern).
|
|
74
|
+
// See design/07-routing.md §"Intercepting Routes"
|
|
75
|
+
if (currentUrl) {
|
|
76
|
+
headers['X-Timber-URL'] = currentUrl;
|
|
77
|
+
}
|
|
78
|
+
return headers;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Response Header Extraction ──────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract head elements from the X-Timber-Head response header.
|
|
85
|
+
* Returns null if the header is missing or malformed.
|
|
86
|
+
*/
|
|
87
|
+
export function extractHeadElements(response: Response): HeadElement[] | null {
|
|
88
|
+
const header = response.headers.get('X-Timber-Head');
|
|
89
|
+
if (!header) return null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(decodeURIComponent(header));
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract segment metadata from the X-Timber-Segments response header.
|
|
99
|
+
* Returns null if the header is missing or malformed.
|
|
100
|
+
*
|
|
101
|
+
* Format: JSON array of {path, isAsync} objects describing the rendered
|
|
102
|
+
* segment chain from root to leaf. Used to populate the client-side
|
|
103
|
+
* segment cache for state tree diffing on subsequent navigations.
|
|
104
|
+
*/
|
|
105
|
+
export function extractSegmentInfo(response: Response): SegmentInfo[] | null {
|
|
106
|
+
const header = response.headers.get('X-Timber-Segments');
|
|
107
|
+
if (!header) return null;
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(header);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract skipped segment paths from the X-Timber-Skipped-Segments header.
|
|
117
|
+
* Returns null if the header is missing or malformed.
|
|
118
|
+
*
|
|
119
|
+
* When the server skips sync layouts the client already has cached,
|
|
120
|
+
* it sends this header listing the skipped segment paths (outermost first).
|
|
121
|
+
* The client uses this to merge the partial payload with cached segments.
|
|
122
|
+
*/
|
|
123
|
+
export function extractSkippedSegments(response: Response): string[] | null {
|
|
124
|
+
const header = response.headers.get('X-Timber-Skipped-Segments');
|
|
125
|
+
if (!header) return null;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(header);
|
|
128
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Extract route params from the X-Timber-Params response header.
|
|
136
|
+
* Returns null if the header is missing or malformed.
|
|
137
|
+
*
|
|
138
|
+
* Used to populate useParams() after client-side navigation.
|
|
139
|
+
*/
|
|
140
|
+
export function extractParams(response: Response): Record<string, string | string[]> | null {
|
|
141
|
+
const header = response.headers.get('X-Timber-Params');
|
|
142
|
+
if (!header) return null;
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(header);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Redirect Error ──────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Thrown when an RSC payload response contains X-Timber-Redirect header.
|
|
154
|
+
* Caught in navigate() to trigger a soft router navigation to the redirect target.
|
|
155
|
+
*/
|
|
156
|
+
export class RedirectError extends Error {
|
|
157
|
+
readonly redirectUrl: string;
|
|
158
|
+
constructor(url: string) {
|
|
159
|
+
super(`Server redirect to ${url}`);
|
|
160
|
+
this.redirectUrl = url;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Fetch ───────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fetch an RSC payload from the server. If a decodeRsc function is provided,
|
|
168
|
+
* the response is decoded into a React element tree via createFromFetch.
|
|
169
|
+
* Otherwise, the raw response text is returned (test mode).
|
|
170
|
+
*
|
|
171
|
+
* Also extracts head elements from the X-Timber-Head response header
|
|
172
|
+
* so the client can update document.title and <meta> tags after navigation.
|
|
173
|
+
*/
|
|
174
|
+
export async function fetchRscPayload(
|
|
175
|
+
url: string,
|
|
176
|
+
deps: RouterDeps,
|
|
177
|
+
stateTree?: { segments: string[] },
|
|
178
|
+
currentUrl?: string
|
|
179
|
+
): Promise<FetchResult> {
|
|
180
|
+
const rscUrl = appendRscParam(url);
|
|
181
|
+
const headers = buildRscHeaders(stateTree, currentUrl);
|
|
182
|
+
if (deps.decodeRsc) {
|
|
183
|
+
// Production path: use createFromFetch for streaming RSC decoding.
|
|
184
|
+
// createFromFetch takes a Promise<Response> and progressively parses
|
|
185
|
+
// the RSC Flight stream as chunks arrive.
|
|
186
|
+
//
|
|
187
|
+
// Intercept the response to read X-Timber-Head before createFromFetch
|
|
188
|
+
// consumes the body. Reading headers does NOT consume the body stream.
|
|
189
|
+
const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
190
|
+
let headElements: HeadElement[] | null = null;
|
|
191
|
+
let segmentInfo: SegmentInfo[] | null = null;
|
|
192
|
+
let params: Record<string, string | string[]> | null = null;
|
|
193
|
+
let skippedSegments: string[] | null = null;
|
|
194
|
+
const wrappedPromise = fetchPromise.then((response) => {
|
|
195
|
+
// Detect server-side redirects. The server returns 204 + X-Timber-Redirect
|
|
196
|
+
// for RSC payload requests instead of a raw 302, because fetch with
|
|
197
|
+
// redirect: "manual" turns 302s into opaque redirects (status 0, null body)
|
|
198
|
+
// which crashes createFromFetch when it tries to read the body stream.
|
|
199
|
+
const redirectLocation =
|
|
200
|
+
response.headers.get('X-Timber-Redirect') ||
|
|
201
|
+
(response.status >= 300 && response.status < 400 ? response.headers.get('Location') : null);
|
|
202
|
+
if (redirectLocation) {
|
|
203
|
+
throw new RedirectError(redirectLocation);
|
|
204
|
+
}
|
|
205
|
+
headElements = extractHeadElements(response);
|
|
206
|
+
segmentInfo = extractSegmentInfo(response);
|
|
207
|
+
params = extractParams(response);
|
|
208
|
+
skippedSegments = extractSkippedSegments(response);
|
|
209
|
+
return response;
|
|
210
|
+
});
|
|
211
|
+
// Await so headElements/segmentInfo/params are populated before we return.
|
|
212
|
+
// Also await the decoded payload — createFromFetch returns a thenable
|
|
213
|
+
// that resolves to the React element tree.
|
|
214
|
+
await wrappedPromise;
|
|
215
|
+
const payload = await deps.decodeRsc(wrappedPromise);
|
|
216
|
+
return { payload, headElements, segmentInfo, params, skippedSegments };
|
|
217
|
+
}
|
|
218
|
+
// Test/fallback path: return raw text
|
|
219
|
+
const response = await deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
220
|
+
// Check for redirect in test path too
|
|
221
|
+
if (response.status >= 300 && response.status < 400) {
|
|
222
|
+
const location = response.headers.get('Location');
|
|
223
|
+
if (location) {
|
|
224
|
+
throw new RedirectError(location);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
payload: await response.text(),
|
|
229
|
+
headElements: extractHeadElements(response),
|
|
230
|
+
segmentInfo: extractSegmentInfo(response),
|
|
231
|
+
params: extractParams(response),
|
|
232
|
+
skippedSegments: extractSkippedSegments(response),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -13,6 +13,8 @@ export interface PrefetchResult {
|
|
|
13
13
|
segmentInfo?: SegmentInfo[] | null;
|
|
14
14
|
/** Route params from X-Timber-Params header for populating useParams(). */
|
|
15
15
|
params?: Record<string, string | string[]> | null;
|
|
16
|
+
/** Segment paths skipped by the server (for client-side merging). */
|
|
17
|
+
skippedSegments?: string[] | null;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -70,25 +72,34 @@ export class SegmentCache {
|
|
|
70
72
|
* Only includes sync segments — async segments are excluded because the
|
|
71
73
|
* server must always re-render them (they may depend on request context).
|
|
72
74
|
*
|
|
75
|
+
* When mergeableFilter is provided, only segments whose paths are in the
|
|
76
|
+
* set are included. This ensures the server only skips segments that the
|
|
77
|
+
* client can actually merge (i.e., segments whose cached element tree
|
|
78
|
+
* contains an inner SegmentProvider the merger can splice into).
|
|
79
|
+
*
|
|
73
80
|
* This is a performance optimization only, NOT a security boundary.
|
|
74
81
|
* The server always runs all access.ts files regardless of the state tree.
|
|
75
82
|
*/
|
|
76
|
-
serializeStateTree(): StateTree {
|
|
83
|
+
serializeStateTree(mergeableFilter?: Set<string>): StateTree {
|
|
77
84
|
const segments: string[] = [];
|
|
78
85
|
if (this.root) {
|
|
79
|
-
collectSyncSegments(this.root, segments);
|
|
86
|
+
collectSyncSegments(this.root, segments, mergeableFilter);
|
|
80
87
|
}
|
|
81
88
|
return { segments };
|
|
82
89
|
}
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
/** Recursively collect sync segment paths from the tree */
|
|
86
|
-
function collectSyncSegments(
|
|
87
|
-
|
|
93
|
+
function collectSyncSegments(
|
|
94
|
+
node: SegmentNode,
|
|
95
|
+
out: string[],
|
|
96
|
+
mergeableFilter?: Set<string>
|
|
97
|
+
): void {
|
|
98
|
+
if (!node.isAsync && (!mergeableFilter || mergeableFilter.has(node.segment))) {
|
|
88
99
|
out.push(node.segment);
|
|
89
100
|
}
|
|
90
101
|
for (const child of node.children.values()) {
|
|
91
|
-
collectSyncSegments(child, out);
|
|
102
|
+
collectSyncSegments(child, out, mergeableFilter);
|
|
92
103
|
}
|
|
93
104
|
}
|
|
94
105
|
|