@timber-js/app 0.2.0-alpha.121 → 0.2.0-alpha.123
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/LICENSE +8 -0
- package/dist/_chunks/{actions-Dy8vOjFt.js → actions-ZlcKx0H9.js} +5 -5
- package/dist/_chunks/actions-ZlcKx0H9.js.map +1 -0
- package/dist/_chunks/{build-output-helper-B1n0sUqj.js → build-output-helper-DYOWsPjZ.js} +47 -6
- package/dist/_chunks/build-output-helper-DYOWsPjZ.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-4HcQIziG.js +2 -0
- package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js → cli-schema-sync-VcCf1TFY.js} +17 -3
- package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js.map → cli-schema-sync-VcCf1TFY.js.map} +1 -1
- package/dist/_chunks/{define-CPVQaWbx.js → define-C0V_eKiW.js} +6 -18
- package/dist/_chunks/define-C0V_eKiW.js.map +1 -0
- package/dist/_chunks/{handler-store-CcwNX-gk.js → handler-store-bIAstrxQ.js} +1 -1
- package/dist/_chunks/{handler-store-CcwNX-gk.js.map → handler-store-bIAstrxQ.js.map} +1 -1
- package/dist/_chunks/{merge-search-params-az6ZiBeU.js → merge-search-params-Cm1j99HC.js} +1 -1
- package/dist/_chunks/{merge-search-params-az6ZiBeU.js.map → merge-search-params-Cm1j99HC.js.map} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-DESG8uk5.js} +32 -19
- package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -0
- package/dist/_chunks/{navigation-root-DoRTeBm5.js → navigation-root-Ds_hDtRW.js} +2 -2
- package/dist/_chunks/{navigation-root-DoRTeBm5.js.map → navigation-root-Ds_hDtRW.js.map} +1 -1
- package/dist/_chunks/{resolve-schema-Bf8eC6q6.js → resolve-schema-DU2nbVOi.js} +5 -1
- package/dist/_chunks/resolve-schema-DU2nbVOi.js.map +1 -0
- package/dist/_chunks/{router-ref-CpvjBqdu.js → router-ref-B2-2kIIC.js} +2 -2
- package/dist/_chunks/{router-ref-CpvjBqdu.js.map → router-ref-B2-2kIIC.js.map} +1 -1
- package/dist/_chunks/{segment-classify-CnfLLvir.js → segment-classify-BjfuctV2.js} +1 -1
- package/dist/_chunks/{segment-classify-CnfLLvir.js.map → segment-classify-BjfuctV2.js.map} +1 -1
- package/dist/_chunks/{segment-context-EsfQpS--.js → segment-context-Bg0pFbLT.js} +1 -1
- package/dist/_chunks/{segment-context-EsfQpS--.js.map → segment-context-Bg0pFbLT.js.map} +1 -1
- package/dist/_chunks/{ssr-data-m9-cNEIw.js → ssr-data-DVX8S7vj.js} +1 -1
- package/dist/_chunks/{ssr-data-m9-cNEIw.js.map → ssr-data-DVX8S7vj.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-CtIAjqK0.js → use-segment-params-Du7u25cK.js} +3 -8
- package/dist/_chunks/{use-segment-params-CtIAjqK0.js.map → use-segment-params-Du7u25cK.js.map} +1 -1
- package/dist/_chunks/{validation-BMU5MPzP.js → validation-AfEFM5ze.js} +1 -1
- package/dist/_chunks/{validation-BMU5MPzP.js.map → validation-AfEFM5ze.js.map} +1 -1
- package/dist/_chunks/{walkers-DNp9tHyV.js → walkers-tkw7sDlj.js} +12 -9
- package/dist/_chunks/walkers-tkw7sDlj.js.map +1 -0
- package/dist/adapters/build-output-helper.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/error-boundary.js +2 -2
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +48 -15
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +67 -32
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +9 -5
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +8 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/shallow-url.d.ts +10 -0
- package/dist/client/shallow-url.d.ts.map +1 -0
- package/dist/cookies/index.js +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +102 -10
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/params/resolve-schema.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/cloudflare-wasm.d.ts +18 -0
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/routing/codegen-shared.d.ts +5 -4
- package/dist/routing/codegen-shared.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/segment-params/define.d.ts +0 -6
- package/dist/segment-params/define.d.ts.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/actions.d.ts +3 -8
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +6 -0
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.d.ts +1 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +15 -6
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-routes.d.ts +23 -7
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +4 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/tree-match.d.ts +12 -4
- package/dist/server/tree-match.d.ts.map +1 -1
- package/dist/server/types.d.ts +2 -2
- package/package.json +6 -7
- package/src/adapters/build-output-helper.ts +67 -5
- package/src/cli-schema-sync.ts +25 -4
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/action-dispatch.ts +9 -10
- package/src/client/browser-entry/index.ts +1 -1
- package/src/client/browser-entry/post-hydration.ts +12 -6
- package/src/client/index.ts +1 -5
- package/src/client/link.tsx +34 -21
- package/src/client/router.ts +74 -25
- package/src/client/rsc-fetch.ts +20 -5
- package/src/client/shallow-url.ts +22 -0
- package/src/index.ts +2 -0
- package/src/params/resolve-schema.ts +15 -0
- package/src/plugins/build-manifest.ts +57 -5
- package/src/plugins/cloudflare-wasm.ts +103 -0
- package/src/plugins/dev-server.ts +4 -2
- package/src/plugins/routing.ts +1 -1
- package/src/routing/codegen-shared.ts +8 -5
- package/src/routing/codegen.ts +5 -0
- package/src/routing/link-codegen.ts +1 -1
- package/src/segment-params/define.ts +2 -29
- package/src/server/actions.ts +4 -14
- package/src/server/build-manifest.ts +7 -0
- package/src/server/internal.ts +7 -1
- package/src/server/metadata-routes.ts +44 -19
- package/src/server/pipeline-phases.ts +8 -0
- package/src/server/request-context.ts +0 -4
- package/src/server/route-element-builder.ts +57 -11
- package/src/server/route-matcher.ts +81 -49
- package/src/server/rsc-entry/index.ts +7 -1
- package/src/server/rsc-entry/render-route.ts +7 -1
- package/src/server/ssr-entry.ts +6 -0
- package/src/server/tree-match.ts +23 -11
- package/src/server/types.ts +2 -2
- package/dist/_chunks/actions-Dy8vOjFt.js.map +0 -1
- package/dist/_chunks/build-output-helper-B1n0sUqj.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-DHQLjFu3.js +0 -2
- package/dist/_chunks/define-CPVQaWbx.js.map +0 -1
- package/dist/_chunks/metadata-routes-Cjmvi3rQ.js.map +0 -1
- package/dist/_chunks/resolve-schema-Bf8eC6q6.js.map +0 -1
- package/dist/_chunks/walkers-DNp9tHyV.js.map +0 -1
package/src/client/router.ts
CHANGED
|
@@ -215,6 +215,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
215
215
|
const segmentElementCache = new SegmentElementCache();
|
|
216
216
|
|
|
217
217
|
let routerPhase: RouterPhase = { phase: 'idle' };
|
|
218
|
+
let navigationId = 0;
|
|
218
219
|
const pendingListeners = new Set<(pending: boolean) => void>();
|
|
219
220
|
|
|
220
221
|
// AbortController for the current in-flight navigation.
|
|
@@ -287,24 +288,46 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
287
288
|
|
|
288
289
|
/**
|
|
289
290
|
* Merge a partial RSC payload with cached segment elements if segments
|
|
290
|
-
* were skipped
|
|
291
|
-
*
|
|
291
|
+
* were skipped. Returns the merged payload ready for rendering.
|
|
292
|
+
*
|
|
293
|
+
* Segment element caching is deferred to after paint by the caller
|
|
294
|
+
* so it doesn't block the render path.
|
|
292
295
|
*/
|
|
293
|
-
function
|
|
296
|
+
async function mergePayload(
|
|
294
297
|
payload: unknown,
|
|
295
298
|
skippedSegments: string[] | null | undefined
|
|
296
|
-
): unknown {
|
|
297
|
-
let merged = payload;
|
|
298
|
-
|
|
299
|
-
// If segments were skipped, merge the partial payload with cached segments
|
|
299
|
+
): Promise<unknown> {
|
|
300
300
|
if (skippedSegments && skippedSegments.length > 0) {
|
|
301
|
-
|
|
301
|
+
// Segment merging needs a resolved React element tree for cloneElement.
|
|
302
|
+
// Await the payload only when merging is actually needed.
|
|
303
|
+
const resolved =
|
|
304
|
+
payload != null && typeof payload === 'object' && 'then' in payload
|
|
305
|
+
? await (payload as PromiseLike<unknown>)
|
|
306
|
+
: payload;
|
|
307
|
+
return mergeSegmentTree(resolved, skippedSegments, segmentElementCache);
|
|
302
308
|
}
|
|
309
|
+
// No merging needed — return the payload as-is (may be an unresolved
|
|
310
|
+
// Flight thenable). React handles progressive rendering via Suspense.
|
|
311
|
+
return payload;
|
|
312
|
+
}
|
|
303
313
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Schedule segment element caching after paint. Resolves thenable
|
|
316
|
+
* payloads first since tree walking requires resolved React elements.
|
|
317
|
+
* Guards against stale writes: if a newer navigation started before
|
|
318
|
+
* this callback fires, the cache write is skipped.
|
|
319
|
+
*/
|
|
320
|
+
function deferSegmentCaching(payload: unknown): void {
|
|
321
|
+
const capturedNavId = navigationId;
|
|
322
|
+
afterPaint(async () => {
|
|
323
|
+
if (capturedNavId !== navigationId) return;
|
|
324
|
+
const resolved =
|
|
325
|
+
payload != null && typeof payload === 'object' && 'then' in payload
|
|
326
|
+
? await (payload as PromiseLike<unknown>)
|
|
327
|
+
: payload;
|
|
328
|
+
if (capturedNavId !== navigationId) return;
|
|
329
|
+
cacheSegmentElements(resolved, segmentElementCache);
|
|
330
|
+
});
|
|
308
331
|
}
|
|
309
332
|
|
|
310
333
|
/**
|
|
@@ -335,17 +358,27 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
335
358
|
* FetchResult plus the NavigationState (so it can be passed explicitly
|
|
336
359
|
* to wrapPayload/renderRoot without temporal coupling).
|
|
337
360
|
*/
|
|
361
|
+
interface TransitionResult {
|
|
362
|
+
headElements: HeadElement[] | null;
|
|
363
|
+
decodePromise: Promise<void> | null;
|
|
364
|
+
}
|
|
365
|
+
|
|
338
366
|
async function renderViaTransition(
|
|
339
367
|
url: string,
|
|
340
368
|
perform: () => Promise<FetchResult & { navState: NavigationState }>
|
|
341
|
-
): Promise<
|
|
369
|
+
): Promise<TransitionResult> {
|
|
342
370
|
if (deps.navigateTransition) {
|
|
343
371
|
let headElements: HeadElement[] | null = null;
|
|
372
|
+
let decodePromise: Promise<void> | null = null;
|
|
344
373
|
await deps.navigateTransition(url, async (wrapPayload) => {
|
|
345
374
|
const result = await perform();
|
|
346
375
|
headElements = result.headElements;
|
|
347
|
-
|
|
348
|
-
|
|
376
|
+
decodePromise = result.decodePromise;
|
|
377
|
+
// Merge partial payload with cached segments before wrapping.
|
|
378
|
+
// When no segments were skipped, mergePayload returns the payload
|
|
379
|
+
// as-is (possibly still an unresolved Flight thenable — React
|
|
380
|
+
// handles progressive rendering via Suspense).
|
|
381
|
+
const merged = await mergePayload(result.payload, result.skippedSegments);
|
|
349
382
|
// Store the MERGED payload in history — not the partial pre-merge tree.
|
|
350
383
|
// This ensures handlePopState replays the complete tree on back/forward.
|
|
351
384
|
historyStack.push(url, {
|
|
@@ -353,24 +386,28 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
353
386
|
headElements: result.headElements,
|
|
354
387
|
params: result.params,
|
|
355
388
|
});
|
|
389
|
+
// Defer segment element caching to after paint — it only benefits
|
|
390
|
+
// future navigations and doesn't need to block the current render.
|
|
391
|
+
deferSegmentCaching(merged);
|
|
356
392
|
// Pass navState explicitly — wrapPayload wraps element in
|
|
357
393
|
// NavigationProvider with this state, no getNavigationState() needed.
|
|
358
394
|
return wrapPayload(merged, result.navState);
|
|
359
395
|
});
|
|
360
|
-
return headElements;
|
|
396
|
+
return { headElements, decodePromise };
|
|
361
397
|
}
|
|
362
398
|
// Fallback: no transition (tests, no React tree)
|
|
363
399
|
const result = await perform();
|
|
364
400
|
// Merge partial payload with cached segments before rendering
|
|
365
|
-
const merged =
|
|
401
|
+
const merged = await mergePayload(result.payload, result.skippedSegments);
|
|
366
402
|
// Store merged payload in history
|
|
367
403
|
historyStack.push(url, {
|
|
368
404
|
payload: merged,
|
|
369
405
|
headElements: result.headElements,
|
|
370
406
|
params: result.params,
|
|
371
407
|
});
|
|
408
|
+
deferSegmentCaching(merged);
|
|
372
409
|
renderPayload(merged, result.navState);
|
|
373
|
-
return result.headElements;
|
|
410
|
+
return { headElements: result.headElements, decodePromise: result.decodePromise };
|
|
374
411
|
}
|
|
375
412
|
|
|
376
413
|
/** Apply head elements (title, meta tags) to the DOM if available. */
|
|
@@ -414,6 +451,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
414
451
|
let result: FetchResult | undefined = prefetched
|
|
415
452
|
? {
|
|
416
453
|
payload: prefetched.payload,
|
|
454
|
+
decodePromise: null,
|
|
417
455
|
headElements: prefetched.headElements,
|
|
418
456
|
segmentInfo: prefetched.segmentInfo ?? null,
|
|
419
457
|
params: prefetched.params ?? null,
|
|
@@ -466,6 +504,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
466
504
|
const externalSignal = options._signal as AbortSignal | undefined;
|
|
467
505
|
const skipHistory = options._skipHistory === true;
|
|
468
506
|
|
|
507
|
+
navigationId++;
|
|
508
|
+
|
|
469
509
|
// Create an abort controller for this navigation. Links to the external
|
|
470
510
|
// signal (Navigation API's event.signal) when provided.
|
|
471
511
|
const navAbort = createNavAbort(externalSignal);
|
|
@@ -499,7 +539,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
499
539
|
setPending(true, url);
|
|
500
540
|
|
|
501
541
|
try {
|
|
502
|
-
const headElements = await renderViaTransition(url, () =>
|
|
542
|
+
const { headElements, decodePromise } = await renderViaTransition(url, () =>
|
|
503
543
|
performNavigationFetch(url, {
|
|
504
544
|
replace,
|
|
505
545
|
signal: navAbort.signal,
|
|
@@ -507,6 +547,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
507
547
|
})
|
|
508
548
|
);
|
|
509
549
|
|
|
550
|
+
// Await async RSC decode errors (truncated streams, Flight parse failures)
|
|
551
|
+
// so they surface through this try/catch rather than becoming unhandled.
|
|
552
|
+
if (decodePromise) await decodePromise;
|
|
553
|
+
|
|
510
554
|
// Update document.title and <meta> tags with the new page's metadata
|
|
511
555
|
applyHead(headElements);
|
|
512
556
|
|
|
@@ -575,12 +619,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
575
619
|
|
|
576
620
|
async function refresh(): Promise<void> {
|
|
577
621
|
const currentUrl = deps.getCurrentUrl();
|
|
622
|
+
navigationId++;
|
|
578
623
|
const navAbort = createNavAbort();
|
|
579
624
|
|
|
580
625
|
setPending(true, currentUrl);
|
|
581
626
|
|
|
582
627
|
try {
|
|
583
|
-
const headElements = await renderViaTransition(currentUrl, async () => {
|
|
628
|
+
const { headElements, decodePromise } = await renderViaTransition(currentUrl, async () => {
|
|
584
629
|
// No state tree sent — server renders the complete RSC payload
|
|
585
630
|
const result = await fetchRscPayload(
|
|
586
631
|
currentUrl,
|
|
@@ -595,6 +640,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
595
640
|
return { ...result, navState };
|
|
596
641
|
});
|
|
597
642
|
|
|
643
|
+
if (decodePromise) await decodePromise;
|
|
598
644
|
applyHead(headElements);
|
|
599
645
|
} catch (error) {
|
|
600
646
|
// Stale transition (superseded by a newer navigation) or aborted
|
|
@@ -615,6 +661,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
615
661
|
scrollY: number = 0,
|
|
616
662
|
externalSignal?: AbortSignal
|
|
617
663
|
): Promise<void> {
|
|
664
|
+
navigationId++;
|
|
618
665
|
// Scroll position is read from history.state by the caller (browser-entry.ts)
|
|
619
666
|
// and passed in. This is more reliable than tracking scroll per-URL in memory
|
|
620
667
|
// because the browser maintains per-entry state even with duplicate URLs.
|
|
@@ -634,7 +681,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
634
681
|
const navAbort = createNavAbort(externalSignal);
|
|
635
682
|
setPending(true, url);
|
|
636
683
|
try {
|
|
637
|
-
const headElements = await renderViaTransition(url, async () => {
|
|
684
|
+
const { headElements, decodePromise } = await renderViaTransition(url, async () => {
|
|
638
685
|
const stateTree = segmentCache.serializeStateTree(
|
|
639
686
|
segmentElementCache.getMergeablePaths()
|
|
640
687
|
);
|
|
@@ -645,6 +692,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
645
692
|
return { ...result, navState };
|
|
646
693
|
});
|
|
647
694
|
|
|
695
|
+
if (decodePromise) await decodePromise;
|
|
648
696
|
applyHead(headElements);
|
|
649
697
|
restoreScrollAfterPaint(scrollY);
|
|
650
698
|
} catch (error) {
|
|
@@ -697,18 +745,19 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
697
745
|
// Render the piggybacked element tree from a server action response.
|
|
698
746
|
// Updates the current history entry with the fresh payload and applies
|
|
699
747
|
// head elements — same as refresh() but without a server fetch.
|
|
700
|
-
//
|
|
748
|
+
// Revalidation payloads are already resolved (no Flight thenable),
|
|
749
|
+
// so no segment merging is needed — just cache and render.
|
|
701
750
|
const currentUrl = deps.getCurrentUrl();
|
|
702
|
-
const merged = mergeAndCachePayload(element, null);
|
|
703
751
|
historyStack.push(currentUrl, {
|
|
704
|
-
payload:
|
|
752
|
+
payload: element,
|
|
705
753
|
headElements,
|
|
706
754
|
});
|
|
755
|
+
deferSegmentCaching(element);
|
|
707
756
|
// Revalidation doesn't change params/pathname — preserve current state.
|
|
708
757
|
// DO NOT call updateNavigationState(null, ...) here: that normalizes
|
|
709
758
|
// params to {}, clearing dynamic route params on every action response.
|
|
710
759
|
const navState = getNavigationState();
|
|
711
|
-
renderPayload(
|
|
760
|
+
renderPayload(element, navState);
|
|
712
761
|
applyHead(headElements);
|
|
713
762
|
},
|
|
714
763
|
initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
|
package/src/client/rsc-fetch.ts
CHANGED
|
@@ -20,6 +20,14 @@ import type { RouterDeps } from './router';
|
|
|
20
20
|
/** Result of fetching an RSC payload — includes head elements and segment metadata. */
|
|
21
21
|
export interface FetchResult {
|
|
22
22
|
payload: unknown;
|
|
23
|
+
/**
|
|
24
|
+
* Promise that settles when the RSC decode completes or fails.
|
|
25
|
+
* The payload thenable is NOT awaited before returning (for streaming),
|
|
26
|
+
* so callers must monitor this to catch async decode errors
|
|
27
|
+
* (truncated streams, Flight parse failures) that would otherwise
|
|
28
|
+
* become unhandled rejections.
|
|
29
|
+
*/
|
|
30
|
+
decodePromise: Promise<void> | null;
|
|
23
31
|
headElements: HeadElement[] | null;
|
|
24
32
|
/** Segment metadata from X-Timber-Segments header for populating the segment cache. */
|
|
25
33
|
segmentInfo: SegmentInfo[] | null;
|
|
@@ -313,11 +321,17 @@ export async function fetchRscPayload(
|
|
|
313
321
|
});
|
|
314
322
|
// Await headers so headElements/segmentInfo/params are populated.
|
|
315
323
|
await wrappedPromise;
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
324
|
+
// Start decoding but do NOT await — return the in-progress thenable.
|
|
325
|
+
// React can render a Flight thenable directly: it suspends on unresolved
|
|
326
|
+
// parts and progressively renders as chunks arrive, spreading work across
|
|
327
|
+
// frames instead of blocking the main thread in one burst.
|
|
328
|
+
const payload = deps.decodeRsc(wrappedPromise);
|
|
329
|
+
// Track decode completion so callers can catch async errors (truncated
|
|
330
|
+
// streams, Flight parse failures) that fire after fetchRscPayload returns.
|
|
331
|
+
const decodePromise = Promise.resolve(payload).then(() => undefined);
|
|
332
|
+
// Prevent unhandled rejection — the router monitors decodePromise separately.
|
|
333
|
+
decodePromise.catch(() => {});
|
|
334
|
+
return { payload, decodePromise, headElements, segmentInfo, params, skippedSegments };
|
|
321
335
|
}
|
|
322
336
|
// Test/fallback path: return raw text
|
|
323
337
|
const response = await deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
|
|
@@ -330,6 +344,7 @@ export async function fetchRscPayload(
|
|
|
330
344
|
}
|
|
331
345
|
return {
|
|
332
346
|
payload: await response.text(),
|
|
347
|
+
decodePromise: null,
|
|
333
348
|
headElements: extractHeadElements(response),
|
|
334
349
|
segmentInfo: extractSegmentInfo(response),
|
|
335
350
|
params: extractParams(response),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shallow URL replacement — update the browser URL bar without triggering
|
|
3
|
+
* RSC navigation, TopLoader, or any server round-trip.
|
|
4
|
+
*
|
|
5
|
+
* Uses the Navigation API's `info: { shallow: true }` when available (Chrome),
|
|
6
|
+
* which the navigate event handler intercepts with a no-op handler. Falls back
|
|
7
|
+
* to raw `history.replaceState` (Safari/Firefox — no navigate event fired).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getNavigationApi } from './navigation-api.js';
|
|
11
|
+
|
|
12
|
+
export function replaceUrl(url: string): void {
|
|
13
|
+
const nav = getNavigationApi();
|
|
14
|
+
if (nav) {
|
|
15
|
+
nav.navigate(url, {
|
|
16
|
+
history: 'replace',
|
|
17
|
+
info: { shallow: true },
|
|
18
|
+
});
|
|
19
|
+
} else {
|
|
20
|
+
history.replaceState(history.state, '', url);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { timberReactProd } from './plugins/react-prod';
|
|
|
29
29
|
import { timberChunks } from './plugins/chunks';
|
|
30
30
|
import { clientChunkGroup } from './plugins/client-chunks';
|
|
31
31
|
import { timberServerBundle } from './plugins/server-bundle';
|
|
32
|
+
import { timberCloudflareWasm } from './plugins/cloudflare-wasm';
|
|
32
33
|
import { timberAdapterBuild } from './plugins/adapter-build';
|
|
33
34
|
import { timberBuildReport } from './plugins/build-report';
|
|
34
35
|
import { createNoopTimer } from './utils/startup-timer';
|
|
@@ -531,6 +532,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
531
532
|
timberMdx(ctx),
|
|
532
533
|
timberContent(ctx),
|
|
533
534
|
timberServerBundle(), // Bundle all deps in server environments for prod
|
|
535
|
+
timberCloudflareWasm(ctx), // Externalize WASM for Cloudflare Workers
|
|
534
536
|
timberChunks(),
|
|
535
537
|
timberBuildReport(ctx), // Post-build: route table with bundle sizes
|
|
536
538
|
timberAdapterBuild(ctx), // Post-build: invoke adapter.buildOutput()
|
|
@@ -70,6 +70,10 @@ function isCatchAllKey(key: string): boolean {
|
|
|
70
70
|
return CATCH_ALL_RE.test(key) || OPTIONAL_CATCH_ALL_RE.test(key);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function isOptionalCatchAllKey(key: string): boolean {
|
|
74
|
+
return OPTIONAL_CATCH_ALL_RE.test(key);
|
|
75
|
+
}
|
|
76
|
+
|
|
73
77
|
/**
|
|
74
78
|
* Resolve the global schema's segmentParams into a map of bracket-keyed
|
|
75
79
|
* param name → resolved Codec.
|
|
@@ -94,6 +98,17 @@ export function resolveSchemaCodecs(
|
|
|
94
98
|
`(e.g. codec.catchAll(codec.string)). Got a scalar codec.`
|
|
95
99
|
);
|
|
96
100
|
}
|
|
101
|
+
if (
|
|
102
|
+
isOptionalCatchAllKey(bracketName) &&
|
|
103
|
+
isCodec(value) &&
|
|
104
|
+
'__catchAll' in value &&
|
|
105
|
+
!('__optional' in value)
|
|
106
|
+
) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[timber] Schema error: '${bracketName}' is an optional catch-all segment and requires codec.optionalCatchAll() ` +
|
|
109
|
+
`(e.g. codec.optionalCatchAll(codec.string)). Got a non-optional catch-all codec.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
97
112
|
resolved[bracketName] = resolveCodec(bracketName, value);
|
|
98
113
|
}
|
|
99
114
|
|
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
* Design docs: 18-build-system.md §"Build Manifest", 02-rendering-pipeline.md §"Early Hints"
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
20
21
|
import type { Plugin, ResolvedConfig } from 'vite';
|
|
21
22
|
import type { PluginContext } from '../plugin-context.js';
|
|
22
23
|
import type { BuildManifest } from '../server/build-manifest.js';
|
|
24
|
+
import type { SegmentNode, RouteFile } from '../routing/types.js';
|
|
25
|
+
import { isDynamicMetadataExtension } from '../server/metadata-routes.js';
|
|
23
26
|
|
|
24
27
|
// Rollup types used by generateBundle hook — imported from vite which re-exports them.
|
|
25
28
|
// We define minimal interfaces here to avoid a direct 'rollup' dependency.
|
|
@@ -84,7 +87,7 @@ export function parseViteManifest(
|
|
|
84
87
|
modulepreload[inputPath] = collectTransitiveDeps(inputPath, viteManifest, base);
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
return { css, js, modulepreload, fonts: {} };
|
|
90
|
+
return { css, js, modulepreload, fonts: {}, metadataRouteHashes: {} };
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/**
|
|
@@ -197,7 +200,7 @@ export function buildManifestFromBundle(
|
|
|
197
200
|
css['_global'] = allCss;
|
|
198
201
|
}
|
|
199
202
|
|
|
200
|
-
return { css, js, modulepreload, fonts: {} };
|
|
203
|
+
return { css, js, modulepreload, fonts: {}, metadataRouteHashes: {} };
|
|
201
204
|
}
|
|
202
205
|
|
|
203
206
|
/**
|
|
@@ -228,6 +231,50 @@ function collectTransitiveBundleDeps(
|
|
|
228
231
|
return result;
|
|
229
232
|
}
|
|
230
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Walk the route tree and compute short content hashes for metadata route files
|
|
236
|
+
* (opengraph-image, icon, apple-icon) used for cache-busting
|
|
237
|
+
* the auto-linked URLs in <head>.
|
|
238
|
+
*/
|
|
239
|
+
function computeMetadataRouteHashes(node: SegmentNode<RouteFile>): Record<string, string> {
|
|
240
|
+
const hashes: Record<string, string> = {};
|
|
241
|
+
|
|
242
|
+
if (node.metadataRoutes) {
|
|
243
|
+
for (const [baseName, file] of Object.entries(node.metadataRoutes)) {
|
|
244
|
+
if (!isDynamicMetadataExtension(baseName, file.extension)) {
|
|
245
|
+
// For static files, hash the file content
|
|
246
|
+
try {
|
|
247
|
+
const content = readFileSync(file.filePath);
|
|
248
|
+
const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
|
|
249
|
+
hashes[file.filePath] = hash;
|
|
250
|
+
} catch {
|
|
251
|
+
// File unreadable — skip hash, DEV_NONCE fallback applies
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
// For dynamic files (.ts/.tsx), hash the source file as a reasonable
|
|
255
|
+
// approximation — the output depends on runtime data too, but the
|
|
256
|
+
// source change is the best build-time signal we have.
|
|
257
|
+
try {
|
|
258
|
+
const content = readFileSync(file.filePath);
|
|
259
|
+
const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
|
|
260
|
+
hashes[file.filePath] = hash;
|
|
261
|
+
} catch {
|
|
262
|
+
// File unreadable — skip
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const child of node.children) {
|
|
269
|
+
Object.assign(hashes, computeMetadataRouteHashes(child));
|
|
270
|
+
}
|
|
271
|
+
for (const slot of Object.values(node.slots)) {
|
|
272
|
+
Object.assign(hashes, computeMetadataRouteHashes(slot));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return hashes;
|
|
276
|
+
}
|
|
277
|
+
|
|
231
278
|
/**
|
|
232
279
|
* Create the timber-build-manifest Vite plugin.
|
|
233
280
|
*
|
|
@@ -278,7 +325,7 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
|
|
|
278
325
|
'// Auto-generated build manifest — do not edit.',
|
|
279
326
|
'// Dev mode: empty manifest (Vite HMR handles CSS/JS).',
|
|
280
327
|
'',
|
|
281
|
-
'const manifest = { css: {}, js: {}, modulepreload: {}, fonts: {} };',
|
|
328
|
+
'const manifest = { css: {}, js: {}, modulepreload: {}, fonts: {}, metadataRouteHashes: {} };',
|
|
282
329
|
'',
|
|
283
330
|
'export default manifest;',
|
|
284
331
|
].join('\n');
|
|
@@ -292,7 +339,7 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
|
|
|
292
339
|
'// Auto-generated build manifest — do not edit.',
|
|
293
340
|
'// Production: reads manifest from globalThis (set by _timber-manifest-init.js).',
|
|
294
341
|
'',
|
|
295
|
-
'const manifest = globalThis.__TIMBER_BUILD_MANIFEST__ ?? { css: {}, js: {}, modulepreload: {}, fonts: {} };',
|
|
342
|
+
'const manifest = globalThis.__TIMBER_BUILD_MANIFEST__ ?? { css: {}, js: {}, modulepreload: {}, fonts: {}, metadataRouteHashes: {} };',
|
|
296
343
|
'',
|
|
297
344
|
'export default manifest;',
|
|
298
345
|
].join('\n');
|
|
@@ -309,6 +356,11 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
|
|
|
309
356
|
if (envName === 'client') {
|
|
310
357
|
ctx.buildManifest = buildManifestFromBundle(bundle, resolvedBase, ctx.root);
|
|
311
358
|
|
|
359
|
+
// Compute cache-busting hashes for metadata route files
|
|
360
|
+
if (ctx.routeTree) {
|
|
361
|
+
ctx.buildManifest.metadataRouteHashes = computeMetadataRouteHashes(ctx.routeTree.root);
|
|
362
|
+
}
|
|
363
|
+
|
|
312
364
|
// When client JavaScript is disabled, strip JS chunks from the bundle
|
|
313
365
|
// so Rollup never writes them to disk. CSS assets are preserved —
|
|
314
366
|
// they're still needed for server-rendered HTML.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-cloudflare-wasm — Handle WASM modules for Cloudflare Workers builds.
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare Workers can natively import `.wasm` files as compiled
|
|
5
|
+
* WebAssembly.Module. But Rolldown can't load `.wasm` files at build time.
|
|
6
|
+
*
|
|
7
|
+
* This plugin bridges the gap:
|
|
8
|
+
* 1. Adds the `workerd` resolve condition so libraries like @takumi-rs/wasm
|
|
9
|
+
* resolve to their Cloudflare-native entry (bare `.wasm` import)
|
|
10
|
+
* 2. Externalizes `.wasm` imports so Rolldown skips them
|
|
11
|
+
* 3. Copies the WASM files to the build output so wrangler can find them
|
|
12
|
+
*
|
|
13
|
+
* Only active for Cloudflare adapter production builds.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { basename, resolve, dirname } from 'node:path';
|
|
17
|
+
import { copyFile, mkdir } from 'node:fs/promises';
|
|
18
|
+
import type { Plugin, ResolvedConfig } from 'vite';
|
|
19
|
+
import type { PluginContext } from '../plugin-context.js';
|
|
20
|
+
|
|
21
|
+
function isCloudflareAdapter(ctx: PluginContext): boolean {
|
|
22
|
+
const adapter = ctx.config.adapter as { name?: string } | undefined;
|
|
23
|
+
return adapter?.name === 'cloudflare';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
|
|
27
|
+
// Track resolved WASM file paths so we can copy them to the output
|
|
28
|
+
const wasmFiles = new Map<string, string>();
|
|
29
|
+
|
|
30
|
+
const plugin: Plugin = {
|
|
31
|
+
name: 'timber-cloudflare-wasm',
|
|
32
|
+
|
|
33
|
+
configResolved(resolved: ResolvedConfig) {
|
|
34
|
+
if (resolved.command !== 'build') return;
|
|
35
|
+
if (!isCloudflareAdapter(ctx)) return;
|
|
36
|
+
|
|
37
|
+
// Prepend `workerd` to resolve conditions for server environments
|
|
38
|
+
// so @takumi-rs/wasm/auto resolves to the Cloudflare-native entry
|
|
39
|
+
// (bare WASM import) instead of the Vite entry (?url + fetch).
|
|
40
|
+
// Must happen in configResolved because the RSC plugin sets its own
|
|
41
|
+
// conditions during config — merging via config() would lose them.
|
|
42
|
+
for (const envName of ['rsc', 'ssr']) {
|
|
43
|
+
const env = resolved.environments[envName];
|
|
44
|
+
if (!env) continue;
|
|
45
|
+
const conditions = env.resolve.conditions;
|
|
46
|
+
if (!conditions.includes('workerd')) {
|
|
47
|
+
conditions.unshift('workerd');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
resolveId: {
|
|
53
|
+
order: 'pre',
|
|
54
|
+
async handler(source, importer) {
|
|
55
|
+
if (!source.endsWith('.wasm')) return;
|
|
56
|
+
|
|
57
|
+
let absolutePath: string;
|
|
58
|
+
if (source.startsWith('.') && importer) {
|
|
59
|
+
absolutePath = resolve(dirname(importer), source);
|
|
60
|
+
} else {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const filename = basename(absolutePath);
|
|
65
|
+
wasmFiles.set(filename, absolutePath);
|
|
66
|
+
|
|
67
|
+
return { id: './' + filename, external: true };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async writeBundle(options, bundle) {
|
|
72
|
+
if (wasmFiles.size === 0) return;
|
|
73
|
+
|
|
74
|
+
const outDir = options.dir;
|
|
75
|
+
if (!outDir) return;
|
|
76
|
+
|
|
77
|
+
// Find which output chunks reference WASM files and copy the WASM
|
|
78
|
+
// to the same directory so the relative imports resolve correctly.
|
|
79
|
+
const targetDirs = new Set<string>();
|
|
80
|
+
for (const chunk of Object.values(bundle)) {
|
|
81
|
+
if (chunk.type !== 'chunk') continue;
|
|
82
|
+
for (const filename of wasmFiles.keys()) {
|
|
83
|
+
if (chunk.code.includes(filename)) {
|
|
84
|
+
targetDirs.add(dirname(resolve(outDir, chunk.fileName)));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (targetDirs.size === 0) return;
|
|
90
|
+
|
|
91
|
+
const copies: Promise<void>[] = [];
|
|
92
|
+
for (const dir of targetDirs) {
|
|
93
|
+
await mkdir(dir, { recursive: true });
|
|
94
|
+
for (const [filename, absolutePath] of wasmFiles) {
|
|
95
|
+
copies.push(copyFile(absolutePath, resolve(dir, filename)));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await Promise.all(copies);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return [plugin];
|
|
103
|
+
}
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from '../dev-tools/error-page.js';
|
|
32
32
|
import { addVirtualModuleContext } from '../config-validation.js';
|
|
33
33
|
import { compressResponse } from '../server/compress.js';
|
|
34
|
+
import { isMetadataRouteServePath } from '../server/metadata-routes.js';
|
|
34
35
|
|
|
35
36
|
// ─── Constants ────────────────────────────────────────────────────────────
|
|
36
37
|
|
|
@@ -165,8 +166,9 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
|
|
|
165
166
|
return;
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
// Pass through static asset requests
|
|
169
|
-
|
|
169
|
+
// Pass through static asset requests — but not metadata routes like
|
|
170
|
+
// /favicon.ico that match asset extensions but are handled by timber.
|
|
171
|
+
if (isAssetRequest(url) && !isMetadataRouteServePath(url)) {
|
|
170
172
|
next();
|
|
171
173
|
return;
|
|
172
174
|
}
|
package/src/plugins/routing.ts
CHANGED
|
@@ -68,7 +68,7 @@ const SEGMENT_PREFIX = '\0$segment:';
|
|
|
68
68
|
* File convention names we track for changes that require manifest regeneration.
|
|
69
69
|
*/
|
|
70
70
|
const ROUTE_FILE_PATTERNS =
|
|
71
|
-
/\/(page|layout|middleware|access|route|error|global-error|default|denied|params|schema|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|
|
|
71
|
+
/\/(page|layout|middleware|access|route|error|global-error|default|denied|params|schema|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|apple-icon)\./;
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
74
|
* Create the timber-routing Vite plugin.
|
|
@@ -82,13 +82,16 @@ export function buildCodecChainType(
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Build a TypeScript type expression for a single param using the global
|
|
85
|
-
* schema's InferCodecType. Falls back to
|
|
86
|
-
* declare this param.
|
|
85
|
+
* schema's InferCodecType. Falls back to the param's base type when
|
|
86
|
+
* schema doesn't declare this param (schemas are opt-in per key).
|
|
87
87
|
*
|
|
88
|
-
* Generates
|
|
88
|
+
* Generates a conditional that checks key existence:
|
|
89
|
+
* `[bracketName] extends keyof typeof schema.segmentParams ? InferCodecType<...> : fallback`
|
|
89
90
|
*/
|
|
90
|
-
export function buildSchemaParamType(p: ParamEntry): string {
|
|
91
|
-
|
|
91
|
+
export function buildSchemaParamType(p: ParamEntry, fallback?: string): string {
|
|
92
|
+
const key = JSON.stringify(p.bracketName);
|
|
93
|
+
const fb = fallback ?? p.type;
|
|
94
|
+
return `${key} extends keyof typeof schema['segmentParams'] ? InferCodecType<typeof schema.segmentParams[${key}]> : ${fb}`;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/**
|
package/src/routing/codegen.ts
CHANGED
|
@@ -350,6 +350,11 @@ function formatDeclarationFile(
|
|
|
350
350
|
" [K in keyof typeof schema['segmentParams']]: typeof schema['segmentParams'][K] extends SegmentParamCodec ? true : ['Error: value for', K, 'is not a valid Codec or Standard Schema'];"
|
|
351
351
|
);
|
|
352
352
|
lines.push('};');
|
|
353
|
+
// Generic constraint enforces that all values are `true` — if any value is
|
|
354
|
+
// an error tuple, TS reports a real diagnostic instead of silently computing it.
|
|
355
|
+
lines.push(
|
|
356
|
+
'type _EnforceSchemaValues<_T extends Record<string, true> = _AssertSchemaValues> = true;'
|
|
357
|
+
);
|
|
353
358
|
lines.push('');
|
|
354
359
|
}
|
|
355
360
|
|
|
@@ -83,7 +83,7 @@ export function formatLinkParamsType(
|
|
|
83
83
|
const fields = params.map((p) => {
|
|
84
84
|
const fallback = p.type === 'string' ? 'string | number' : p.type;
|
|
85
85
|
const codecType = hasSchema
|
|
86
|
-
? buildSchemaParamType(p)
|
|
86
|
+
? buildSchemaParamType(p, fallback)
|
|
87
87
|
: buildCodecChainType(p, importBase, fallback);
|
|
88
88
|
return `${p.name}: ${codecType}`;
|
|
89
89
|
});
|