@timber-js/app 0.2.0-alpha.186 → 0.2.0-alpha.188
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-C-Rw9vPc.js → actions-35jnMdeJ.js} +4 -3
- package/dist/_chunks/{actions-C-Rw9vPc.js.map → actions-35jnMdeJ.js.map} +1 -1
- package/dist/_chunks/als-registry-C6kcfprT.js +41 -0
- package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -0
- package/dist/_chunks/{als-slots-mFweg276.js → als-slots-BEEIPKYm.js} +3 -4
- package/dist/_chunks/{als-slots-mFweg276.js.map → als-slots-BEEIPKYm.js.map} +1 -1
- package/dist/_chunks/{cache-api-Cd0VZ_Pd.js → cache-api-DjNrIWRR.js} +7 -13
- package/dist/_chunks/cache-api-DjNrIWRR.js.map +1 -0
- package/dist/_chunks/cli-check-CpmN7Nh-.js +256 -0
- package/dist/_chunks/cli-check-CpmN7Nh-.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-CGMp_Psg.js +298 -0
- package/dist/_chunks/cli-schema-sync-CGMp_Psg.js.map +1 -0
- package/dist/_chunks/{cloudflare-Cs0uZXea.js → cloudflare-CGP6BZKO.js} +4 -3
- package/dist/_chunks/{cloudflare-Cs0uZXea.js.map → cloudflare-CGP6BZKO.js.map} +1 -1
- package/dist/_chunks/convention-lint-kXsgc_-7.js +784 -0
- package/dist/_chunks/convention-lint-kXsgc_-7.js.map +1 -0
- package/dist/_chunks/{error-boundary-DpYRI_I1.js → error-boundary-D-ODYX41.js} +25 -2
- package/dist/_chunks/error-boundary-D-ODYX41.js.map +1 -0
- package/dist/_chunks/file-cache-DmX7OqZP.js +454 -0
- package/dist/_chunks/file-cache-DmX7OqZP.js.map +1 -0
- package/dist/_chunks/{logger-N7e5auP0.js → logger-D8xJZXIN.js} +27 -40
- package/dist/_chunks/logger-D8xJZXIN.js.map +1 -0
- package/dist/_chunks/mdx-file-C005ay-P.js +25 -0
- package/dist/_chunks/mdx-file-C005ay-P.js.map +1 -0
- package/dist/_chunks/{plugin-context-DEGLSJs3.js → plugin-context-rCinWLiE.js} +7 -2
- package/dist/_chunks/{plugin-context-DEGLSJs3.js.map → plugin-context-rCinWLiE.js.map} +1 -1
- package/dist/_chunks/purge-store-Byr8XOjU.js +14 -0
- package/dist/_chunks/purge-store-Byr8XOjU.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Dz3fcFUo.js → resolve-schema-5ma5pp1b.js} +2 -2
- package/dist/_chunks/{resolve-schema-Dz3fcFUo.js.map → resolve-schema-5ma5pp1b.js.map} +1 -1
- package/dist/_chunks/rsc-media-type-DRqE_lD_.js +46 -0
- package/dist/_chunks/rsc-media-type-DRqE_lD_.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-CKgHC2MB.js → scanner-C8b0Gcw3.js} +5 -298
- package/dist/_chunks/scanner-C8b0Gcw3.js.map +1 -0
- package/dist/_chunks/schema-bridge-Cc2Gngu1.js +199 -0
- package/dist/_chunks/schema-bridge-Cc2Gngu1.js.map +1 -0
- package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
- package/dist/_chunks/{param-value-C8TNYchQ.js → segment-context-CjOlyB8Y.js} +33 -2
- package/dist/_chunks/segment-context-CjOlyB8Y.js.map +1 -0
- package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
- package/dist/_chunks/{use-query-states-DFvWd-EA.js → use-query-states-BbU5Ge1V.js} +74 -26
- package/dist/_chunks/use-query-states-BbU5Ge1V.js.map +1 -0
- package/dist/_chunks/{navigation-root-B29qg0_T.js → use-segment-params-C4r4BD9T.js} +129 -5
- package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -0
- package/dist/_chunks/walkers-RzN6AFjr.js +141 -0
- package/dist/_chunks/walkers-RzN6AFjr.js.map +1 -0
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/compress-module.d.ts +12 -0
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.js +54 -2
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cdn/cloudflare-purge.js +30 -0
- package/dist/cdn/cloudflare-purge.js.map +1 -0
- package/dist/cdn/fastly-purge.js +33 -0
- package/dist/cdn/fastly-purge.js.map +1 -0
- package/dist/cdn/index.js +94 -0
- package/dist/cdn/index.js.map +1 -0
- package/dist/cdn/workers-cache-purge.js +35 -0
- package/dist/cdn/workers-cache-purge.js.map +1 -0
- package/dist/cli-check.d.ts +153 -0
- package/dist/cli-check.d.ts.map +1 -0
- package/dist/cli.d.ts +34 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +46 -21
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +1 -1
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts +6 -0
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +27 -33
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +33 -17
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +1 -7
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-commit.d.ts +16 -0
- package/dist/client/navigation-commit.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +9 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +14 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +9 -3
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -2
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +142 -477
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/plugin-context.d.ts +15 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts +0 -9
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +24 -0
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen-shared.d.ts +3 -44
- package/dist/routing/codegen-shared.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +10 -31
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/codegen-write.d.ts +51 -0
- package/dist/routing/codegen-write.d.ts.map +1 -0
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts +18 -4
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/export-detect.d.ts +16 -0
- package/dist/routing/export-detect.d.ts.map +1 -1
- package/dist/routing/index.js +3 -2
- package/dist/routing/link-codegen.d.ts +19 -4
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/routing/manifest-codegen.d.ts +1 -7
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/routing/segment-keys.d.ts +22 -0
- package/dist/routing/segment-keys.d.ts.map +1 -1
- package/dist/routing/types.d.ts +0 -6
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +60 -9
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts +62 -8
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.d.ts +0 -1
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +66 -29
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/parse-total.d.ts +70 -0
- package/dist/search-params/parse-total.d.ts.map +1 -0
- package/dist/search-params/wrappers.d.ts +26 -3
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/server/access-gate.d.ts +19 -8
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +16 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/chain-url-parts.d.ts +2 -3
- package/dist/server/chain-url-parts.d.ts.map +1 -1
- package/dist/server/compress.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +148 -15
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +2 -2
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +85 -15
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.d.ts +3 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +344 -237
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-collector.d.ts +52 -0
- package/dist/server/metadata-collector.d.ts.map +1 -0
- package/dist/server/param-coercion.d.ts +12 -5
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline-outcome.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +23 -0
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +1 -12
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +0 -7
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +0 -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/rsc-entry/revalidate-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +1 -3
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +12 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +0 -2
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/skippable-prefix.d.ts +3 -0
- package/dist/server/skippable-prefix.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +18 -19
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +11 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts +0 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +9 -16
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/dist/server/static-generator.d.ts.map +1 -1
- package/dist/server/status-code-resolver.d.ts +8 -1
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +28 -43
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +12 -7
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/utils/element-type.d.ts +40 -0
- package/dist/server/utils/element-type.d.ts.map +1 -0
- package/dist/shared/rsc-media-type.d.ts +40 -0
- package/dist/shared/rsc-media-type.d.ts.map +1 -0
- package/dist/shared/segment-info.d.ts +7 -0
- package/dist/shared/segment-info.d.ts.map +1 -1
- package/docs/api/30-api-server.mdx +1 -1
- package/docs/api/33-api-search-params.mdx +38 -16
- package/docs/api/35-api-typescript.mdx +3 -3
- package/docs/api/36-cli.mdx +34 -7
- package/docs/learn/00-introduction.mdx +1 -1
- package/docs/learn/02-pages-and-layouts.mdx +1 -1
- package/docs/learn/05-typed-params.mdx +8 -8
- package/docs/learn/07-typed-routes.mdx +12 -7
- package/docs/learn/11-error-handling.mdx +16 -0
- package/docs/more/01-advanced-routing.mdx +1 -1
- package/docs/more/03-coming-from-nextjs.mdx +2 -2
- package/docs/more/50-ai-agent-instructions.mdx +6 -4
- package/package.json +8 -5
- package/src/adapters/cloudflare.ts +4 -1
- package/src/adapters/compress-module.ts +79 -1
- package/src/cli-check.ts +458 -0
- package/src/cli.ts +59 -24
- package/src/client/browser-entry/action-dispatch.ts +2 -1
- package/src/client/browser-entry/index.ts +0 -5
- package/src/client/error-boundary.tsx +65 -1
- package/src/client/index.ts +14 -3
- package/src/client/link.tsx +65 -64
- package/src/client/navigation-commit.ts +27 -4
- package/src/client/params-context.ts +4 -4
- package/src/client/router-pipeline.ts +4 -0
- package/src/client/router.ts +1 -0
- package/src/client/rsc-fetch.ts +14 -4
- package/src/client/segment-cache.ts +26 -5
- package/src/client/use-query-states.ts +102 -39
- package/src/cookies/define-cookie.ts +6 -1
- package/src/index.ts +20 -3
- package/src/plugin-context.ts +26 -0
- package/src/plugins/routing.ts +84 -146
- package/src/plugins/shims.ts +0 -1
- package/src/plugins/static-build.ts +78 -24
- package/src/routing/codegen-shared.ts +3 -79
- package/src/routing/codegen-types.ts +10 -31
- package/src/routing/codegen-write.ts +139 -0
- package/src/routing/codegen.ts +56 -182
- package/src/routing/convention-lint.ts +139 -40
- package/src/routing/export-detect.ts +151 -7
- package/src/routing/link-codegen.ts +32 -65
- package/src/routing/manifest-codegen.ts +1 -59
- package/src/routing/scanner.ts +3 -3
- package/src/routing/segment-keys.ts +37 -0
- package/src/routing/types.ts +0 -6
- package/src/schema-bridge.ts +180 -58
- package/src/search-params/define.ts +102 -37
- package/src/search-params/index.ts +0 -1
- package/src/search-params/parse-total.ts +78 -0
- package/src/search-params/wrappers.ts +60 -11
- package/src/server/access-gate.tsx +61 -68
- package/src/server/action-handler.ts +1 -4
- package/src/server/als-registry.ts +16 -0
- package/src/server/chain-url-parts.ts +2 -3
- package/src/server/compress.ts +9 -1
- package/src/server/deny-boundary.ts +269 -41
- package/src/server/deny-renderer.ts +32 -21
- package/src/server/error-boundary-wrapper.ts +166 -79
- package/src/server/index.ts +1 -3
- package/src/server/internal.ts +2 -2
- package/src/server/metadata-collector.ts +115 -0
- package/src/server/param-coercion.ts +13 -61
- package/src/server/pipeline-helpers.ts +2 -2
- package/src/server/pipeline-outcome.ts +2 -1
- package/src/server/pipeline-phases.ts +9 -9
- package/src/server/primitives.ts +25 -0
- package/src/server/route-element-builder.ts +169 -194
- package/src/server/rsc-cache-key-guard.ts +2 -8
- package/src/server/rsc-entry/deny-fallback.ts +3 -2
- package/src/server/rsc-entry/error-renderer.ts +31 -11
- package/src/server/rsc-entry/helpers.ts +3 -12
- package/src/server/rsc-entry/index.ts +0 -5
- package/src/server/rsc-entry/render-route.ts +14 -11
- package/src/server/rsc-entry/revalidate-renderer.ts +2 -1
- package/src/server/rsc-entry/rsc-payload.ts +104 -20
- package/src/server/rsc-entry/rsc-stream.ts +25 -2
- package/src/server/rsc-entry/ssr-renderer.ts +28 -16
- package/src/server/skippable-prefix.ts +21 -39
- package/src/server/slot-resolver.ts +69 -189
- package/src/server/ssr-bridge-types.ts +12 -0
- package/src/server/ssr-entry.ts +8 -6
- package/src/server/state-tree-diff.ts +11 -62
- package/src/server/static-generator.ts +3 -2
- package/src/server/status-code-resolver.ts +28 -11
- package/src/server/tree-builder.ts +35 -228
- package/src/server/types.ts +12 -7
- package/src/server/utils/element-type.ts +72 -0
- package/src/shared/rsc-media-type.ts +43 -0
- package/src/shared/segment-info.ts +7 -0
- package/dist/_chunks/cache-api-Cd0VZ_Pd.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-CKgHC2MB.js.map +0 -1
- package/dist/_chunks/error-boundary-DpYRI_I1.js.map +0 -1
- package/dist/_chunks/logger-N7e5auP0.js.map +0 -1
- package/dist/_chunks/navigation-root-B29qg0_T.js.map +0 -1
- package/dist/_chunks/param-value-C8TNYchQ.js.map +0 -1
- package/dist/_chunks/registry-DbJPKoBp.js +0 -20
- package/dist/_chunks/registry-DbJPKoBp.js.map +0 -1
- package/dist/_chunks/schema-bridge-DT_Tn0Xf.js +0 -119
- package/dist/_chunks/schema-bridge-DT_Tn0Xf.js.map +0 -1
- package/dist/_chunks/segment-context-ZDnXDkbz.js +0 -34
- package/dist/_chunks/segment-context-ZDnXDkbz.js.map +0 -1
- package/dist/_chunks/use-query-states-DFvWd-EA.js.map +0 -1
- package/dist/_chunks/use-segment-params-ClyUNq4d.js +0 -128
- package/dist/_chunks/use-segment-params-ClyUNq4d.js.map +0 -1
- package/dist/_chunks/walkers-BhhwI9TD.js +0 -936
- package/dist/_chunks/walkers-BhhwI9TD.js.map +0 -1
- package/dist/search-params/registry.d.ts +0 -20
- package/dist/search-params/registry.d.ts.map +0 -1
- package/dist/segment-params/define.d.ts +0 -83
- package/dist/segment-params/define.d.ts.map +0 -1
- package/dist/segment-params/index.d.ts +0 -3
- package/dist/segment-params/index.d.ts.map +0 -1
- package/dist/segment-params/index.js +0 -70
- package/dist/segment-params/index.js.map +0 -1
- package/src/search-params/registry.ts +0 -31
- package/src/segment-params/define.ts +0 -226
- package/src/segment-params/index.ts +0 -9
|
@@ -1,106 +1,193 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Error boundary wrapper — wraps a React element in error boundaries
|
|
2
|
+
* Error boundary wrapper — wraps a React element in the error boundaries
|
|
3
|
+
* declared by a route segment (`{status}.tsx`, `4xx.tsx`/`5xx.tsx`, `error.tsx`).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* This is the single implementation of that walk. It is driven by both
|
|
6
|
+
* construction sites:
|
|
7
|
+
*
|
|
8
|
+
* - `route-element-builder.ts` / `slot-resolver.ts` — the request-time
|
|
9
|
+
* renderer, walking the runtime manifest (`SegmentNode<ManifestFile>`).
|
|
10
|
+
* - `tree-builder.ts` — the build-time tree walker
|
|
11
|
+
* (`SegmentNode<RouteFile>`), with its loader and `createElement` injected.
|
|
12
|
+
*
|
|
13
|
+
* `errorBoundaryComponent` is injected rather than imported: `TimberErrorBoundary`
|
|
14
|
+
* is a `'use client'` module, and importing it here would pull client code into
|
|
15
|
+
* every consumer — including `tree-builder.ts`, which the server barrel reaches.
|
|
16
|
+
* `tests/use-client-pipeline.test.ts` walks the import graph to enforce this.
|
|
17
|
+
*
|
|
18
|
+
* See design/10-error-handling.md §"Status-Code File Variants".
|
|
6
19
|
*/
|
|
7
20
|
|
|
8
|
-
import {
|
|
9
|
-
import type {
|
|
10
|
-
import {
|
|
21
|
+
import type { ReactNode } from 'react';
|
|
22
|
+
import type { SegmentNode } from '../routing/types.js';
|
|
23
|
+
import { isValidElementType, type LoadedComponent } from './utils/element-type.js';
|
|
11
24
|
import { isMdxFilePath } from './utils/mdx-file.js';
|
|
12
25
|
|
|
26
|
+
/** The minimum a route file must carry for the walk: a path to classify as MDX. */
|
|
27
|
+
interface BoundaryFile {
|
|
28
|
+
filePath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
/**
|
|
14
|
-
*
|
|
32
|
+
* Framework-injected error boundary wrapper.
|
|
33
|
+
* Wraps content with status-code error boundary handling.
|
|
15
34
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
35
|
+
* Field types must agree with `TimberErrorBoundaryProps` in
|
|
36
|
+
* `client/error-boundary.tsx`. The two are kept structurally compatible by
|
|
37
|
+
* convention rather than by direct type import — this is the server-side
|
|
38
|
+
* construction site and may not import types from a 'use client' module.
|
|
39
|
+
*/
|
|
40
|
+
export interface ErrorBoundaryProps {
|
|
41
|
+
/** The component to render when an error is caught (TSX status files). */
|
|
42
|
+
fallbackComponent?: LoadedComponent;
|
|
43
|
+
/** Pre-rendered fallback element (MDX status files — see TIM-503). */
|
|
44
|
+
fallbackElement?: ReactNode;
|
|
45
|
+
/** Status code filter: 400 = any 4xx, 500 = any 5xx, specific number = exact match. */
|
|
46
|
+
status?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Segment key of the segment that declared this boundary. An in-tree deny
|
|
49
|
+
* catch site that matched a file it may not render in place re-throws with
|
|
50
|
+
* that file's owning key on the signal; boundaries whose key differs
|
|
51
|
+
* decline, so the deny page lands at its owner rather than at the first
|
|
52
|
+
* boundary the throw happens to pass. Unset for slot boundaries and for
|
|
53
|
+
* construction sites with no deny chains — an unkeyed boundary declines
|
|
54
|
+
* every owner-addressed deny. See TIM-1356.
|
|
55
|
+
*/
|
|
56
|
+
segmentKey?: string;
|
|
57
|
+
children: ReactNode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How to load a route file's module. Only `default` is read. */
|
|
61
|
+
type BoundaryModuleLoader<TFile> = (
|
|
62
|
+
file: TFile
|
|
63
|
+
) => { default?: unknown } | Promise<{ default?: unknown }>;
|
|
64
|
+
|
|
65
|
+
/** Everything the walk needs from its host environment. */
|
|
66
|
+
export interface ErrorBoundaryWrapConfig<TFile, TElement> {
|
|
67
|
+
/** `React.createElement` or an equivalent element factory. */
|
|
68
|
+
createElement: (type: unknown, props: object) => TElement;
|
|
69
|
+
/** Loads a route file's module. Failures skip the boundary — see below. */
|
|
70
|
+
loadModule: BoundaryModuleLoader<TFile>;
|
|
71
|
+
/**
|
|
72
|
+
* The error boundary component to instantiate (`TimberErrorBoundary`, or a
|
|
73
|
+
* host-specific stand-in). Injected — see the module docstring.
|
|
74
|
+
*/
|
|
75
|
+
errorBoundaryComponent: unknown;
|
|
76
|
+
/** When true, skip 4xx error boundaries — deny catching handles them in-tree. See TIM-666. */
|
|
77
|
+
skipDenyBoundaries?: boolean;
|
|
78
|
+
/** Segment key stamped on every boundary this walk creates. See `ErrorBoundaryProps`. */
|
|
79
|
+
segmentKey?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Load a status/error file's component, or `null` if the boundary should be skipped.
|
|
84
|
+
*
|
|
85
|
+
* Two ways to get `null`, both deliberate:
|
|
86
|
+
* - the module fails to load (syntax error in the error page) — building an
|
|
87
|
+
* error boundary must not itself throw, so the boundary is skipped (TIM-584);
|
|
88
|
+
* - the default export is not a valid React component type, which would
|
|
89
|
+
* otherwise crash inside `createElement`.
|
|
90
|
+
*/
|
|
91
|
+
async function loadBoundaryComponent<TFile>(
|
|
92
|
+
file: TFile,
|
|
93
|
+
loadModule: BoundaryModuleLoader<TFile>
|
|
94
|
+
): Promise<LoadedComponent | null> {
|
|
95
|
+
let mod: { default?: unknown };
|
|
96
|
+
try {
|
|
97
|
+
mod = await loadModule(file);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return isValidElementType(mod?.default) ? mod.default : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Wrap `element` in one error boundary rendering `Component` on failure.
|
|
20
106
|
*
|
|
21
107
|
* MDX status files are server components and cannot be passed as function
|
|
22
|
-
* props to TimberErrorBoundary (a 'use client' component).
|
|
23
|
-
*
|
|
108
|
+
* props to `TimberErrorBoundary` (a 'use client' component). They are
|
|
109
|
+
* pre-rendered as elements and passed as `fallbackElement` instead. See TIM-503.
|
|
110
|
+
*
|
|
111
|
+
* @param status boundary status filter — omitted entirely for `error.tsx`
|
|
112
|
+
* @param fallbackProps props handed to an MDX fallback when it is pre-rendered
|
|
24
113
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
114
|
+
function wrapInBoundary<TFile extends BoundaryFile, TElement extends ReactNode>(
|
|
115
|
+
element: TElement,
|
|
116
|
+
Component: LoadedComponent,
|
|
117
|
+
file: TFile,
|
|
118
|
+
status: number | undefined,
|
|
119
|
+
fallbackProps: object,
|
|
120
|
+
config: ErrorBoundaryWrapConfig<TFile, TElement>
|
|
121
|
+
): TElement {
|
|
122
|
+
const props: ErrorBoundaryProps = isMdxFilePath(file.filePath)
|
|
123
|
+
? { fallbackElement: config.createElement(Component, fallbackProps), children: element }
|
|
124
|
+
: { fallbackComponent: Component, children: element };
|
|
125
|
+
if (status !== undefined) props.status = status;
|
|
126
|
+
if (config.segmentKey !== undefined) props.segmentKey = config.segmentKey;
|
|
127
|
+
return config.createElement(config.errorBoundaryComponent, props);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Wrap an element in the error boundaries declared by a route segment.
|
|
132
|
+
*
|
|
133
|
+
* Wrapping is applied inside-out — the last wrap produces the outermost
|
|
134
|
+
* boundary — so the processing order below is innermost to outermost:
|
|
135
|
+
*
|
|
136
|
+
* 1. Specific status files (e.g. `404.tsx`, `503.tsx`) — highest priority at runtime
|
|
137
|
+
* 2. Category catch-alls (`4xx.tsx`, `5xx.tsx`)
|
|
138
|
+
* 3. `error.tsx` — catches anything not matched by a status file
|
|
139
|
+
*
|
|
140
|
+
* The order is load-bearing: it decides which boundary catches a given throw.
|
|
141
|
+
*
|
|
142
|
+
* See design/10-error-handling.md §"Format-Aware Fallback Chains".
|
|
143
|
+
*/
|
|
144
|
+
export async function wrapSegmentWithErrorBoundaries<
|
|
145
|
+
TFile extends BoundaryFile,
|
|
146
|
+
TElement extends ReactNode,
|
|
147
|
+
>(
|
|
148
|
+
segment: SegmentNode<TFile>,
|
|
149
|
+
element: TElement,
|
|
150
|
+
config: ErrorBoundaryWrapConfig<TFile, TElement>
|
|
151
|
+
): Promise<TElement> {
|
|
152
|
+
const { loadModule, skipDenyBoundaries = false } = config;
|
|
153
|
+
|
|
33
154
|
if (segment.statusFiles) {
|
|
155
|
+
// Specific status files (innermost — highest priority at runtime)
|
|
34
156
|
for (const [key, file] of Object.entries(segment.statusFiles)) {
|
|
35
|
-
if (key
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (isMdxFilePath(file.filePath)) {
|
|
46
|
-
// MDX: pre-render as element (server component can't be a function prop)
|
|
47
|
-
element = h(TimberErrorBoundary, {
|
|
48
|
-
fallbackElement: h(mod.default as never, { status }),
|
|
49
|
-
status,
|
|
50
|
-
children: element,
|
|
51
|
-
});
|
|
52
|
-
} else {
|
|
53
|
-
element = h(TimberErrorBoundary, {
|
|
54
|
-
fallbackComponent: mod.default,
|
|
55
|
-
status,
|
|
56
|
-
children: element,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
157
|
+
if (key === '4xx' || key === '5xx') continue;
|
|
158
|
+
const status = parseInt(key, 10);
|
|
159
|
+
if (isNaN(status)) continue;
|
|
160
|
+
// Skip 4xx boundaries when deny catching is active — the deny
|
|
161
|
+
// page is rendered by AccessGate/PageDenyBoundary instead.
|
|
162
|
+
if (skipDenyBoundaries && status >= 400 && status <= 499) continue;
|
|
163
|
+
|
|
164
|
+
const Component = await loadBoundaryComponent(file, loadModule);
|
|
165
|
+
if (Component) {
|
|
166
|
+
element = wrapInBoundary(element, Component, file, status, { status }, config);
|
|
61
167
|
}
|
|
62
168
|
}
|
|
63
169
|
|
|
64
170
|
// Category catch-alls (4xx.tsx, 5xx.tsx)
|
|
65
171
|
for (const [key, file] of Object.entries(segment.statusFiles)) {
|
|
172
|
+
if (key !== '4xx' && key !== '5xx') continue;
|
|
66
173
|
if (skipDenyBoundaries && key === '4xx') continue;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
element = h(TimberErrorBoundary, {
|
|
73
|
-
fallbackElement: h(mod.default as never, {}),
|
|
74
|
-
status: categoryStatus,
|
|
75
|
-
children: element,
|
|
76
|
-
});
|
|
77
|
-
} else {
|
|
78
|
-
element = h(TimberErrorBoundary, {
|
|
79
|
-
fallbackComponent: mod.default,
|
|
80
|
-
status: categoryStatus,
|
|
81
|
-
children: element,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
}
|
|
174
|
+
|
|
175
|
+
const Component = await loadBoundaryComponent(file, loadModule);
|
|
176
|
+
if (Component) {
|
|
177
|
+
const categoryStatus = key === '4xx' ? 400 : 500;
|
|
178
|
+
element = wrapInBoundary(element, Component, file, categoryStatus, {}, config);
|
|
85
179
|
}
|
|
86
180
|
}
|
|
87
181
|
}
|
|
88
182
|
|
|
89
|
-
// error.tsx (outermost — catches anything not matched by status files)
|
|
183
|
+
// error.tsx (outermost — catches anything not matched by status files).
|
|
184
|
+
// error.tsx/error.mdx receives { error, digest, reset } props from the
|
|
185
|
+
// boundary at catch time; MDX error files are pre-rendered without them
|
|
186
|
+
// (they're static content).
|
|
90
187
|
if (segment.error) {
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
element = h(TimberErrorBoundary, {
|
|
95
|
-
fallbackElement: h(mod.default as never, {}),
|
|
96
|
-
children: element,
|
|
97
|
-
});
|
|
98
|
-
} else {
|
|
99
|
-
element = h(TimberErrorBoundary, {
|
|
100
|
-
fallbackComponent: mod.default,
|
|
101
|
-
children: element,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
188
|
+
const Component = await loadBoundaryComponent(segment.error, loadModule);
|
|
189
|
+
if (Component) {
|
|
190
|
+
element = wrapInBoundary(element, Component, segment.error, undefined, {}, config);
|
|
104
191
|
}
|
|
105
192
|
}
|
|
106
193
|
|
package/src/server/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type { RouteContext } from './types';
|
|
|
10
10
|
export type { Metadata, MetadataRoute, MetadataHandler, MetadataResult } from './types';
|
|
11
11
|
|
|
12
12
|
// Request Context — ALS-backed accessors (all sync)
|
|
13
|
-
// Prefer defineSearchParams().get() and
|
|
13
|
+
// Prefer defineSearchParams().get() and getSegmentParams(SEGMENT_PATH) for typed access.
|
|
14
14
|
// Design doc: design/04-authorization.md §"AccessContext does not include cookies or headers"
|
|
15
15
|
export { getHeaders, getSegmentParams } from './request-context';
|
|
16
16
|
export type { ReadonlyHeaders } from './request-context';
|
|
@@ -65,5 +65,3 @@ export { revalidatePath, revalidateTag } from './actions';
|
|
|
65
65
|
export { getTraceId, getSpanId, withSpan, addSpanEvent } from './tracing';
|
|
66
66
|
|
|
67
67
|
// Segment params types — re-exported for convenience.
|
|
68
|
-
// defineSegmentParams itself lives at @timber-js/app/segment-params.
|
|
69
|
-
export type { ParamsDefinition, InferParamField, ParamField } from '../segment-params/define.js';
|
package/src/server/internal.ts
CHANGED
|
@@ -66,12 +66,12 @@ export type {
|
|
|
66
66
|
TreeBuilderConfig,
|
|
67
67
|
TreeBuildResult,
|
|
68
68
|
LoadedModule,
|
|
69
|
-
LoadedComponent,
|
|
70
69
|
ModuleLoader,
|
|
71
70
|
AccessGateProps,
|
|
72
71
|
SlotAccessGateProps,
|
|
73
|
-
ErrorBoundaryProps,
|
|
74
72
|
} from './tree-builder.js';
|
|
73
|
+
export type { LoadedComponent } from './utils/element-type.js';
|
|
74
|
+
export type { ErrorBoundaryProps } from './error-boundary-wrapper.js';
|
|
75
75
|
|
|
76
76
|
// ── Access Gates ─────────────────────────────────────────────────────────
|
|
77
77
|
export { AccessGate, SlotAccessGate } from './access-gate.js';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-time metadata resolution components.
|
|
3
|
+
*
|
|
4
|
+
* MetadataCollector runs below AccessGate for each segment, collecting
|
|
5
|
+
* metadata entries into a request-scoped array. MetadataHead runs at
|
|
6
|
+
* the page level, merging all collected entries and rendering Float-
|
|
7
|
+
* hoisted head elements.
|
|
8
|
+
*
|
|
9
|
+
* This structural approach replaces the eager pre-render metadata
|
|
10
|
+
* resolution: a denied segment's AccessGate prevents MetadataCollector
|
|
11
|
+
* from rendering, so metadata() never executes for denied segments.
|
|
12
|
+
* No `firstDeniedIndex` bookkeeping needed.
|
|
13
|
+
*
|
|
14
|
+
* See TIM-1363, design/16-metadata.md.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createElement, Fragment } from 'react';
|
|
18
|
+
import type { ReactNode } from 'react';
|
|
19
|
+
|
|
20
|
+
import { requestContextAls } from './als-registry.js';
|
|
21
|
+
import type { Metadata } from './types.js';
|
|
22
|
+
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
23
|
+
import { withSpan } from './tracing.js';
|
|
24
|
+
import { resolveMetadata, renderMetadataToElements, headElementsToReact } from './metadata.js';
|
|
25
|
+
import type { SegmentMetadataEntry } from './metadata.js';
|
|
26
|
+
import { collectMetadataRouteHeadElements } from './metadata-routes.js';
|
|
27
|
+
|
|
28
|
+
// ─── Request-scoped metadata entry store ────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function getMetadataEntries(): SegmentMetadataEntry[] {
|
|
31
|
+
const store = requestContextAls.getStore();
|
|
32
|
+
if (!store) return [];
|
|
33
|
+
store._metadataEntries ??= [];
|
|
34
|
+
return store._metadataEntries;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── MetadataCollector ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface MetadataCollectorProps {
|
|
40
|
+
metadataExport: Metadata | (() => Promise<Metadata>);
|
|
41
|
+
segmentName: string;
|
|
42
|
+
isPage: boolean;
|
|
43
|
+
onSettled?: () => void;
|
|
44
|
+
children: ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Async server component that collects one segment's metadata during
|
|
49
|
+
* render. Placed between AccessGate and the layout/page. When
|
|
50
|
+
* AccessGate denies, this component never renders, so dynamic
|
|
51
|
+
* metadata() never executes for denied segments (TIM-1027).
|
|
52
|
+
*
|
|
53
|
+
* Static metadata objects are pushed synchronously (no side effects).
|
|
54
|
+
* Dynamic metadata functions are awaited with OTEL tracing.
|
|
55
|
+
*/
|
|
56
|
+
export async function MetadataCollector(props: MetadataCollectorProps): Promise<ReactNode> {
|
|
57
|
+
const { metadataExport, segmentName, isPage, onSettled, children } = props;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
let metadata: Metadata | null = null;
|
|
61
|
+
if (typeof metadataExport === 'function') {
|
|
62
|
+
metadata =
|
|
63
|
+
(await withSpan('timber.metadata', { 'timber.segment': segmentName }, () =>
|
|
64
|
+
(metadataExport as () => Promise<Metadata>)()
|
|
65
|
+
)) ?? null;
|
|
66
|
+
} else {
|
|
67
|
+
metadata = metadataExport;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (metadata) {
|
|
71
|
+
getMetadataEntries().push({ metadata, isPage });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return children;
|
|
75
|
+
} finally {
|
|
76
|
+
onSettled?.();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── MetadataHead ───────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export interface MetadataHeadProps {
|
|
83
|
+
segments: ManifestSegmentNode[];
|
|
84
|
+
requestUrl: string;
|
|
85
|
+
metadataRouteHashes?: Record<string, string>;
|
|
86
|
+
children: ReactNode;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Server component at the page level. Reads all metadata entries
|
|
91
|
+
* collected by MetadataCollector components above it, merges them,
|
|
92
|
+
* auto-links metadata routes, and renders Float-hoisted head elements.
|
|
93
|
+
*
|
|
94
|
+
* Placed as a sibling of the page element inside a Fragment so the
|
|
95
|
+
* Float elements are always at the page segment level (re-rendered
|
|
96
|
+
* on every SPA navigation, never skipped by segment tree diffing).
|
|
97
|
+
*/
|
|
98
|
+
export function MetadataHead(props: MetadataHeadProps): ReactNode {
|
|
99
|
+
const { segments, requestUrl, metadataRouteHashes, children } = props;
|
|
100
|
+
const entries = getMetadataEntries();
|
|
101
|
+
const resolved = resolveMetadata(entries);
|
|
102
|
+
const headElements = renderMetadataToElements(resolved);
|
|
103
|
+
|
|
104
|
+
const autoLinked = collectMetadataRouteHeadElements(
|
|
105
|
+
segments,
|
|
106
|
+
Infinity,
|
|
107
|
+
resolved,
|
|
108
|
+
new URL(requestUrl),
|
|
109
|
+
metadataRouteHashes
|
|
110
|
+
);
|
|
111
|
+
headElements.push(...autoLinked);
|
|
112
|
+
|
|
113
|
+
const metadataElement = headElementsToReact(headElements);
|
|
114
|
+
return createElement(Fragment, null, metadataElement, children);
|
|
115
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Segment param coercion — runs the
|
|
3
|
-
*
|
|
2
|
+
* Segment param coercion — runs the app/schema.ts codecs over the raw
|
|
3
|
+
* matcher output before middleware and rendering.
|
|
4
4
|
*
|
|
5
5
|
* Lifted out of `pipeline-phases.ts` (TIM-853) so the coercer can be
|
|
6
6
|
* imported directly by other entry points (the action-dispatch wrapper,
|
|
@@ -18,7 +18,6 @@ import { effectiveUrlSegment } from '../routing/segment-classify.js';
|
|
|
18
18
|
import { toBracketKey } from '../params/resolve-schema.js';
|
|
19
19
|
import type { RouteMatch } from './pipeline.js';
|
|
20
20
|
import { normalizeParamValue } from '../shared/param-value.js';
|
|
21
|
-
import { loadModule } from './safe-load.js';
|
|
22
21
|
import { ParamCoercionError } from './route-element-builder.js';
|
|
23
22
|
import { isDebug } from './debug.js';
|
|
24
23
|
|
|
@@ -112,16 +111,24 @@ export function coerceSlotParams(
|
|
|
112
111
|
/**
|
|
113
112
|
* Run segment param coercion on the matched route's segments.
|
|
114
113
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
114
|
+
* Codecs come from `app/schema.ts` and nowhere else (TIM-1342 completed
|
|
115
|
+
* design/41-global-params.md migration step 9). A route with no schema, or
|
|
116
|
+
* a param the schema does not declare, keeps its raw string — which is the
|
|
117
|
+
* honest type for an uninterpreted URL segment.
|
|
118
118
|
*
|
|
119
119
|
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
120
120
|
*
|
|
121
121
|
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
122
122
|
* See design/07-routing.md §"Where Coercion Runs"
|
|
123
123
|
* See design/41-global-params.md §"Pipeline Integration"
|
|
124
|
+
*
|
|
125
|
+
* Stays `async` with nothing to await: it is injected as a
|
|
126
|
+
* `CoerceSegmentParamsFn` into the revalidation renderer and the action
|
|
127
|
+
* middleware runner, both of which await it. Narrowing the return type to
|
|
128
|
+
* `void` would break those contracts and remove a microtask boundary the
|
|
129
|
+
* pipeline's ordering has always had, for no gain.
|
|
124
130
|
*/
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- see above
|
|
125
132
|
export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
126
133
|
const globalCodecs = _globalCodecs;
|
|
127
134
|
// Unconditionally install a null-prototype target so the invariant
|
|
@@ -178,60 +185,5 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
|
178
185
|
// inside would report the second as the first.
|
|
179
186
|
mergeTarget[key] = normalizeParamValue(parsed, key);
|
|
180
187
|
}
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Legacy path: per-segment params.ts coercion
|
|
185
|
-
for (const segment of match.segments) {
|
|
186
|
-
// Only process segments that have a params.ts convention file
|
|
187
|
-
if (!segment.params) continue;
|
|
188
|
-
|
|
189
|
-
let mod: Record<string, unknown>;
|
|
190
|
-
try {
|
|
191
|
-
mod = await loadModule(segment.params);
|
|
192
|
-
} catch (err) {
|
|
193
|
-
const message = `Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`;
|
|
194
|
-
if (isDebug()) {
|
|
195
|
-
console.warn(
|
|
196
|
-
`[timber] Param coercion error: ${message}\n` +
|
|
197
|
-
` Segment: ${segment.segmentName}\n` +
|
|
198
|
-
` Params file: ${segment.params}`
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
throw new ParamCoercionError(message);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const segmentParamsDef = mod.segmentParams as
|
|
205
|
-
| { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
|
|
206
|
-
| undefined;
|
|
207
|
-
|
|
208
|
-
if (!segmentParamsDef || typeof segmentParamsDef.parse !== 'function') continue;
|
|
209
|
-
|
|
210
|
-
let coerced: Record<string, unknown>;
|
|
211
|
-
try {
|
|
212
|
-
coerced = segmentParamsDef.parse(match.segmentParams) as Record<string, unknown>;
|
|
213
|
-
} catch (err) {
|
|
214
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
215
|
-
if (isDebug()) {
|
|
216
|
-
const rawKeys = Object.keys(match.segmentParams).join(', ');
|
|
217
|
-
console.warn(
|
|
218
|
-
`[timber] Param codec rejected values for segment "${segment.segmentName}"\n` +
|
|
219
|
-
` Error: ${message}\n` +
|
|
220
|
-
` Available raw params: { ${rawKeys} }\n` +
|
|
221
|
-
` Params file: ${segment.params}\n` +
|
|
222
|
-
` Hint: this usually means a codec threw for the raw URL value.\n` +
|
|
223
|
-
` Check that the regex/schema accepts the actual path segment string.`
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
throw new ParamCoercionError(message);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Normalize outside the catch, for the same reason as above. `__proto__`
|
|
230
|
-
// is skipped here as well as inside `normalizeParamValue`, because this
|
|
231
|
-
// loop assigns into `mergeTarget` directly.
|
|
232
|
-
for (const key of Object.keys(coerced)) {
|
|
233
|
-
if (key === '__proto__') continue;
|
|
234
|
-
mergeTarget[key] = normalizeParamValue(coerced[key], key);
|
|
235
|
-
}
|
|
236
188
|
}
|
|
237
189
|
}
|
|
@@ -15,6 +15,7 @@ import { callOnRequestError } from './instrumentation.js';
|
|
|
15
15
|
import { getTraceId } from './tracing.js';
|
|
16
16
|
import { RedirectSignal } from './primitives.js';
|
|
17
17
|
import type { ProxyConfig } from './pipeline.js';
|
|
18
|
+
import { isRscRequest } from '../shared/rsc-media-type.js';
|
|
18
19
|
|
|
19
20
|
// ─── Prototype-Pollution-Safe Sanitizer ────────────────────────────────────
|
|
20
21
|
|
|
@@ -125,8 +126,7 @@ export function buildRedirectResponse(
|
|
|
125
126
|
req: Request,
|
|
126
127
|
headers: Headers
|
|
127
128
|
): Response {
|
|
128
|
-
|
|
129
|
-
if (isRsc) {
|
|
129
|
+
if (isRscRequest(req)) {
|
|
130
130
|
headers.set('X-Timber-Redirect', signal.location);
|
|
131
131
|
return new Response(null, { status: 204, headers });
|
|
132
132
|
}
|
|
@@ -27,6 +27,7 @@ import { markResponseFlushed } from './request-context.js';
|
|
|
27
27
|
import { RedirectSignal, DenySignal } from './primitives.js';
|
|
28
28
|
import { isDebug } from './debug.js';
|
|
29
29
|
import { rscErrorEnvelope } from '../shared/rsc-error-envelope.js';
|
|
30
|
+
import { isRscRequest } from '../shared/rsc-media-type.js';
|
|
30
31
|
import type { PipelineConfig, RouteMatch } from './pipeline.js';
|
|
31
32
|
|
|
32
33
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
@@ -141,7 +142,7 @@ export async function outcomeToResponse(
|
|
|
141
142
|
// Signal the error via X-Timber-Error so the client hard-navigates
|
|
142
143
|
// to the server-rendered error page instead of feeding HTML to the
|
|
143
144
|
// Flight decoder (which crashes with "enqueueModel is not a function").
|
|
144
|
-
const isRsc = (ctx.req
|
|
145
|
+
const isRsc = isRscRequest(ctx.req);
|
|
145
146
|
|
|
146
147
|
if (outcome.phase === 'proxy') {
|
|
147
148
|
logProxyError({ error: outcome.error });
|
|
@@ -39,6 +39,7 @@ import type { InterceptionContext, PipelineConfig, RouteMatch } from './pipeline
|
|
|
39
39
|
import type { MetadataHandler, MetadataRoute, MiddlewareContext } from './types.js';
|
|
40
40
|
import { swallow } from './logger.js';
|
|
41
41
|
import { isDebug } from './debug.js';
|
|
42
|
+
import { isRscRequest } from '../shared/rsc-media-type.js';
|
|
42
43
|
|
|
43
44
|
interface RenderContext {
|
|
44
45
|
canonicalPathname: string;
|
|
@@ -367,8 +368,8 @@ export async function handleRequest(
|
|
|
367
368
|
// deployment ID matches the current build. On mismatch, signal the
|
|
368
369
|
// client to do a full page reload instead of returning an RSC payload
|
|
369
370
|
// that references mismatched module IDs.
|
|
370
|
-
const
|
|
371
|
-
if (
|
|
371
|
+
const isRsc = isRscRequest(req);
|
|
372
|
+
if (isRsc) {
|
|
372
373
|
const skewCheck = checkVersionSkew(req);
|
|
373
374
|
if (!skewCheck.ok) {
|
|
374
375
|
const reloadHeaders = new Headers();
|
|
@@ -386,7 +387,7 @@ export async function handleRequest(
|
|
|
386
387
|
// to prevent spoofing from plain HTML navigations or raw HTTP clients.
|
|
387
388
|
// The header value must be a valid relative pathname — reject schemes,
|
|
388
389
|
// authority, and control characters. Canonicalize before matching.
|
|
389
|
-
if (
|
|
390
|
+
if (isRsc && config.interceptionRewrites?.length) {
|
|
390
391
|
const rawSourceUrl = req.headers.get('X-Timber-URL');
|
|
391
392
|
const validatedSourceUrl = rawSourceUrl
|
|
392
393
|
? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash)
|
|
@@ -511,7 +512,7 @@ export async function handleRequest(
|
|
|
511
512
|
| { route?: unknown; page?: unknown }
|
|
512
513
|
| undefined;
|
|
513
514
|
const isApiRoute = leafSegment?.route && !leafSegment?.page;
|
|
514
|
-
if (config.earlyHints && !
|
|
515
|
+
if (config.earlyHints && !isRsc && !isApiRoute) {
|
|
515
516
|
try {
|
|
516
517
|
await config.earlyHints(match, req, responseHeaders);
|
|
517
518
|
} catch (err) {
|
|
@@ -520,8 +521,8 @@ export async function handleRequest(
|
|
|
520
521
|
}
|
|
521
522
|
|
|
522
523
|
// Stage 2c: Param coercion (before middleware)
|
|
523
|
-
//
|
|
524
|
-
//
|
|
524
|
+
// Coerce raw string params through the app/schema.ts codecs.
|
|
525
|
+
// Coercion failure → 404
|
|
525
526
|
// (middleware never runs). See design/07-routing.md §"Where Coercion Runs"
|
|
526
527
|
//
|
|
527
528
|
// Snapshot raw params before coercion — slot resolution needs the
|
|
@@ -540,9 +541,8 @@ export async function handleRequest(
|
|
|
540
541
|
`[timber] Param coercion failed for ${method} ${canonicalPathname} — responding with 404\n` +
|
|
541
542
|
` Matched segments: ${segmentChain}\n` +
|
|
542
543
|
` Error: ${error.message}\n` +
|
|
543
|
-
` This usually means a
|
|
544
|
-
` Check
|
|
545
|
-
` that don't appear at every route depth (e.g. year, month, day).`
|
|
544
|
+
` This usually means a codec in app/schema.ts rejected the URL value.\n` +
|
|
545
|
+
` Check the codec for the bracket key of the segment named above.`
|
|
546
546
|
);
|
|
547
547
|
}
|
|
548
548
|
// For API routes (route.ts), return a bare 404 — not an HTML page.
|
package/src/server/primitives.ts
CHANGED
|
@@ -113,6 +113,31 @@ export class DenySignal extends Error {
|
|
|
113
113
|
readonly status: number;
|
|
114
114
|
readonly data: JsonSerializable | undefined;
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Segment key of the segment that owns the deny page matched for this
|
|
118
|
+
* signal — set by a framework catch site that matched an entry it may not
|
|
119
|
+
* render at its own position, and re-threw so the boundary at the owning
|
|
120
|
+
* segment renders it instead.
|
|
121
|
+
*
|
|
122
|
+
* Written only by the three in-tree catch sites (`PageDenyBoundary`,
|
|
123
|
+
* `TracedLayout`, `AccessGate`), never by user code. Two readers depend on
|
|
124
|
+
* it: `TimberErrorBoundary`, which declines any deny whose `ownerKey` is
|
|
125
|
+
* not its own `segmentKey`, and `buildRscPayloadResponse`, which keeps
|
|
126
|
+
* streaming instead of re-rendering when it is set.
|
|
127
|
+
*
|
|
128
|
+
* See design/04-authorization.md §"Where a Deny Page Renders", TIM-1356.
|
|
129
|
+
*/
|
|
130
|
+
ownerKey?: string;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* When true, the pipeline must respond with `rscErrorEnvelope` so the
|
|
134
|
+
* client hard-navigates instead of attempting to render a deny page
|
|
135
|
+
* from the RSC payload. Set by AccessGate when a skipped segment
|
|
136
|
+
* denies — rendering the deny page in-tree would appear inside the
|
|
137
|
+
* client's cached layout chrome (TIM-1074). See TIM-1363.
|
|
138
|
+
*/
|
|
139
|
+
_hardNavigate?: boolean;
|
|
140
|
+
|
|
116
141
|
constructor(status: number, data?: JsonSerializable) {
|
|
117
142
|
super(`Access denied with status ${status}`);
|
|
118
143
|
this.name = 'DenySignal';
|