@timber-js/app 0.2.0-alpha.160 → 0.2.0-alpha.161
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/_chunks/{actions-C9yAuoX3.js → actions-cjklt63G.js} +7 -4
- package/dist/_chunks/{actions-C9yAuoX3.js.map → actions-cjklt63G.js.map} +1 -1
- package/dist/_chunks/{cache-api-swbefQ6F.js → cache-api-CzYUlgXA.js} +2 -2
- package/dist/_chunks/{cache-api-swbefQ6F.js.map → cache-api-CzYUlgXA.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-BhYDz_-x.js → cli-schema-sync-NfLbLnDw.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-BhYDz_-x.js.map → cli-schema-sync-NfLbLnDw.js.map} +1 -1
- package/dist/_chunks/{logger-CPoQkGK6.js → logger-B_O6-mdJ.js} +7 -45
- package/dist/_chunks/logger-B_O6-mdJ.js.map +1 -0
- package/dist/_chunks/{walkers-c8aReVgo.js → walkers-9mz9T7mb.js} +2 -2
- package/dist/_chunks/{walkers-c8aReVgo.js.map → walkers-9mz9T7mb.js.map} +1 -1
- package/dist/adapters/cloudflare.d.ts +13 -13
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +44 -46
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +1 -8
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/adapters/types.d.ts +0 -6
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/child-segment-context.d.ts +22 -0
- package/dist/client/child-segment-context.d.ts.map +1 -0
- package/dist/client/child-segment-outlet.d.ts +18 -0
- package/dist/client/child-segment-outlet.d.ts.map +1 -0
- package/dist/client/child-segment-provider.d.ts +21 -0
- package/dist/client/child-segment-provider.d.ts.map +1 -0
- package/dist/client/use-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +5 -1
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.js +256 -9
- package/dist/index.js.map +1 -1
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/callsite-ast.d.ts +27 -5
- package/dist/plugins/callsite-ast.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/prebuilt-capture.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +1 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +85 -10
- package/dist/server/internal.js.map +1 -1
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/prebuilt-runtime.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +5 -12
- package/dist/server/primitives.d.ts.map +1 -1
- 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 +2 -2
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/adapters/cloudflare.ts +41 -63
- package/src/adapters/nitro.ts +0 -11
- package/src/adapters/types.ts +0 -7
- package/src/client/child-segment-context.ts +40 -0
- package/src/client/child-segment-outlet.tsx +25 -0
- package/src/client/child-segment-provider.tsx +27 -0
- package/src/client/use-cookie.ts +11 -1
- package/src/plugins/cache.ts +44 -1
- package/src/plugins/callsite-ast.ts +310 -5
- package/src/plugins/fonts.ts +8 -0
- package/src/plugins/prebuilt-capture.ts +7 -0
- package/src/plugins/shims.ts +24 -0
- package/src/server/actions.ts +10 -1
- package/src/server/als-registry.ts +1 -0
- package/src/server/index.ts +1 -1
- package/src/server/prebuilt-builder.ts +120 -42
- package/src/server/prebuilt-runtime.ts +24 -1
- package/src/server/primitives.ts +5 -20
- package/src/server/route-element-builder.ts +31 -6
- package/src/server/rsc-entry/index.ts +40 -40
- package/src/server/rsc-entry/render-route.ts +6 -2
- package/src/server/rsc-entry/rsc-payload.ts +2 -2
- package/src/server/server-only-guard-noop.js +3 -0
- package/dist/_chunks/logger-CPoQkGK6.js.map +0 -1
|
@@ -46,6 +46,7 @@ import { createElement } from 'react';
|
|
|
46
46
|
|
|
47
47
|
import { requestContextAls } from './als-registry.js';
|
|
48
48
|
import { ParamCoercionError } from './route-element-builder.js';
|
|
49
|
+
import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
|
|
49
50
|
import { coerceSegmentParams } from './param-coercion.js';
|
|
50
51
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
51
52
|
import {
|
|
@@ -154,6 +155,8 @@ interface CandidateRoute {
|
|
|
154
155
|
layoutsToLoad: ManifestFile[];
|
|
155
156
|
/** Component IDs attributed to this route's page + layout chain. */
|
|
156
157
|
componentIds: string[];
|
|
158
|
+
/** Component IDs from layout files (receive ChildSegmentOutlet as children). */
|
|
159
|
+
layoutComponentIds: Set<string>;
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
/**
|
|
@@ -176,15 +179,20 @@ function collectCandidateRoutes(
|
|
|
176
179
|
|
|
177
180
|
if (node.page) {
|
|
178
181
|
const componentIds = new Set<string>();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
const layoutIds = new Set<string>();
|
|
183
|
+
for (const f of chain)
|
|
184
|
+
for (const id of fileComponentMap[f.filePath] ?? []) {
|
|
185
|
+
componentIds.add(id);
|
|
186
|
+
layoutIds.add(id);
|
|
187
|
+
}
|
|
188
|
+
for (const id of fileComponentMap[node.page.filePath] ?? []) componentIds.add(id);
|
|
182
189
|
if (componentIds.size > 0) {
|
|
183
190
|
routes.push({
|
|
184
191
|
urlPath: node.urlPath,
|
|
185
192
|
page: node.page,
|
|
186
193
|
layoutsToLoad: chain.filter((l) => (fileComponentMap[l.filePath] ?? []).length > 0),
|
|
187
194
|
componentIds: Array.from(componentIds),
|
|
195
|
+
layoutComponentIds: layoutIds,
|
|
188
196
|
});
|
|
189
197
|
}
|
|
190
198
|
}
|
|
@@ -252,15 +260,14 @@ async function enumerateCombos(
|
|
|
252
260
|
|
|
253
261
|
// ─── Rendering ────────────────────────────────────────────────────────────
|
|
254
262
|
|
|
255
|
-
/** Concatenate drained chunks without Buffer (the bundle also runs on Workers). */
|
|
256
263
|
function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
|
257
264
|
let total = 0;
|
|
258
|
-
for (const
|
|
265
|
+
for (const c of chunks) total += c.byteLength;
|
|
259
266
|
const out = new Uint8Array(total);
|
|
260
|
-
let
|
|
261
|
-
for (const
|
|
262
|
-
out.set(
|
|
263
|
-
|
|
267
|
+
let off = 0;
|
|
268
|
+
for (const c of chunks) {
|
|
269
|
+
out.set(c, off);
|
|
270
|
+
off += c.byteLength;
|
|
264
271
|
}
|
|
265
272
|
return out;
|
|
266
273
|
}
|
|
@@ -299,10 +306,10 @@ async function drainWithTimeout(
|
|
|
299
306
|
|
|
300
307
|
/** Wrap payload bytes as a single-chunk stream for the smoke revival. */
|
|
301
308
|
function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
302
|
-
return new ReadableStream
|
|
303
|
-
start(
|
|
304
|
-
|
|
305
|
-
|
|
309
|
+
return new ReadableStream({
|
|
310
|
+
start(c) {
|
|
311
|
+
c.enqueue(bytes);
|
|
312
|
+
c.close();
|
|
306
313
|
},
|
|
307
314
|
});
|
|
308
315
|
}
|
|
@@ -332,7 +339,8 @@ async function renderEntry(
|
|
|
332
339
|
registration: PrebuiltRegistration,
|
|
333
340
|
props: Record<string, unknown>,
|
|
334
341
|
coercedParams: Record<string, string | string[]>,
|
|
335
|
-
timeoutMs: number
|
|
342
|
+
timeoutMs: number,
|
|
343
|
+
isLayout: boolean
|
|
336
344
|
): Promise<EntryOutcome> {
|
|
337
345
|
const storeHandle = createBuildTimeStore(registration.id, coercedParams);
|
|
338
346
|
const { store, violations } = storeHandle;
|
|
@@ -348,9 +356,15 @@ async function renderEntry(
|
|
|
348
356
|
// synthetic store (segmentParams.get() inside the component).
|
|
349
357
|
// Tag resolution also runs inside this scope so function-form tags
|
|
350
358
|
// that read segment params see the synthetic store (codex P2 on PR #858).
|
|
359
|
+
// Layout captures get <ChildSegmentOutlet /> as children so the
|
|
360
|
+
// cached payload has a context-driven hole (design/45, TIM-1181).
|
|
361
|
+
const renderProps =
|
|
362
|
+
isLayout && !('children' in props)
|
|
363
|
+
? { ...props, children: createElement(ChildSegmentOutlet as React.FC) }
|
|
364
|
+
: props;
|
|
351
365
|
bytes = await requestContextAls.run(store, async () => {
|
|
352
366
|
const stream = renderToReadableStream(
|
|
353
|
-
createElement(registration.component as RenderableComponent,
|
|
367
|
+
createElement(registration.component as RenderableComponent, renderProps),
|
|
354
368
|
{
|
|
355
369
|
onError(error: unknown) {
|
|
356
370
|
failure ??= error ?? new Error('unknown render error');
|
|
@@ -422,7 +436,7 @@ function warnNestingClamps(
|
|
|
422
436
|
let count = 0;
|
|
423
437
|
for (const { innerId, options } of nested) {
|
|
424
438
|
if (options.ttl === undefined && options.tags === undefined) continue;
|
|
425
|
-
const pairKey = `${outerId}
|
|
439
|
+
const pairKey = `${outerId} ${innerId}`;
|
|
426
440
|
if (warnedPairs.has(pairKey)) continue;
|
|
427
441
|
warnedPairs.add(pairKey);
|
|
428
442
|
count++;
|
|
@@ -448,6 +462,15 @@ function resolveCapturedTags(
|
|
|
448
462
|
return resolved.length > 0 ? resolved : undefined;
|
|
449
463
|
}
|
|
450
464
|
|
|
465
|
+
/** Buffered entry pending the param-independence decision (TIM-1175). */
|
|
466
|
+
interface PendingEntry {
|
|
467
|
+
callSiteProps: Record<string, unknown>;
|
|
468
|
+
cacheKey: string;
|
|
469
|
+
combo: ParamCombo;
|
|
470
|
+
bytes: Uint8Array;
|
|
471
|
+
tags: string[] | undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
451
474
|
/**
|
|
452
475
|
* Capture flight payloads for every attributed, registered, prerender-tier
|
|
453
476
|
* component across the route manifest. See module docblock for the flow.
|
|
@@ -465,9 +488,18 @@ export async function capturePrebuiltPayloads(
|
|
|
465
488
|
const warnedPairs = new Set<string>();
|
|
466
489
|
const reportedMissingRegistrations = new Set<string>();
|
|
467
490
|
const eagerLoadedChunks = new Set<string>();
|
|
468
|
-
// Components
|
|
469
|
-
|
|
470
|
-
|
|
491
|
+
// Components already emitted as param-independent — skip on second route.
|
|
492
|
+
const emittedParamIndependent = new Set<string>();
|
|
493
|
+
// (componentId, propsKey) pairs that rendered at least once without
|
|
494
|
+
// reading segment params. Segment param access is structural (the
|
|
495
|
+
// component either calls getSegmentParams() or it doesn't), so if the
|
|
496
|
+
// first combo doesn't trigger it, subsequent combos won't either —
|
|
497
|
+
// skip them to avoid redundant renders. Tracked per props set so a
|
|
498
|
+
// failed variant doesn't suppress retries for a different variant.
|
|
499
|
+
const provenParamIndependent = new Set<string>();
|
|
500
|
+
// Per-component: whether ANY combo read segmentParams, plus buffered
|
|
501
|
+
// entries awaiting the param-independence decision after all combos.
|
|
502
|
+
const pendingByComponent = new Map<string, { anyParamRead: boolean; entries: PendingEntry[] }>();
|
|
471
503
|
|
|
472
504
|
const skip = (issue: CaptureIssue) => {
|
|
473
505
|
summary.skipped.push(issue);
|
|
@@ -594,8 +626,8 @@ export async function capturePrebuiltPayloads(
|
|
|
594
626
|
}
|
|
595
627
|
// Runtime tier (ttl/tags without prerender) does no build work.
|
|
596
628
|
if (registration.options.prerender !== true) continue;
|
|
597
|
-
//
|
|
598
|
-
if (
|
|
629
|
+
// Already emitted as param-independent from a prior route.
|
|
630
|
+
if (emittedParamIndependent.has(componentId)) continue;
|
|
599
631
|
|
|
600
632
|
// One entry per distinct call-site props object (design/44 §Props
|
|
601
633
|
// Derivation, TIM-1142). When no call-site props were captured,
|
|
@@ -608,7 +640,23 @@ export async function capturePrebuiltPayloads(
|
|
|
608
640
|
const propsSets: Record<string, unknown>[] =
|
|
609
641
|
capturedProps && capturedProps.length > 0 ? [...capturedProps] : [{}];
|
|
610
642
|
|
|
643
|
+
let pending = pendingByComponent.get(componentId);
|
|
644
|
+
if (!pending) {
|
|
645
|
+
pending = { anyParamRead: false, entries: [] };
|
|
646
|
+
pendingByComponent.set(componentId, pending);
|
|
647
|
+
}
|
|
648
|
+
|
|
611
649
|
for (const callSiteProps of propsSets) {
|
|
650
|
+
// Already rendered this (component, props) pair without reading
|
|
651
|
+
// params — additional combos produce identical payloads. Tracked
|
|
652
|
+
// per props set so a failed variant doesn't suppress retries.
|
|
653
|
+
// Guard on !anyParamRead: if another prop variant already proved
|
|
654
|
+
// param-dependent, this variant needs per-combo entries too since
|
|
655
|
+
// the emit phase uses a component-level paramIndependent flag.
|
|
656
|
+
const propsKey = JSON.stringify(callSiteProps);
|
|
657
|
+
const proofKey = `${componentId}\0${propsKey}`;
|
|
658
|
+
if (provenParamIndependent.has(proofKey) && !pending.anyParamRead) continue;
|
|
659
|
+
|
|
612
660
|
const cacheKey = computeComponentCacheKey(callSiteProps, coercedParams);
|
|
613
661
|
if (cacheKey === null) {
|
|
614
662
|
skip({
|
|
@@ -621,11 +669,27 @@ export async function capturePrebuiltPayloads(
|
|
|
621
669
|
});
|
|
622
670
|
continue;
|
|
623
671
|
}
|
|
624
|
-
const entryKey = `${componentId}
|
|
672
|
+
const entryKey = `${componentId} ${cacheKey}`;
|
|
625
673
|
if (seenEntries.has(entryKey)) continue;
|
|
626
674
|
seenEntries.add(entryKey);
|
|
627
675
|
|
|
628
|
-
const
|
|
676
|
+
const isLayout = route.layoutComponentIds.has(componentId);
|
|
677
|
+
const outcome = await renderEntry(
|
|
678
|
+
registration,
|
|
679
|
+
callSiteProps,
|
|
680
|
+
coercedParams,
|
|
681
|
+
timeoutMs,
|
|
682
|
+
isLayout
|
|
683
|
+
);
|
|
684
|
+
// Track param reads immediately — before any failure continue —
|
|
685
|
+
// so a combo that reads params then fails still prevents
|
|
686
|
+
// param-independent classification (codex P1 on PR #869).
|
|
687
|
+
if (outcome.segmentParamsRead) {
|
|
688
|
+
pending.anyParamRead = true;
|
|
689
|
+
provenParamIndependent.delete(proofKey);
|
|
690
|
+
} else if (outcome.failure === null && !pending.anyParamRead) {
|
|
691
|
+
provenParamIndependent.add(proofKey);
|
|
692
|
+
}
|
|
629
693
|
summary.nestingWarnings += warnNestingClamps(
|
|
630
694
|
componentId,
|
|
631
695
|
outcome.nested,
|
|
@@ -665,32 +729,46 @@ export async function capturePrebuiltPayloads(
|
|
|
665
729
|
continue;
|
|
666
730
|
}
|
|
667
731
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if (isParamIndep) {
|
|
673
|
-
paramIndependentComponents.add(componentId);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
const finalCacheKey = isParamIndep
|
|
677
|
-
? (computeComponentCacheKey(callSiteProps, {}) ?? cacheKey)
|
|
678
|
-
: cacheKey;
|
|
679
|
-
const hasProps = Object.keys(callSiteProps).length > 0;
|
|
680
|
-
await options.onEntry({
|
|
681
|
-
componentId,
|
|
682
|
-
cacheKey: finalCacheKey,
|
|
683
|
-
params: isParamIndep ? {} : combo,
|
|
684
|
-
...(hasProps ? { props: callSiteProps } : {}),
|
|
732
|
+
pending.entries.push({
|
|
733
|
+
callSiteProps,
|
|
734
|
+
cacheKey,
|
|
735
|
+
combo,
|
|
685
736
|
bytes: outcome.bytes!,
|
|
686
737
|
tags: outcome.resolvedTags,
|
|
687
|
-
...(isParamIndep ? { paramIndependent: true } : {}),
|
|
688
738
|
});
|
|
689
|
-
summary.captured++;
|
|
690
739
|
}
|
|
691
740
|
}
|
|
692
741
|
}
|
|
693
742
|
}
|
|
694
743
|
|
|
744
|
+
// Emit buffered entries. Param-independent = ALL combos rendered and
|
|
745
|
+
// NONE read segmentParams → collapse to one entry per props set (TIM-1175).
|
|
746
|
+
for (const [componentId, pending] of pendingByComponent) {
|
|
747
|
+
const isParamIndep = !pending.anyParamRead && pending.entries.length > 0;
|
|
748
|
+
if (isParamIndep) emittedParamIndependent.add(componentId);
|
|
749
|
+
const emittedProps = isParamIndep ? new Set<string>() : null;
|
|
750
|
+
for (const entry of pending.entries) {
|
|
751
|
+
if (emittedProps) {
|
|
752
|
+
const propsKey = JSON.stringify(entry.callSiteProps);
|
|
753
|
+
if (emittedProps.has(propsKey)) continue;
|
|
754
|
+
emittedProps.add(propsKey);
|
|
755
|
+
}
|
|
756
|
+
const hasProps = Object.keys(entry.callSiteProps).length > 0;
|
|
757
|
+
const finalCacheKey = isParamIndep
|
|
758
|
+
? (computeComponentCacheKey(entry.callSiteProps, {}) ?? entry.cacheKey)
|
|
759
|
+
: entry.cacheKey;
|
|
760
|
+
await options.onEntry({
|
|
761
|
+
componentId,
|
|
762
|
+
cacheKey: finalCacheKey,
|
|
763
|
+
params: isParamIndep ? {} : entry.combo,
|
|
764
|
+
...(hasProps ? { props: entry.callSiteProps } : {}),
|
|
765
|
+
bytes: entry.bytes,
|
|
766
|
+
tags: entry.tags,
|
|
767
|
+
...(isParamIndep ? { paramIndependent: true } : {}),
|
|
768
|
+
});
|
|
769
|
+
summary.captured++;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
695
773
|
return summary;
|
|
696
774
|
}
|
|
@@ -37,6 +37,7 @@ import { createElement } from 'react';
|
|
|
37
37
|
|
|
38
38
|
import { requestContextAls, type RequestContextStore } from './als-registry.js';
|
|
39
39
|
import { computeComponentCacheKey, tryCanonicalize } from './prebuilt/cache-key.js';
|
|
40
|
+
import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
|
|
40
41
|
import {
|
|
41
42
|
isCaptureActive,
|
|
42
43
|
noteWrapperRender,
|
|
@@ -510,7 +511,29 @@ async function tryRevivePayload(
|
|
|
510
511
|
const keyParams = paramIndep ? {} : resolveKeyParams(store);
|
|
511
512
|
if (keyParams === null) return null;
|
|
512
513
|
|
|
513
|
-
|
|
514
|
+
// Strip `children` from the cache key ONLY when it is the framework-
|
|
515
|
+
// injected ChildSegmentOutlet. Layout components receive
|
|
516
|
+
// <ChildSegmentOutlet /> as children — a deterministic client reference
|
|
517
|
+
// that is the same on every request, so it should not vary the key.
|
|
518
|
+
// User-provided React element children (e.g., <Cached><A /></Cached>)
|
|
519
|
+
// are NOT stripped — they remain unkeyable so the component renders
|
|
520
|
+
// live, which is correct: the cache cannot distinguish <A> from <B>.
|
|
521
|
+
// See design/45-cache-lifetimes.md §Layout Propagation, TIM-1181.
|
|
522
|
+
const allProps = (props ?? {}) as Record<string, unknown>;
|
|
523
|
+
let keyProps: Record<string, unknown>;
|
|
524
|
+
const childrenValue = allProps.children;
|
|
525
|
+
const isChildSegmentOutlet =
|
|
526
|
+
childrenValue != null &&
|
|
527
|
+
typeof childrenValue === 'object' &&
|
|
528
|
+
'type' in childrenValue &&
|
|
529
|
+
(childrenValue as { type: unknown }).type === ChildSegmentOutlet;
|
|
530
|
+
if (isChildSegmentOutlet) {
|
|
531
|
+
const { children: _children, ...rest } = allProps;
|
|
532
|
+
keyProps = rest;
|
|
533
|
+
} else {
|
|
534
|
+
keyProps = allProps;
|
|
535
|
+
}
|
|
536
|
+
const cacheKey = computeComponentCacheKey(keyProps, keyParams);
|
|
514
537
|
if (cacheKey === null) return null;
|
|
515
538
|
|
|
516
539
|
const outcome =
|
package/src/server/primitives.ts
CHANGED
|
@@ -416,11 +416,6 @@ export class RenderError<
|
|
|
416
416
|
|
|
417
417
|
// ─── waitUntil ──────────────────────────────────────────────────────────────
|
|
418
418
|
|
|
419
|
-
/** Minimal interface for adapters that support background work. */
|
|
420
|
-
export interface WaitUntilAdapter {
|
|
421
|
-
waitUntil?(promise: Promise<unknown>): void;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
419
|
// Intentional per-app singleton — warn-once flag that persists for the
|
|
425
420
|
// lifetime of the process/isolate. Not per-request; do not migrate to ALS.
|
|
426
421
|
let _waitUntilWarned = false;
|
|
@@ -429,30 +424,20 @@ let _waitUntilWarned = false;
|
|
|
429
424
|
* Register a promise to be kept alive after the response is sent.
|
|
430
425
|
* Maps to `ctx.waitUntil()` on Cloudflare Workers and similar platforms.
|
|
431
426
|
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
* If neither is available, a warning is logged once and the promise is
|
|
437
|
-
* left to resolve (or reject) without being tracked.
|
|
427
|
+
* The platform adapter installs a per-request waitUntil function via ALS
|
|
428
|
+
* (see waituntil-bridge.ts). If no ALS handler is available, a warning
|
|
429
|
+
* is logged once and the promise is left to resolve (or reject) without
|
|
430
|
+
* being tracked.
|
|
438
431
|
*
|
|
439
432
|
* @param promise - The background work to keep alive.
|
|
440
|
-
* @param adapter - Optional legacy adapter (prefer ALS bridge in production).
|
|
441
433
|
*/
|
|
442
|
-
export function waitUntil(promise: Promise<unknown
|
|
443
|
-
// Check ALS bridge first (installed by generated entry points)
|
|
434
|
+
export function waitUntil(promise: Promise<unknown>): void {
|
|
444
435
|
const alsFn = _getWaitUntil();
|
|
445
436
|
if (alsFn) {
|
|
446
437
|
alsFn(promise);
|
|
447
438
|
return;
|
|
448
439
|
}
|
|
449
440
|
|
|
450
|
-
// Fall back to legacy adapter argument
|
|
451
|
-
if (adapter && typeof adapter.waitUntil === 'function') {
|
|
452
|
-
adapter.waitUntil(promise);
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
441
|
if (!_waitUntilWarned) {
|
|
457
442
|
_waitUntilWarned = true;
|
|
458
443
|
console.warn(
|
|
@@ -44,6 +44,8 @@ import type { DenyPageEntry } from './deny-boundary.js';
|
|
|
44
44
|
import { resolveSlotElement } from './slot-resolver.js';
|
|
45
45
|
import { SegmentProvider } from '../client/segment-context.js';
|
|
46
46
|
import { SegmentOutlet } from '../client/segment-outlet.js';
|
|
47
|
+
import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
|
|
48
|
+
import { ChildSegmentProvider } from '../client/child-segment-provider.js';
|
|
47
49
|
|
|
48
50
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
49
51
|
import type { InterceptionContext } from './pipeline.js';
|
|
@@ -126,7 +128,7 @@ export function isClientReference(component: unknown): boolean {
|
|
|
126
128
|
);
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
// ───
|
|
131
|
+
// ─── Typed Errors ────────────────────────────────────────────────────────
|
|
130
132
|
|
|
131
133
|
/**
|
|
132
134
|
* Thrown when a defineSegmentParams codec's parse() fails.
|
|
@@ -139,6 +141,17 @@ export class ParamCoercionError extends Error {
|
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Thrown when a matched route has no page component in its leaf segment.
|
|
146
|
+
* The pipeline catches this and renders the custom 404 page.
|
|
147
|
+
*/
|
|
148
|
+
export class NoPageComponentError extends Error {
|
|
149
|
+
constructor(message: string) {
|
|
150
|
+
super(message);
|
|
151
|
+
this.name = 'NoPageComponentError';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
142
155
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
143
156
|
|
|
144
157
|
/** Layout entry with component and segment. */
|
|
@@ -352,7 +365,7 @@ export async function buildRouteElement(
|
|
|
352
365
|
` [${i}] ${s.segmentName} (page: ${s.page ? 'yes' : 'no'}, layout: ${s.layout ? 'yes' : 'no'}, children: ${s.children?.length ?? 0})${i === segments.length - 1 ? ' ← leaf' : ''}`
|
|
353
366
|
)
|
|
354
367
|
.join('\n');
|
|
355
|
-
throw new
|
|
368
|
+
throw new NoPageComponentError(
|
|
356
369
|
`No page component found for route: ${new URL(req.url).pathname}\nMatched segments:\n${segmentInfo}`
|
|
357
370
|
);
|
|
358
371
|
}
|
|
@@ -650,13 +663,18 @@ export async function buildRouteElement(
|
|
|
650
663
|
: segment.urlPath;
|
|
651
664
|
|
|
652
665
|
// Build the layout element.
|
|
653
|
-
//
|
|
654
|
-
//
|
|
666
|
+
// Layouts receive <ChildSegmentOutlet /> as children — a stable client
|
|
667
|
+
// reference — instead of the varying inner content. The actual child
|
|
668
|
+
// content is provided via ChildSegmentProvider above the layout.
|
|
669
|
+
// This makes layout cache.component hits possible: the cached payload
|
|
670
|
+
// contains a deterministic children reference, not varying content.
|
|
671
|
+
// See design/45-cache-lifetimes.md §Layout Propagation.
|
|
672
|
+
const childOutlet = h(ChildSegmentOutlet, {});
|
|
655
673
|
let layoutElement: React.ReactElement;
|
|
656
674
|
if (isClientReference(layoutComponent)) {
|
|
657
675
|
layoutElement = h(layoutComponent, {
|
|
658
676
|
...slotProps,
|
|
659
|
-
children:
|
|
677
|
+
children: childOutlet,
|
|
660
678
|
});
|
|
661
679
|
} else {
|
|
662
680
|
// Server component layout — wrap with OTEL tracing AND DenySignal
|
|
@@ -686,10 +704,17 @@ export async function buildRouteElement(
|
|
|
686
704
|
};
|
|
687
705
|
layoutElement = h(TracedLayout, {
|
|
688
706
|
...slotProps,
|
|
689
|
-
children:
|
|
707
|
+
children: childOutlet,
|
|
690
708
|
});
|
|
691
709
|
}
|
|
692
710
|
|
|
711
|
+
// Wrap the layout in ChildSegmentProvider so ChildSegmentOutlet
|
|
712
|
+
// (inside the layout's children) can read the actual child content.
|
|
713
|
+
layoutElement = h(ChildSegmentProvider, {
|
|
714
|
+
childContent: element,
|
|
715
|
+
children: layoutElement,
|
|
716
|
+
});
|
|
717
|
+
|
|
693
718
|
const segmentProviderElement = h(SegmentProvider, {
|
|
694
719
|
segments: segmentPath,
|
|
695
720
|
segmentId,
|
|
@@ -108,12 +108,6 @@ export function setDevSourceMapHandler(handler: (error: Error) => void): void {
|
|
|
108
108
|
* 103 Early Hints → middleware.ts → render (RSC → SSR → HTML).
|
|
109
109
|
*/
|
|
110
110
|
async function createRequestHandler(manifest: typeof routeManifest, runtimeConfig: typeof config) {
|
|
111
|
-
// Load the user's instrumentation.ts — register() is awaited before the
|
|
112
|
-
// server accepts any requests. The logger and onRequestError hooks are
|
|
113
|
-
// wired into the framework. This runs once at startup.
|
|
114
|
-
// See design/17-logging.md §"register() — Server Startup"
|
|
115
|
-
await loadInstrumentation(loadUserInstrumentation);
|
|
116
|
-
|
|
117
111
|
// Initialize deployment ID for version skew detection (TIM-446).
|
|
118
112
|
// The manifest init module sets globalThis.__TIMBER_DEPLOYMENT_ID__ at startup.
|
|
119
113
|
// In dev mode this is undefined — skew checks are skipped.
|
|
@@ -125,16 +119,6 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
125
119
|
setDeploymentId(deploymentId);
|
|
126
120
|
}
|
|
127
121
|
|
|
128
|
-
// Wire the configured cache handler into the module-level singleton (TIM-599).
|
|
129
|
-
// The cacheHandler is a class instance (e.g. RedisCacheHandler) that can't be
|
|
130
|
-
// JSON-serialized into virtual:timber-config. Instead, virtual:timber-cache-handler
|
|
131
|
-
// dynamically imports the user's timber.config.ts and extracts the handler.
|
|
132
|
-
const userCacheHandler = await loadCacheHandler();
|
|
133
|
-
if (userCacheHandler) {
|
|
134
|
-
const { setCacheHandler } = await import('../../cache/handler-store.js');
|
|
135
|
-
setCacheHandler(userCacheHandler as Parameters<typeof setCacheHandler>[0]);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
122
|
// Wire the CDN purge handler so revalidateTag triggers CDN purge (TIM-1166).
|
|
139
123
|
const userCdnPurge = await loadCdnPurgeHandler();
|
|
140
124
|
if (userCdnPurge) {
|
|
@@ -148,17 +132,6 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
148
132
|
setCdnPurgeHandler(new WorkersCachePurge());
|
|
149
133
|
}
|
|
150
134
|
|
|
151
|
-
// Propagate renderTimeoutMs into the default singleflight so cache calls
|
|
152
|
-
// without a per-call timeoutMs still get deadlock protection (TIM-1032).
|
|
153
|
-
const renderTimeoutMs = (runtimeConfig as Record<string, unknown>).renderTimeoutMs as
|
|
154
|
-
| number
|
|
155
|
-
| undefined;
|
|
156
|
-
if (renderTimeoutMs) {
|
|
157
|
-
const { setDefaultSingleflightTimeout } = await import('../../cache/timber-cache.js');
|
|
158
|
-
setDefaultSingleflightTimeout(renderTimeoutMs);
|
|
159
|
-
setComponentRefreshTimeout(renderTimeoutMs);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
135
|
// Wire the prebuilt payload source so cache.component wrappers can look
|
|
163
136
|
// up cached flight payloads at request time (TIM-1122). The source is
|
|
164
137
|
// platform-aware: Node/Nitro reads from disk; Cloudflare reads from
|
|
@@ -216,13 +189,6 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
216
189
|
const matchRoute = createRouteMatcher(manifest);
|
|
217
190
|
const matchMetadataRoute = createMetadataRouteMatcher(manifest);
|
|
218
191
|
|
|
219
|
-
// TIM-931: Register global schema codecs for param coercion.
|
|
220
|
-
// When app/schema.ts exists, globalSchemaCodecs is a Record<string, Codec>;
|
|
221
|
-
// otherwise null. Registered once at startup so all coercion paths
|
|
222
|
-
// (pipeline-phases.ts, action-middleware-runner.ts, revalidation) share
|
|
223
|
-
// the same codec source.
|
|
224
|
-
setGlobalSchemaCodecs(globalSchemaCodecs);
|
|
225
|
-
|
|
226
192
|
// Build the client bootstrap configuration.
|
|
227
193
|
// When client JavaScript is disabled, no scripts are injected
|
|
228
194
|
// (unless enableHMRInDev is true in dev mode — then only HMR client).
|
|
@@ -559,15 +525,49 @@ export function __timber_capturePrebuilt(
|
|
|
559
525
|
return capturePrebuiltPayloads(routeManifest, options);
|
|
560
526
|
}
|
|
561
527
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
528
|
+
// Schema codecs are needed by both the capture path (param coercion) and
|
|
529
|
+
// the request handler, so register them unconditionally.
|
|
530
|
+
setGlobalSchemaCodecs(globalSchemaCodecs);
|
|
531
|
+
|
|
532
|
+
// Singleflight timeout protects against deadlocked cache calls — needed
|
|
533
|
+
// in both capture mode (components may call timber.cache) and normal mode.
|
|
534
|
+
const renderTimeoutMs = (config as Record<string, unknown>).renderTimeoutMs as number | undefined;
|
|
535
|
+
if (renderTimeoutMs) {
|
|
536
|
+
const { setDefaultSingleflightTimeout } = await import('../../cache/timber-cache.js');
|
|
537
|
+
setDefaultSingleflightTimeout(renderTimeoutMs);
|
|
538
|
+
setComponentRefreshTimeout(renderTimeoutMs);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Run instrumentation before the capture-mode guard — components may
|
|
542
|
+
// depend on globals or SDKs initialized in register().
|
|
543
|
+
await loadInstrumentation(loadUserInstrumentation);
|
|
544
|
+
|
|
545
|
+
// Wire the cache handler — prebuilt components may call timber.cache
|
|
546
|
+
// during capture and must see the configured store, not the fallback.
|
|
547
|
+
const userCacheHandler = await loadCacheHandler();
|
|
548
|
+
if (userCacheHandler) {
|
|
549
|
+
const { setCacheHandler } = await import('../../cache/handler-store.js');
|
|
550
|
+
setCacheHandler(userCacheHandler as Parameters<typeof setCacheHandler>[0]);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// In capture mode the plugin only calls __timber_capturePrebuilt — skip
|
|
554
|
+
// the remaining production startup (CDN purge, OTEL, route matchers)
|
|
555
|
+
// which is expensive and unnecessary.
|
|
556
|
+
const isCaptureMode = !!(globalThis as Record<string, unknown>).__TIMBER_CAPTURE_MODE__;
|
|
557
|
+
|
|
558
|
+
let _handler: ((req: Request) => Promise<Response>) | undefined;
|
|
559
|
+
let _pipelineConfig: PipelineConfig | undefined;
|
|
560
|
+
|
|
561
|
+
if (!isCaptureMode) {
|
|
562
|
+
const result = await createRequestHandler(routeManifest, config);
|
|
563
|
+
_handler = result.handler;
|
|
564
|
+
_pipelineConfig = result.pipelineConfig;
|
|
565
|
+
}
|
|
566
566
|
|
|
567
567
|
/**
|
|
568
568
|
* The pipeline configuration object. Exported so the dev server can set
|
|
569
569
|
* `devPipelineErrorHandler` after import without a module-level singleton.
|
|
570
570
|
*/
|
|
571
|
-
export const pipelineConfig = _pipelineConfig
|
|
571
|
+
export const pipelineConfig = _pipelineConfig!;
|
|
572
572
|
|
|
573
|
-
export default _handler
|
|
573
|
+
export default _handler!;
|
|
@@ -31,7 +31,11 @@ import type { LayoutEntry } from '../deny-renderer.js';
|
|
|
31
31
|
import { renderDenyPage, renderDenyPageAsRsc } from '../deny-renderer.js';
|
|
32
32
|
import type { ClientBootstrapConfig } from '../html-injectors.js';
|
|
33
33
|
import type { InterceptionContext, RouteMatch } from '../pipeline.js';
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
buildRouteElement,
|
|
36
|
+
NoPageComponentError,
|
|
37
|
+
ParamCoercionError,
|
|
38
|
+
} from '../route-element-builder.js';
|
|
35
39
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
36
40
|
import { recordTiming } from '../server-timing.js';
|
|
37
41
|
import { requestContextAls } from '../als-registry.js';
|
|
@@ -117,7 +121,7 @@ export async function renderRoute(
|
|
|
117
121
|
return renderNoMatchPage(req, rootSegment, responseHeaders, clientBootstrap);
|
|
118
122
|
}
|
|
119
123
|
// No PageComponent found — same treatment as param coercion: render custom 404.
|
|
120
|
-
if (error instanceof
|
|
124
|
+
if (error instanceof NoPageComponentError) {
|
|
121
125
|
if (process.env.NODE_ENV !== 'production') {
|
|
122
126
|
console.warn(`[timber] ${error.message}`);
|
|
123
127
|
}
|
|
@@ -101,11 +101,11 @@ export async function buildRscPayloadResponse(
|
|
|
101
101
|
// Check for redirect/deny signals detected during initial rendering
|
|
102
102
|
const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
|
|
103
103
|
if (trackedRedirect) {
|
|
104
|
-
reader.cancel();
|
|
104
|
+
reader.cancel().catch(() => {});
|
|
105
105
|
return buildRedirectResponse(trackedRedirect, req, responseHeaders);
|
|
106
106
|
}
|
|
107
107
|
if (signals.denySignal) {
|
|
108
|
-
reader.cancel();
|
|
108
|
+
reader.cancel().catch(() => {});
|
|
109
109
|
return renderDenyPageAsRsc(
|
|
110
110
|
signals.denySignal,
|
|
111
111
|
segments,
|