@timber-js/app 0.2.0-alpha.196 → 0.2.0-alpha.198
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-CWYtq6ii.js → actions-BS-m5SLv.js} +3 -3
- package/dist/_chunks/{actions-CWYtq6ii.js.map → actions-BS-m5SLv.js.map} +1 -1
- package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
- package/dist/_chunks/{build-manifest-DWppEdLB.js → build-manifest-DTmSGLRz.js} +51 -2
- package/dist/_chunks/build-manifest-DTmSGLRz.js.map +1 -0
- package/dist/_chunks/{cache-api-CQeYzA5g.js → cache-api-DqzgTEqk.js} +4 -49
- package/dist/_chunks/cache-api-DqzgTEqk.js.map +1 -0
- package/dist/_chunks/{chains-h7EO-u3n.js → chains-CZG7E5zg.js} +2 -2
- package/dist/_chunks/{chains-h7EO-u3n.js.map → chains-CZG7E5zg.js.map} +1 -1
- package/dist/_chunks/{cli-check-BVthpfLS.js → cli-check-dVDi1GQz.js} +3 -3
- package/dist/_chunks/{cli-check-BVthpfLS.js.map → cli-check-dVDi1GQz.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-3Wutm8pH.js → cli-schema-sync-DTy_-Msq.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-3Wutm8pH.js.map → cli-schema-sync-DTy_-Msq.js.map} +1 -1
- package/dist/_chunks/{cloudflare-BKJC3SC_.js → cloudflare-BFb__LYG.js} +2 -2
- package/dist/_chunks/{cloudflare-BKJC3SC_.js.map → cloudflare-BFb__LYG.js.map} +1 -1
- package/dist/_chunks/{convention-lint-DO10_pVl.js → convention-lint-Ph6luW4c.js} +4 -2
- package/dist/_chunks/convention-lint-Ph6luW4c.js.map +1 -0
- package/dist/_chunks/{error-boundary-D-lkwyaD.js → error-boundary-BvRCCmbN.js} +3 -3
- package/dist/_chunks/{error-boundary-D-lkwyaD.js.map → error-boundary-BvRCCmbN.js.map} +1 -1
- package/dist/_chunks/{href-validation-CMc5JRls.js → href-validation-BIrxavIy.js} +74 -2
- package/dist/_chunks/href-validation-BIrxavIy.js.map +1 -0
- package/dist/_chunks/{live-graph-Bx4HodF1.js → live-graph-BXDsdzBv.js} +3 -3
- package/dist/_chunks/{live-graph-Bx4HodF1.js.map → live-graph-BXDsdzBv.js.map} +1 -1
- package/dist/_chunks/{logger-pumCm3Il.js → logger-DDirEsn7.js} +3 -4
- package/dist/_chunks/{logger-pumCm3Il.js.map → logger-DDirEsn7.js.map} +1 -1
- package/dist/_chunks/navigation-root-B00jjGd5.js +233 -0
- package/dist/_chunks/navigation-root-B00jjGd5.js.map +1 -0
- package/dist/_chunks/{segment-context-CjOlyB8Y.js → param-value-C8TNYchQ.js} +2 -33
- package/dist/_chunks/param-value-C8TNYchQ.js.map +1 -0
- package/dist/_chunks/{poison-scan-BAxfTT5L.js → poison-scan-BoDLgbix.js} +2 -2
- package/dist/_chunks/{poison-scan-BAxfTT5L.js.map → poison-scan-BoDLgbix.js.map} +1 -1
- package/dist/_chunks/{router-ref-BzqbPwYC.js → router-ref-8gr8qsxN.js} +2 -2
- package/dist/_chunks/{router-ref-BzqbPwYC.js.map → router-ref-8gr8qsxN.js.map} +1 -1
- package/dist/_chunks/{rsc-cache-key-DD0fl_-s.js → rsc-cache-key-ClUiXQnK.js} +2 -2
- package/dist/_chunks/{rsc-cache-key-DD0fl_-s.js.map → rsc-cache-key-ClUiXQnK.js.map} +1 -1
- package/dist/_chunks/{scanner-BRIOmHE2.js → scanner-tdFPvDYi.js} +174 -7
- package/dist/_chunks/scanner-tdFPvDYi.js.map +1 -0
- package/dist/_chunks/segment-context-D9_89u34.js +34 -0
- package/dist/_chunks/segment-context-D9_89u34.js.map +1 -0
- package/dist/_chunks/singleflight-2lUWfcAk.js +54 -0
- package/dist/_chunks/singleflight-2lUWfcAk.js.map +1 -0
- package/dist/_chunks/{ssr-data-Ya2HJPFp.js → ssr-data-BQGhTPAK.js} +2 -17
- package/dist/_chunks/ssr-data-BQGhTPAK.js.map +1 -0
- package/dist/_chunks/{walkers-BU6z9xRV.js → walkers-DNX05dC0.js} +2 -2
- package/dist/_chunks/{walkers-BU6z9xRV.js.map → walkers-DNX05dC0.js.map} +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.d.ts +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/analyze/crawl-entry.js +2 -2
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cache/index.js +1 -1
- package/dist/cache/singleflight.d.ts +2 -0
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/browser-entry/hydrate.d.ts +21 -15
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +4 -3
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +17 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/global-context.d.ts +15 -0
- package/dist/client/global-context.d.ts.map +1 -0
- package/dist/client/index.js +138 -35
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.d.ts +0 -1
- package/dist/client/internal.d.ts.map +1 -1
- package/dist/client/internal.js +206 -55
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/location-search.d.ts +12 -0
- package/dist/client/location-search.d.ts.map +1 -0
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-commit.d.ts +18 -0
- package/dist/client/navigation-commit.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +13 -11
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +47 -108
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/navigation-transition.d.ts +136 -0
- package/dist/client/navigation-transition.d.ts.map +1 -0
- package/dist/client/nuqs-adapter.d.ts.map +1 -1
- package/dist/client/params-context.d.ts +4 -5
- package/dist/client/params-context.d.ts.map +1 -1
- package/dist/client/react-root.d.ts +44 -0
- package/dist/client/react-root.d.ts.map +1 -0
- package/dist/client/router-pipeline.d.ts +2 -2
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +12 -2
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +39 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +25 -14
- package/dist/client/segment-outlet.d.ts.map +1 -1
- package/dist/client/segment-update-context.d.ts +3 -9
- package/dist/client/segment-update-context.d.ts.map +1 -1
- package/dist/client/slot-content-cache-context.d.ts +35 -0
- package/dist/client/slot-content-cache-context.d.ts.map +1 -0
- package/dist/client/ssr-data.d.ts +8 -2
- package/dist/client/ssr-data.d.ts.map +1 -1
- package/dist/client/state.d.ts +0 -15
- package/dist/client/state.d.ts.map +1 -1
- package/dist/client/use-pathname.d.ts +13 -11
- package/dist/client/use-pathname.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +13 -13
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/client/use-segment-params.d.ts +18 -68
- package/dist/client/use-segment-params.d.ts.map +1 -1
- package/dist/config-types.d.ts +17 -0
- package/dist/config-types.d.ts.map +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/dev-tools/holding-server.d.ts +15 -10
- package/dist/dev-tools/holding-server.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -43
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +2 -2
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen-write.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/routing/interception-overlap.d.ts +35 -0
- package/dist/routing/interception-overlap.d.ts.map +1 -0
- package/dist/routing/interception.d.ts.map +1 -1
- package/dist/rsc-runtime/ssr.d.ts +3 -1
- package/dist/rsc-runtime/ssr.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +6 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/csp-nonce.d.ts +45 -0
- package/dist/server/csp-nonce.d.ts.map +1 -0
- package/dist/server/default-status-page.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/flight-scripts.d.ts +5 -2
- package/dist/server/flight-scripts.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +17 -2
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts +3 -2
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +86 -37
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-render.d.ts.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +3 -17
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/nuqs-ssr-provider.d.ts +7 -3
- package/dist/server/nuqs-ssr-provider.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/prebuilt/key-discipline.d.ts +32 -3
- package/dist/server/prebuilt/key-discipline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/render-utils.d.ts +4 -3
- package/dist/server/render-utils.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +22 -2
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +5 -1
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts +59 -27
- package/dist/server/ssr-wrappers.d.ts.map +1 -1
- package/dist/server/types.d.ts +10 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/shims/navigation-rsc.d.ts +21 -0
- package/dist/shims/navigation-rsc.d.ts.map +1 -0
- package/docs/api/30-api-server.mdx +1 -0
- package/docs/api/35-api-typescript.mdx +4 -83
- package/docs/learn/03-fetching-data.mdx +1 -1
- package/docs/learn/{03b-access-control.mdx → 04-access-control.mdx} +2 -17
- package/docs/learn/05-the-flush-point.mdx +175 -0
- package/docs/learn/{05-typed-params.mdx → 06-typed-params.mdx} +1 -1
- package/docs/learn/07-typed-routes.mdx +25 -49
- package/docs/learn/{08-streaming.mdx → 09-streaming.mdx} +1 -7
- package/docs/learn/{10-middleware.mdx → 11-middleware.mdx} +1 -0
- package/package.json +3 -3
- package/src/adapters/nitro.ts +7 -7
- package/src/cache/singleflight.ts +5 -0
- package/src/client/browser-entry/hydrate.ts +54 -104
- package/src/client/browser-entry/index.ts +16 -6
- package/src/client/browser-entry/post-hydration.ts +3 -2
- package/src/client/browser-entry/router-init.ts +84 -33
- package/src/client/global-context.ts +31 -0
- package/src/client/internal.ts +1 -2
- package/src/client/link.tsx +18 -18
- package/src/client/location-search.ts +15 -0
- package/src/client/navigation-api.ts +4 -2
- package/src/client/navigation-commit.ts +48 -2
- package/src/client/navigation-context.ts +25 -37
- package/src/client/navigation-root.tsx +55 -411
- package/src/client/navigation-transition.ts +278 -0
- package/src/client/nuqs-adapter.tsx +4 -5
- package/src/client/params-context.ts +13 -18
- package/src/client/react-root.ts +72 -0
- package/src/client/router-lifecycle.ts +1 -1
- package/src/client/router-pipeline.ts +96 -22
- package/src/client/router-types.ts +12 -2
- package/src/client/router.ts +48 -36
- package/src/client/segment-cache.ts +70 -2
- package/src/client/segment-context.ts +7 -4
- package/src/client/segment-outlet.tsx +41 -86
- package/src/client/segment-update-context.ts +7 -26
- package/src/client/slot-content-cache-context.ts +43 -0
- package/src/client/ssr-data.ts +8 -2
- package/src/client/state.ts +0 -26
- package/src/client/use-pathname.ts +21 -31
- package/src/client/use-search-params.ts +31 -29
- package/src/client/use-segment-params.ts +27 -126
- package/src/config-types.ts +17 -0
- package/src/config-validation.ts +17 -0
- package/src/dev-tools/holding-server.ts +23 -12
- package/src/index.ts +26 -11
- package/src/plugins/dev-server.ts +9 -12
- package/src/plugins/entries.ts +3 -0
- package/src/plugins/shims.ts +8 -7
- package/src/plugins/static-build.ts +9 -5
- package/src/react-canary.d.ts +2 -0
- package/src/routing/codegen-write.ts +2 -0
- package/src/routing/interception-overlap.ts +141 -0
- package/src/routing/interception.ts +118 -5
- package/src/rsc-runtime/ssr.ts +3 -2
- package/src/server/als-registry.ts +6 -0
- package/src/server/csp-nonce.ts +70 -0
- package/src/server/default-status-page.ts +1 -0
- package/src/server/deny-renderer.ts +7 -3
- package/src/server/flight-scripts.ts +9 -4
- package/src/server/html-injector-core.ts +26 -9
- package/src/server/html-injectors.ts +8 -8
- package/src/server/metadata-render.ts +26 -4
- package/src/server/node-stream-transforms.ts +7 -20
- package/src/server/nuqs-ssr-provider.tsx +8 -7
- package/src/server/pipeline-phases.ts +5 -0
- package/src/server/prebuilt/key-discipline.ts +82 -13
- package/src/server/prebuilt-runtime.ts +2 -2
- package/src/server/primitives.ts +4 -4
- package/src/server/render-utils.ts +8 -4
- package/src/server/rsc-entry/action-middleware-runner.ts +7 -0
- package/src/server/rsc-entry/error-renderer.ts +5 -2
- package/src/server/rsc-entry/index.ts +8 -0
- package/src/server/rsc-entry/ssr-renderer.ts +11 -4
- package/src/server/ssr-bridge-types.ts +22 -2
- package/src/server/ssr-entry.ts +35 -28
- package/src/server/ssr-render.ts +13 -4
- package/src/server/ssr-wrappers.tsx +81 -61
- package/src/server/types.ts +10 -0
- package/src/shared/slot-params.ts +3 -4
- package/src/shims/navigation-rsc.ts +47 -0
- package/dist/_chunks/build-manifest-DWppEdLB.js.map +0 -1
- package/dist/_chunks/cache-api-CQeYzA5g.js.map +0 -1
- package/dist/_chunks/convention-lint-DO10_pVl.js.map +0 -1
- package/dist/_chunks/href-validation-CMc5JRls.js.map +0 -1
- package/dist/_chunks/scanner-BRIOmHE2.js.map +0 -1
- package/dist/_chunks/segment-context-CjOlyB8Y.js.map +0 -1
- package/dist/_chunks/slot-params-BCTmZkQB.js +0 -76
- package/dist/_chunks/slot-params-BCTmZkQB.js.map +0 -1
- package/dist/_chunks/ssr-data-Ya2HJPFp.js.map +0 -1
- package/dist/_chunks/use-segment-params-DzTBpkvj.js +0 -398
- package/dist/_chunks/use-segment-params-DzTBpkvj.js.map +0 -1
- package/docs/learn/04-loading-states.mdx +0 -67
- package/docs/learn/04b-the-flush-point.mdx +0 -115
- package/docs/learn/12-client-navigation.mdx +0 -176
- package/docs/learn/13-configuration.mdx +0 -166
- package/docs/more/01-advanced-routing.mdx +0 -344
- package/docs/more/02-advanced-forms.mdx +0 -137
- package/docs/more/03-coming-from-nextjs.mdx +0 -186
- package/docs/more/04-metadata-and-fonts.mdx +0 -193
- package/docs/more/04b-mdx.mdx +0 -229
- package/docs/more/05-content-collections.mdx +0 -90
- package/docs/more/06-instrumentation.mdx +0 -214
- package/docs/more/07-security.mdx +0 -129
- package/docs/more/08-developer-experience.mdx +0 -134
- package/docs/more/40-why-timber.mdx +0 -50
- package/docs/more/41-timber-vs-nextjs.mdx +0 -81
- package/docs/more/42-timber-vs-others.mdx +0 -68
- package/docs/more/50-ai-agent-instructions.mdx +0 -171
- /package/docs/learn/{06-forms-and-actions.mdx → 08-forms-and-actions.mdx} +0 -0
- /package/docs/learn/{09-caching.mdx → 10-caching.mdx} +0 -0
- /package/docs/learn/{11-error-handling.mdx → 12-error-handling.mdx} +0 -0
- /package/docs/learn/{14-deploying.mdx → 13-deploying.mdx} +0 -0
|
@@ -60,6 +60,8 @@ const TIMBER_ENV_DTS = [
|
|
|
60
60
|
'/* eslint-disable @typescript-eslint/triple-slash-reference */',
|
|
61
61
|
'/// <reference path=".timber/timber-routes.d.ts" />',
|
|
62
62
|
'/// <reference types="vite/client" />',
|
|
63
|
+
'/// <reference types="react/canary" />',
|
|
64
|
+
'/// <reference types="react-dom/canary" />',
|
|
63
65
|
'',
|
|
64
66
|
].join('\n');
|
|
65
67
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern intersection for intercepted URL patterns.
|
|
3
|
+
*
|
|
4
|
+
* Checks whether two intercepted URL patterns can both match the same concrete
|
|
5
|
+
* URL. Used by the cross-mode claim check (TIM-1319) to detect when a
|
|
6
|
+
* children-path interceptor and a slot interceptor from the same owner overlap.
|
|
7
|
+
*
|
|
8
|
+
* This is not the order-dependence oracle (`findOrderDependentCollision`) the
|
|
9
|
+
* within-mode collision checks use. `findInterceptionMatch` picks the mode by
|
|
10
|
+
* first-match on `pathnameMatchesPattern`, which has no priority tiers, so a
|
|
11
|
+
* static `/feed/foo` and a dynamic `/feed/[id]` both match `/feed/foo` even
|
|
12
|
+
* though `matchUrlParts` would resolve the static deterministically. The
|
|
13
|
+
* intersection test catches that: it walks both patterns segment by segment and
|
|
14
|
+
* asks whether any value satisfies both.
|
|
15
|
+
*
|
|
16
|
+
* This is not a model of `matchNode` precedence — that decides *which*
|
|
17
|
+
* candidate wins when several match one URL. This decides the prior question:
|
|
18
|
+
* *do* they both match one URL at all? The two questions are independent, and
|
|
19
|
+
* conflating them is what caused TIM-1308's five-round drift.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { classifyUrlSegment } from './segment-classify.ts';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check whether two intercepted URL patterns can both match the same concrete
|
|
26
|
+
* URL. Returns a display string for the overlapping URL, or null.
|
|
27
|
+
*
|
|
28
|
+
* Walks both patterns segment by segment:
|
|
29
|
+
* - Two statics: must be identical.
|
|
30
|
+
* - Static + dynamic: static must satisfy the dynamic's affix constraints.
|
|
31
|
+
* - Two dynamics: affix constraints must be compatible.
|
|
32
|
+
* - A catch-all at either side: compatible with everything remaining.
|
|
33
|
+
*
|
|
34
|
+
* If the patterns differ in length, they can only overlap if the longer one's
|
|
35
|
+
* extra part is an optional catch-all (which also matches zero segments).
|
|
36
|
+
*/
|
|
37
|
+
export function interceptedPatternsOverlap(p1: string, p2: string): string | null {
|
|
38
|
+
if (p1 === p2) return p1;
|
|
39
|
+
|
|
40
|
+
const parts1 = p1 === '/' ? [] : p1.slice(1).split('/');
|
|
41
|
+
const parts2 = p2 === '/' ? [] : p2.slice(1).split('/');
|
|
42
|
+
const display: string[] = [];
|
|
43
|
+
let i = 0;
|
|
44
|
+
|
|
45
|
+
while (i < parts1.length && i < parts2.length) {
|
|
46
|
+
const seg1 = classifyUrlSegment(parts1[i]);
|
|
47
|
+
const seg2 = classifyUrlSegment(parts2[i]);
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
seg1.kind === 'catch-all' ||
|
|
51
|
+
seg1.kind === 'optional-catch-all' ||
|
|
52
|
+
seg2.kind === 'catch-all' ||
|
|
53
|
+
seg2.kind === 'optional-catch-all'
|
|
54
|
+
) {
|
|
55
|
+
display.push('<any>');
|
|
56
|
+
return '/' + display.join('/');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const part = segmentsOverlap(seg1, seg2);
|
|
60
|
+
if (part === null) return null;
|
|
61
|
+
display.push(part);
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const remaining = i < parts1.length ? parts1 : i < parts2.length ? parts2 : null;
|
|
66
|
+
if (remaining) {
|
|
67
|
+
const next = classifyUrlSegment(remaining[i]);
|
|
68
|
+
if (next.kind !== 'optional-catch-all') return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return display.length === 0 ? '/' : '/' + display.join('/');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Can some concrete value satisfy both classified URL segments?
|
|
76
|
+
*
|
|
77
|
+
* An affixed dynamic (`a-[id].png`) constrains both the prefix and suffix of
|
|
78
|
+
* the value, so a static segment must satisfy those constraints and two
|
|
79
|
+
* dynamics must have compatible affixes — prefixes where one extends the other,
|
|
80
|
+
* and likewise for suffixes. Without this check, `/feed/photo.jpg` beside
|
|
81
|
+
* `/feed/asset-[id].png` would be falsely rejected.
|
|
82
|
+
*/
|
|
83
|
+
function segmentsOverlap(
|
|
84
|
+
seg1: ReturnType<typeof classifyUrlSegment>,
|
|
85
|
+
seg2: ReturnType<typeof classifyUrlSegment>
|
|
86
|
+
): string | null {
|
|
87
|
+
if (seg1.kind === 'static' && seg2.kind === 'static') {
|
|
88
|
+
return seg1.value === seg2.value ? seg1.value : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (seg1.kind === 'static' && seg2.kind === 'dynamic') {
|
|
92
|
+
return staticMatchesAffix(seg1.value, seg2) ? seg1.value : null;
|
|
93
|
+
}
|
|
94
|
+
if (seg2.kind === 'static' && seg1.kind === 'dynamic') {
|
|
95
|
+
return staticMatchesAffix(seg2.value, seg1) ? seg2.value : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (seg1.kind === 'dynamic' && seg2.kind === 'dynamic') {
|
|
99
|
+
return dynamicAffixesCompatible(seg1, seg2) ? '<any>' : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return '<any>';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Does a static value satisfy an affixed dynamic's prefix, suffix, and
|
|
107
|
+
* non-empty capture constraints?
|
|
108
|
+
*
|
|
109
|
+
* Mirrors the check `pathnameMatchesPattern` applies: the value must start with
|
|
110
|
+
* the prefix, end with the suffix, and be strictly longer than their combined
|
|
111
|
+
* length (a capture of zero characters is not a match).
|
|
112
|
+
*/
|
|
113
|
+
function staticMatchesAffix(value: string, dynamic: { prefix?: string; suffix?: string }): boolean {
|
|
114
|
+
const prefix = dynamic.prefix ?? '';
|
|
115
|
+
const suffix = dynamic.suffix ?? '';
|
|
116
|
+
if (!prefix && !suffix) return true;
|
|
117
|
+
if (prefix && !value.startsWith(prefix)) return false;
|
|
118
|
+
if (suffix && !value.endsWith(suffix)) return false;
|
|
119
|
+
return value.length > prefix.length + suffix.length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Can some value simultaneously satisfy two affixed dynamics?
|
|
124
|
+
*
|
|
125
|
+
* Two prefix constraints are compatible when one extends the other (a value
|
|
126
|
+
* starting with the longer one also starts with the shorter). Suffixes are
|
|
127
|
+
* analogous. Without affixes on either side, every value works.
|
|
128
|
+
*/
|
|
129
|
+
function dynamicAffixesCompatible(
|
|
130
|
+
d1: { prefix?: string; suffix?: string },
|
|
131
|
+
d2: { prefix?: string; suffix?: string }
|
|
132
|
+
): boolean {
|
|
133
|
+
const p1 = d1.prefix ?? '',
|
|
134
|
+
s1 = d1.suffix ?? '';
|
|
135
|
+
const p2 = d2.prefix ?? '',
|
|
136
|
+
s2 = d2.suffix ?? '';
|
|
137
|
+
|
|
138
|
+
if (p1 && p2 && !p1.startsWith(p2) && !p2.startsWith(p1)) return false;
|
|
139
|
+
if (s1 && s2 && !s1.endsWith(s2) && !s2.endsWith(s1)) return false;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
@@ -24,6 +24,7 @@ import { classifyUrlSegment, effectiveUrlSegment } from './segment-classify.ts';
|
|
|
24
24
|
// — decides order-dependence with the same oracle, so the spaces cannot drift
|
|
25
25
|
// on what counts as two pages answering one URL. See TIM-1308.
|
|
26
26
|
import { findOrderDependentCollision, type ProbeLeaf } from './collision-probe.ts';
|
|
27
|
+
import { interceptedPatternsOverlap } from './interception-overlap.ts';
|
|
27
28
|
|
|
28
29
|
/** Fields shared by both interception shapes. */
|
|
29
30
|
interface InterceptionRewriteBase {
|
|
@@ -256,11 +257,12 @@ export function collectInterceptionRewrites(root: SegmentNode): InterceptionRewr
|
|
|
256
257
|
* nothing per request.
|
|
257
258
|
*
|
|
258
259
|
* Sorting is stable, so rewrites sharing one scope keep tree-walk order. Equal
|
|
259
|
-
* scopes mean one owner, and of the three pairings that produces only one
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
260
|
+
* scopes mean one owner, and of the three pairings that produces only one was
|
|
261
|
+
* ambiguous — a children-path interceptor beside a slot one — and that is now
|
|
262
|
+
* rejected at build time by `assertNoCrossModeClaims` (TIM-1319). The
|
|
263
|
+
* enumeration is in design/07-routing.md §"Selection Between Owners" rather
|
|
264
|
+
* than repeated here, so there is one place to correct if a rewrite ever grows
|
|
265
|
+
* a field that makes two of them distinguishable.
|
|
264
266
|
*/
|
|
265
267
|
function sortByScopeDepth(rewrites: InterceptionRewrite[]): InterceptionRewrite[] {
|
|
266
268
|
return rewrites.sort(
|
|
@@ -595,6 +597,108 @@ function assertNoInterceptedPatternCollisions(owner: SegmentNode, ownerPath: str
|
|
|
595
597
|
}
|
|
596
598
|
}
|
|
597
599
|
|
|
600
|
+
/**
|
|
601
|
+
* Reject a children-path interceptor and a slot interceptor from the same owner
|
|
602
|
+
* that claim URLs in common.
|
|
603
|
+
*
|
|
604
|
+
* Within one mode the ambiguity is either non-existent (two children-path
|
|
605
|
+
* rewrites are interchangeable, §"Selection Between Owners") or harmless (two
|
|
606
|
+
* slots on one owner both render). Across modes one is a layout swap and the
|
|
607
|
+
* other is a modal overlay, and the walk order picks the children-path shape —
|
|
608
|
+
* making the modal dead, silently and deterministically (TIM-1319).
|
|
609
|
+
*
|
|
610
|
+
* Overlap is a **pattern intersection** test: do two intercepted URL patterns
|
|
611
|
+
* share any concrete URL? This is not the same question the within-mode
|
|
612
|
+
* collision oracle answers. `findOrderDependentCollision` checks whether two
|
|
613
|
+
* candidates in one `matchUrlParts` walk produce different winners when
|
|
614
|
+
* `children` order is reversed — that is, order-dependence. A static segment
|
|
615
|
+
* always beats a dynamic one deterministically (no order-dependence), so
|
|
616
|
+
* `/feed/foo` and `/feed/[id]` pass the oracle even though
|
|
617
|
+
* `pathnameMatchesPattern` — the actual rewrite matcher — accepts both. The
|
|
618
|
+
* pattern intersection test catches that: it walks both patterns segment by
|
|
619
|
+
* segment and asks whether any value satisfies both.
|
|
620
|
+
*
|
|
621
|
+
* The check collects intercepted patterns from children-path interceptors and
|
|
622
|
+
* from each slot's interceptors independently. Slots are checked one at a time
|
|
623
|
+
* so two slot interceptors from *different* slots claiming one URL — the legal
|
|
624
|
+
* "two slots on one owner" case — are never compared.
|
|
625
|
+
*/
|
|
626
|
+
function assertNoCrossModeClaims(node: SegmentNode, ownerPath: string): void {
|
|
627
|
+
// Collect children-path interceptor patterns
|
|
628
|
+
const childrenLeaves: Array<{ pattern: string; filePath: string }> = [];
|
|
629
|
+
for (const child of node.children) {
|
|
630
|
+
if (
|
|
631
|
+
child.segmentType !== 'intercepting' ||
|
|
632
|
+
!child.interceptionMarker ||
|
|
633
|
+
!child.interceptedSegmentName
|
|
634
|
+
)
|
|
635
|
+
continue;
|
|
636
|
+
const urlBase = computeInterceptedUrlBase(
|
|
637
|
+
node.urlPath,
|
|
638
|
+
child.interceptionMarker,
|
|
639
|
+
child.interceptedSegmentName
|
|
640
|
+
);
|
|
641
|
+
walkInterceptedLeaves(child, urlBase, [child], (page, leaf) => {
|
|
642
|
+
childrenLeaves.push({ pattern: leaf.interceptedPattern, filePath: page.filePath });
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (childrenLeaves.length === 0) return;
|
|
647
|
+
|
|
648
|
+
const owner = ownerPath || '(app root)';
|
|
649
|
+
|
|
650
|
+
for (const slot of Object.values(node.slots)) {
|
|
651
|
+
const slotLeaves: Array<{ pattern: string; filePath: string }> = [];
|
|
652
|
+
for (const child of slot.children) {
|
|
653
|
+
if (
|
|
654
|
+
child.segmentType !== 'intercepting' ||
|
|
655
|
+
!child.interceptionMarker ||
|
|
656
|
+
!child.interceptedSegmentName
|
|
657
|
+
)
|
|
658
|
+
continue;
|
|
659
|
+
const urlBase = computeInterceptedUrlBase(
|
|
660
|
+
node.urlPath,
|
|
661
|
+
child.interceptionMarker,
|
|
662
|
+
child.interceptedSegmentName
|
|
663
|
+
);
|
|
664
|
+
walkInterceptedLeaves(child, urlBase, [child], (page, leaf) => {
|
|
665
|
+
slotLeaves.push({ pattern: leaf.interceptedPattern, filePath: page.filePath });
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (const cl of childrenLeaves) {
|
|
670
|
+
for (const sl of slotLeaves) {
|
|
671
|
+
const overlap = interceptedPatternsOverlap(cl.pattern, sl.pattern);
|
|
672
|
+
if (overlap !== null) {
|
|
673
|
+
throwCrossModeClaim(owner, overlap, cl.filePath, sl.filePath);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function throwCrossModeClaim(
|
|
681
|
+
owner: string,
|
|
682
|
+
url: string,
|
|
683
|
+
childFile: string,
|
|
684
|
+
slotFile: string
|
|
685
|
+
): never {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`Build error: a children-path interception and a slot interception both ` +
|
|
688
|
+
`claim the same URL.\n` +
|
|
689
|
+
` Owner: app/${owner}\n` +
|
|
690
|
+
` URL: ${url}\n` +
|
|
691
|
+
` Children: ${childFile}\n` +
|
|
692
|
+
` Slot: ${slotFile}\n` +
|
|
693
|
+
`The children-path interceptor replaces the owner's children (layout swap) ` +
|
|
694
|
+
`while the slot interceptor renders inside a parallel slot (modal overlay). ` +
|
|
695
|
+
`Both apply from the same scope at the same URL, and which one wins would ` +
|
|
696
|
+
`depend on internal walk order rather than an explicit rule.\n` +
|
|
697
|
+
`Remove one of the two, or move them to different scopes.\n` +
|
|
698
|
+
`See design/07-routing.md §"Selection Between Owners".`
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
598
702
|
/**
|
|
599
703
|
* The URL-matching identity of an intercepting node, as a node.
|
|
600
704
|
*
|
|
@@ -792,6 +896,15 @@ function walkForPlacement(node: SegmentNode, path: string, ctx: PlacementContext
|
|
|
792
896
|
ancestorsBindParams: false,
|
|
793
897
|
});
|
|
794
898
|
}
|
|
899
|
+
|
|
900
|
+
// After both children and slots have been validated independently, check for
|
|
901
|
+
// the cross-mode ambiguity that neither space covers: a children-path
|
|
902
|
+
// interceptor and a slot interceptor from the same owner claiming one URL.
|
|
903
|
+
// Within each mode the collision is either ordered (two children-path) or
|
|
904
|
+
// harmless (two slots) — see §"Selection Between Owners" — but across modes
|
|
905
|
+
// one is an overlay and the other a layout swap, and the walk order picks the
|
|
906
|
+
// children-path shape silently (TIM-1319).
|
|
907
|
+
assertNoCrossModeClaims(node, path);
|
|
795
908
|
}
|
|
796
909
|
|
|
797
910
|
/**
|
package/src/rsc-runtime/ssr.ts
CHANGED
|
@@ -53,10 +53,11 @@ try {
|
|
|
53
53
|
* signaling the caller to use createFromReadableStream instead.
|
|
54
54
|
*/
|
|
55
55
|
export function createFromNodeStream(
|
|
56
|
-
stream: import('node:stream').Readable
|
|
56
|
+
stream: import('node:stream').Readable,
|
|
57
|
+
options?: { nonce?: string }
|
|
57
58
|
): React.ReactNode | null {
|
|
58
59
|
if (!_createFromNodeStream) return null;
|
|
59
|
-
return _createFromNodeStream(stream, createServerConsumerManifest());
|
|
60
|
+
return _createFromNodeStream(stream, createServerConsumerManifest(), options);
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/** Whether the Node.js stream RSC decode path is available. */
|
|
@@ -76,6 +76,12 @@ export interface RequestContextStore {
|
|
|
76
76
|
parsedCookies?: Map<string, string>;
|
|
77
77
|
/** Original (pre-overlay) frozen headers, kept for overlay merging. */
|
|
78
78
|
originalHeaders: Headers;
|
|
79
|
+
/**
|
|
80
|
+
* CSP nonce, created the first time middleware reads `ctx.nonce`. Read by
|
|
81
|
+
* the renderers via `getCspNonce()` and stamped on every inline script.
|
|
82
|
+
* Absent when no middleware asked for one. See server/csp-nonce.ts.
|
|
83
|
+
*/
|
|
84
|
+
cspNonce?: string;
|
|
79
85
|
/**
|
|
80
86
|
* Raw URLSearchParams for the current request.
|
|
81
87
|
* To get typed parsed params, import a search params definition and
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSP nonce — per-request, generated on demand.
|
|
3
|
+
*
|
|
4
|
+
* timber emits several inline `<script>` tags per HTML response — React's
|
|
5
|
+
* bootstrap script and Fizz runtime, the Flight init/chunk scripts, the
|
|
6
|
+
* segment-tree script, and the post-flush noindex script. Under a strict
|
|
7
|
+
* `Content-Security-Policy` (`script-src 'nonce-…' 'strict-dynamic'`) every
|
|
8
|
+
* one of them needs the request's nonce or the browser refuses to run it.
|
|
9
|
+
*
|
|
10
|
+
* The app opts in by reading `ctx.nonce` in `middleware.ts` and putting it
|
|
11
|
+
* in the CSP header it sets through `ctx.headers`. The first read generates
|
|
12
|
+
* the value and stores it on the request context; the render pipeline reads
|
|
13
|
+
* it back with `getCspNonce()` and stamps it on every inline script. A
|
|
14
|
+
* request whose middleware never reads `ctx.nonce` has no nonce, and its
|
|
15
|
+
* markup is byte-identical to a render without this feature. timber does
|
|
16
|
+
* not parse the CSP header — the app owns the policy, timber owns the
|
|
17
|
+
* markup, and `ctx.nonce` is the one value both are built from.
|
|
18
|
+
*
|
|
19
|
+
* `proxy.ts` cannot participate: it only sees the finished response from
|
|
20
|
+
* `next()`, after the markup exists.
|
|
21
|
+
*
|
|
22
|
+
* See design/13-security.md §"Content Security Policy" (TIM-1414).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { requestContextAls } from './als-registry.ts';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generate a CSP nonce: 128 random bits, base64. Matches CSP's
|
|
29
|
+
* `base64-value` grammar, so it is valid in the header verbatim and safe
|
|
30
|
+
* inside a double-quoted HTML attribute without escaping.
|
|
31
|
+
*/
|
|
32
|
+
function generateNonce(): string {
|
|
33
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
34
|
+
let binary = '';
|
|
35
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
36
|
+
return btoa(binary);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The request's CSP nonce, creating it on first call. Backs the
|
|
41
|
+
* `ctx.nonce` getter on `MiddlewareContext`. Outside a request context
|
|
42
|
+
* (tests, build-time rendering) a fresh value is returned but not
|
|
43
|
+
* retained, since there is no request for it to belong to.
|
|
44
|
+
*/
|
|
45
|
+
export function requestCspNonce(): string {
|
|
46
|
+
const store = requestContextAls.getStore();
|
|
47
|
+
if (!store) return generateNonce();
|
|
48
|
+
store.cspNonce ??= generateNonce();
|
|
49
|
+
return store.cspNonce;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The request's CSP nonce if middleware asked for one, else `undefined` —
|
|
54
|
+
* in which case the pipeline emits bare `<script>` tags.
|
|
55
|
+
*/
|
|
56
|
+
export function getCspNonce(): string | undefined {
|
|
57
|
+
return requestContextAls.getStore()?.cspNonce;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The ` nonce="…"` attribute fragment for a timber-generated `<script>` tag,
|
|
62
|
+
* or `''` when the request has no nonce. Callers interpolate it directly
|
|
63
|
+
* after the tag name: `` `<script${nonceAttr(nonce)}>` ``.
|
|
64
|
+
*
|
|
65
|
+
* The value comes from `generateNonce` and is base64, so no attribute
|
|
66
|
+
* escaping is required.
|
|
67
|
+
*/
|
|
68
|
+
export function nonceAttr(nonce: string | undefined): string {
|
|
69
|
+
return nonce ? ` nonce="${nonce}"` : '';
|
|
70
|
+
}
|
|
@@ -94,6 +94,7 @@ body {
|
|
|
94
94
|
background: var(--bg); color: var(--fg);
|
|
95
95
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
96
96
|
text-align: center; padding: 2rem;
|
|
97
|
+
box-sizing: border-box;
|
|
97
98
|
}
|
|
98
99
|
h1 { font-size: 3rem; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
|
99
100
|
p { margin: 0.5rem 0 0; color: var(--muted); font-size: 1rem; }
|
|
@@ -37,6 +37,7 @@ import type { ManifestSegmentNode } from './route-matcher.ts';
|
|
|
37
37
|
import type { RouteMatch } from './pipeline.ts';
|
|
38
38
|
import type { NavContext } from './ssr-bridge-types.ts';
|
|
39
39
|
import { flightInitScript } from './flight-scripts.ts';
|
|
40
|
+
import { getCspNonce } from './csp-nonce.ts';
|
|
40
41
|
import type { ClientBootstrapConfig } from './html-injectors.ts';
|
|
41
42
|
import type { Metadata } from './types.ts';
|
|
42
43
|
import { teeWithErrorPropagation } from './stream-utils.ts';
|
|
@@ -226,15 +227,18 @@ export async function renderDenyPage(
|
|
|
226
227
|
|
|
227
228
|
const slotParams = getSlotParamsForClient();
|
|
228
229
|
|
|
230
|
+
const denyUrl = new URL(req.url);
|
|
231
|
+
const nonce = getCspNonce();
|
|
229
232
|
const navContext: NavContext = {
|
|
230
|
-
pathname:
|
|
233
|
+
pathname: denyUrl.pathname,
|
|
234
|
+
search: appVisibleSearch(denyUrl).search,
|
|
231
235
|
segmentParams: match.segmentParams,
|
|
232
|
-
searchParams: Object.fromEntries(appVisibleSearch(new URL(req.url)).params),
|
|
233
236
|
slotParams,
|
|
234
237
|
statusCode: deny.status,
|
|
235
238
|
responseHeaders,
|
|
236
|
-
headHtml: flightInitScript(),
|
|
239
|
+
headHtml: flightInitScript(nonce),
|
|
237
240
|
bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
|
|
241
|
+
nonce,
|
|
238
242
|
rscStream: inlineStream,
|
|
239
243
|
signal: req.signal,
|
|
240
244
|
cookies: getCookiesForSsr(),
|
|
@@ -9,9 +9,14 @@
|
|
|
9
9
|
* to execute before any streaming chunk scripts). Push scripts are emitted
|
|
10
10
|
* as RSC chunks arrive during streaming.
|
|
11
11
|
*
|
|
12
|
+
* Every generator takes the request's CSP nonce (from `getCspNonce()`)
|
|
13
|
+
* so the tags run under a nonce-based `script-src` (TIM-1414).
|
|
14
|
+
*
|
|
12
15
|
* See design/02-rendering-pipeline.md, LOCAL-415
|
|
13
16
|
*/
|
|
14
17
|
|
|
18
|
+
import { nonceAttr } from './csp-nonce.ts';
|
|
19
|
+
|
|
15
20
|
// ─── JSON Escaping ────────────────────────────────────────────────────────
|
|
16
21
|
|
|
17
22
|
/**
|
|
@@ -43,8 +48,8 @@ const FLIGHT_VAR = 'self.__timber_f';
|
|
|
43
48
|
* Also emits the bootstrap signal [0] which tells the client that
|
|
44
49
|
* Flight data is active for this page.
|
|
45
50
|
*/
|
|
46
|
-
export function flightInitScript(): string {
|
|
47
|
-
return `<script>${FLIGHT_VAR}=[${htmlEscapeJsonString(JSON.stringify([0]))}]</script>`;
|
|
51
|
+
export function flightInitScript(nonce?: string): string {
|
|
52
|
+
return `<script${nonceAttr(nonce)}>${FLIGHT_VAR}=[${htmlEscapeJsonString(JSON.stringify([0]))}]</script>`;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/**
|
|
@@ -56,7 +61,7 @@ export function flightInitScript(): string {
|
|
|
56
61
|
* never runs. The `||[]` guard ensures the array is created on first
|
|
57
62
|
* push if needed, preventing a TypeError. See TIM-559.
|
|
58
63
|
*/
|
|
59
|
-
export function flightChunkScript(data: string): string {
|
|
64
|
+
export function flightChunkScript(data: string, nonce?: string): string {
|
|
60
65
|
const escaped = htmlEscapeJsonString(JSON.stringify([1, data]));
|
|
61
|
-
return `<script>(${FLIGHT_VAR}=${FLIGHT_VAR}||[]).push(${escaped})</script>`;
|
|
66
|
+
return `<script${nonceAttr(nonce)}>(${FLIGHT_VAR}=${FLIGHT_VAR}||[]).push(${escaped})</script>`;
|
|
62
67
|
}
|
|
@@ -68,7 +68,11 @@ export class BufferAggregator {
|
|
|
68
68
|
private chunks: Uint8Array[] = [];
|
|
69
69
|
private byteLength = 0;
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
private readonly maxBufferByteLength: number;
|
|
72
|
+
|
|
73
|
+
constructor(maxBufferByteLength: number = Infinity) {
|
|
74
|
+
this.maxBufferByteLength = maxBufferByteLength;
|
|
75
|
+
}
|
|
72
76
|
|
|
73
77
|
/**
|
|
74
78
|
* Append a chunk. Returns `true` if the caller should flush synchronously
|
|
@@ -350,6 +354,21 @@ export function injectorFlush(state: InjectorState): Uint8Array | null {
|
|
|
350
354
|
* **No polling.** See design/02-rendering-pipeline.md §"No Polling".
|
|
351
355
|
* **Always timeout-guarded reads.** Per design/02 §"Streaming Constraints".
|
|
352
356
|
*/
|
|
357
|
+
export interface FlightInjectorOptions {
|
|
358
|
+
/**
|
|
359
|
+
* Timeout in milliseconds for individual RSC stream reads. If a single
|
|
360
|
+
* `rscReader.read()` call does not resolve within this duration, the
|
|
361
|
+
* read is aborted and the stream errors with a `RenderTimeoutError`.
|
|
362
|
+
* Default: 30000 (30s). `0` ("disabled") still enforces a 120s ceiling
|
|
363
|
+
* so a stalled RSC stream can never hang the connection forever.
|
|
364
|
+
*/
|
|
365
|
+
renderTimeoutMs?: number;
|
|
366
|
+
/** Signal that fires when an unhandled RSC error is detected. */
|
|
367
|
+
errorSignal?: AbortSignal;
|
|
368
|
+
/** Request CSP nonce, stamped on every emitted `<script>` (TIM-1414). */
|
|
369
|
+
nonce?: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
353
372
|
export class FlightInjectorCore {
|
|
354
373
|
readonly machine: Machine<FlightInjectionState, FlightInjectionEvent>;
|
|
355
374
|
/** Script chunks waiting to be drained between HTML chunks. */
|
|
@@ -358,18 +377,16 @@ export class FlightInjectorCore {
|
|
|
358
377
|
private readonly rscReader: ReadableStreamDefaultReader<Uint8Array>;
|
|
359
378
|
private readonly timeoutMs: number;
|
|
360
379
|
private readonly errorSignal?: AbortSignal;
|
|
380
|
+
private readonly nonce?: string;
|
|
361
381
|
private readonly decoder = new TextDecoder('utf-8', { fatal: true });
|
|
362
382
|
private pullPromise: Promise<void> | null = null;
|
|
363
383
|
private _aborted = false;
|
|
364
384
|
|
|
365
|
-
constructor(
|
|
366
|
-
rscStream: ReadableStream<Uint8Array>,
|
|
367
|
-
renderTimeoutMs?: number,
|
|
368
|
-
errorSignal?: AbortSignal
|
|
369
|
-
) {
|
|
385
|
+
constructor(rscStream: ReadableStream<Uint8Array>, options: FlightInjectorOptions = {}) {
|
|
370
386
|
this.rscReader = rscStream.getReader();
|
|
371
|
-
this.timeoutMs = (renderTimeoutMs ?? 30_000) || 120_000;
|
|
372
|
-
this.errorSignal = errorSignal;
|
|
387
|
+
this.timeoutMs = (options.renderTimeoutMs ?? 30_000) || 120_000;
|
|
388
|
+
this.errorSignal = options.errorSignal;
|
|
389
|
+
this.nonce = options.nonce;
|
|
373
390
|
this.machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
|
|
374
391
|
initial: { phase: 'init' },
|
|
375
392
|
transitions: flightInjectionTransitions,
|
|
@@ -470,7 +487,7 @@ export class FlightInjectorCore {
|
|
|
470
487
|
}
|
|
471
488
|
|
|
472
489
|
const decoded = this.decoder.decode(value, { stream: true });
|
|
473
|
-
this.pending.push(encoder.encode(flightChunkScript(decoded)));
|
|
490
|
+
this.pending.push(encoder.encode(flightChunkScript(decoded, this.nonce)));
|
|
474
491
|
|
|
475
492
|
// Yield between reads so HTML chunks get priority — but only while
|
|
476
493
|
// HTML is still streaming. Once flush() fires (HTML_DONE), drain
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import {
|
|
22
22
|
BufferAggregator,
|
|
23
23
|
FlightInjectorCore,
|
|
24
|
+
type FlightInjectorOptions,
|
|
24
25
|
createInjectorState,
|
|
25
26
|
createSuffixState,
|
|
26
27
|
encodeUtf8,
|
|
@@ -201,7 +202,8 @@ export function injectScripts(
|
|
|
201
202
|
*/
|
|
202
203
|
export function createInlinedRscStream(
|
|
203
204
|
rscStream: ReadableStream<Uint8Array>,
|
|
204
|
-
renderTimeoutMs?: number
|
|
205
|
+
renderTimeoutMs?: number,
|
|
206
|
+
nonce?: string
|
|
205
207
|
): ReadableStream<Uint8Array> {
|
|
206
208
|
const rscReader = rscStream.getReader();
|
|
207
209
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
@@ -224,7 +226,7 @@ export function createInlinedRscStream(
|
|
|
224
226
|
}
|
|
225
227
|
if (value) {
|
|
226
228
|
const decoded = decoder.decode(value, { stream: true });
|
|
227
|
-
controller.enqueue(encodeUtf8(flightChunkScript(decoded)));
|
|
229
|
+
controller.enqueue(encodeUtf8(flightChunkScript(decoded, nonce)));
|
|
228
230
|
}
|
|
229
231
|
} catch (error) {
|
|
230
232
|
if (error instanceof RenderTimeoutError) {
|
|
@@ -257,10 +259,9 @@ export function createInlinedRscStream(
|
|
|
257
259
|
*/
|
|
258
260
|
function createFlightInjectionTransform(
|
|
259
261
|
rscStream: ReadableStream<Uint8Array>,
|
|
260
|
-
|
|
261
|
-
errorSignal?: AbortSignal
|
|
262
|
+
options?: FlightInjectorOptions
|
|
262
263
|
): TransformStream<Uint8Array, Uint8Array> {
|
|
263
|
-
const core = new FlightInjectorCore(rscStream,
|
|
264
|
+
const core = new FlightInjectorCore(rscStream, options);
|
|
264
265
|
// Controller reference — set on first transform() call so the pull
|
|
265
266
|
// loop can drain pending scripts between HTML chunks (after yielding).
|
|
266
267
|
let _controller: TransformStreamDefaultController<Uint8Array> | null = null;
|
|
@@ -340,8 +341,7 @@ function createFlightInjectionTransform(
|
|
|
340
341
|
export function injectRscPayload(
|
|
341
342
|
htmlStream: ReadableStream<Uint8Array>,
|
|
342
343
|
rscStream: ReadableStream<Uint8Array> | undefined,
|
|
343
|
-
|
|
344
|
-
errorSignal?: AbortSignal
|
|
344
|
+
options?: FlightInjectorOptions
|
|
345
345
|
): ReadableStream<Uint8Array> {
|
|
346
346
|
if (!rscStream) return htmlStream;
|
|
347
347
|
// Pass the raw RSC stream directly to the flight injection transform.
|
|
@@ -349,7 +349,7 @@ export function injectRscPayload(
|
|
|
349
349
|
// <script> via flightChunkScript itself — do NOT pre-wrap with
|
|
350
350
|
// createInlinedRscStream here, that would double-wrap.
|
|
351
351
|
return htmlStream
|
|
352
|
-
.pipeThrough(createFlightInjectionTransform(rscStream,
|
|
352
|
+
.pipeThrough(createFlightInjectionTransform(rscStream, options))
|
|
353
353
|
.pipeThrough(createMoveSuffixStream());
|
|
354
354
|
}
|
|
355
355
|
|
|
@@ -78,14 +78,36 @@ export function renderMetadataToElements(metadata: Metadata): HeadElement[] {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// Open Graph
|
|
81
|
+
// Open Graph — inherit title/description from top-level when not set explicitly
|
|
82
82
|
if (metadata.openGraph) {
|
|
83
|
-
|
|
83
|
+
const og = { ...metadata.openGraph };
|
|
84
|
+
if (og.title === undefined && typeof metadata.title === 'string') {
|
|
85
|
+
og.title = metadata.title;
|
|
86
|
+
}
|
|
87
|
+
if (og.description === undefined && metadata.description) {
|
|
88
|
+
og.description = metadata.description;
|
|
89
|
+
}
|
|
90
|
+
renderOpenGraph(og, elements);
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
// Twitter
|
|
93
|
+
// Twitter — inherit title/description from openGraph or top-level when not set
|
|
87
94
|
if (metadata.twitter) {
|
|
88
|
-
|
|
95
|
+
const tw = { ...metadata.twitter };
|
|
96
|
+
if (tw.title === undefined) {
|
|
97
|
+
tw.title =
|
|
98
|
+
metadata.openGraph?.title !== undefined
|
|
99
|
+
? metadata.openGraph.title
|
|
100
|
+
: typeof metadata.title === 'string'
|
|
101
|
+
? metadata.title
|
|
102
|
+
: undefined;
|
|
103
|
+
}
|
|
104
|
+
if (tw.description === undefined) {
|
|
105
|
+
tw.description =
|
|
106
|
+
metadata.openGraph?.description !== undefined
|
|
107
|
+
? metadata.openGraph.description
|
|
108
|
+
: metadata.description;
|
|
109
|
+
}
|
|
110
|
+
renderTwitter(tw, elements);
|
|
89
111
|
}
|
|
90
112
|
|
|
91
113
|
// Icons
|