@timber-js/app 0.2.0-alpha.146 → 0.2.0-alpha.148
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-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
- package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
- package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
- package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
- package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
- package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-DJDdyEae.js} +157 -313
- package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
- package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
- package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
- package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
- package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
- package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
- package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
- package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
- package/dist/_chunks/logger-CM0clugc.js.map +1 -0
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
- package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
- package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
- package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
- package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
- package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
- package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
- package/dist/_chunks/tree-match-D2l830j2.js +102 -0
- package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
- package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
- package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-DvWDj453.js} +154 -88
- package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +36 -14
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/compress-module.d.ts +5 -4
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts +1 -3
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +14 -134
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.d.ts +3 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +20 -235
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/invalidation-epoch.d.ts +43 -0
- package/dist/cache/invalidation-epoch.d.ts.map +1 -0
- package/dist/cache/redis-handler.d.ts +13 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +17 -9
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli-schema-sync.d.ts +21 -1
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -31
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/hmr.d.ts.map +1 -1
- package/dist/client/browser-entry/hydrate.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.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +13 -4
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/error-reconstituter.d.ts +8 -1
- package/dist/client/error-reconstituter.d.ts.map +1 -1
- package/dist/client/history.d.ts +10 -17
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +30 -33
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +9 -2
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +0 -11
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +0 -5
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +10 -5
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -4
- package/dist/cookies/index.js.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +1 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
- package/dist/dev-tools/hydration-overlay.d.ts +11 -0
- package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
- package/dist/dev-tools/index.d.ts +1 -2
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/fonts/ast-walk.d.ts +68 -0
- package/dist/fonts/ast-walk.d.ts.map +1 -0
- package/dist/fonts/ast.d.ts +23 -11
- package/dist/fonts/ast.d.ts.map +1 -1
- package/dist/fonts/bundle.d.ts +3 -1
- package/dist/fonts/bundle.d.ts.map +1 -1
- package/dist/fonts/dev-middleware.d.ts +1 -1
- package/dist/fonts/dev-middleware.d.ts.map +1 -1
- package/dist/fonts/fallbacks.d.ts +0 -7
- package/dist/fonts/fallbacks.d.ts.map +1 -1
- package/dist/fonts/google.d.ts +4 -2
- package/dist/fonts/google.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +3 -3
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/fonts/paths.d.ts +43 -0
- package/dist/fonts/paths.d.ts.map +1 -0
- package/dist/fonts/pipeline.d.ts +7 -21
- package/dist/fonts/pipeline.d.ts.map +1 -1
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts +1 -18
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +609 -406
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/params/resolve-schema.d.ts.map +1 -1
- package/dist/plugin-context.d.ts +28 -5
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +13 -5
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +1 -2
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +4 -0
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/file-cache.d.ts +8 -0
- package/dist/routing/file-cache.d.ts.map +1 -0
- package/dist/routing/index.js +2 -2
- package/dist/routing/interception.d.ts +0 -5
- package/dist/routing/interception.d.ts.map +1 -1
- package/dist/routing/manifest-codegen.d.ts +32 -0
- package/dist/routing/manifest-codegen.d.ts.map +1 -0
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +15 -0
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.js +7 -3
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/segment-params/define.d.ts.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/actions.d.ts +0 -14
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +0 -29
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/canonicalize.d.ts +10 -0
- package/dist/server/canonicalize.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +27 -2
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +8 -0
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.d.ts +1 -5
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +238 -349
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +0 -11
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/render-utils.d.ts +25 -0
- package/dist/server/render-utils.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +11 -25
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +2 -18
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/status-code-resolver.d.ts +6 -0
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/utils/mdx-file.d.ts +15 -0
- package/dist/server/utils/mdx-file.d.ts.map +1 -0
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +3 -3
- package/src/adapters/cloudflare-kv-cache.ts +56 -19
- package/src/adapters/compress-module.ts +27 -118
- package/src/adapters/nitro.ts +3 -41
- package/src/adapters/raw-imports.d.ts +5 -0
- package/src/cache/cache-api.ts +9 -1
- package/src/cache/index.ts +5 -2
- package/src/cache/invalidation-epoch.ts +93 -0
- package/src/cache/redis-handler.ts +33 -2
- package/src/cache/timber-cache.ts +109 -53
- package/src/cli-schema-sync.ts +123 -30
- package/src/cli.ts +33 -37
- package/src/client/browser-entry/hmr.ts +0 -11
- package/src/client/browser-entry/hydrate.ts +39 -5
- package/src/client/browser-entry/post-hydration.ts +6 -15
- package/src/client/browser-entry/router-init.ts +98 -3
- package/src/client/error-boundary.tsx +14 -0
- package/src/client/error-reconstituter.tsx +15 -1
- package/src/client/history.ts +10 -24
- package/src/client/navigation-api.ts +14 -9
- package/src/client/navigation-context.ts +1 -21
- package/src/client/navigation-root.tsx +0 -8
- package/src/client/router.ts +18 -8
- package/src/client/rsc-fetch.ts +7 -2
- package/src/client/use-search-params.ts +10 -5
- package/src/config-validation.ts +33 -25
- package/src/cookies/define-cookie.ts +2 -18
- package/src/dev-tools/dev-page-shell.ts +6 -2
- package/src/dev-tools/hydration-overlay.ts +158 -0
- package/src/dev-tools/index.ts +3 -16
- package/src/fonts/ast-walk.ts +235 -0
- package/src/fonts/ast.ts +91 -249
- package/src/fonts/bundle.ts +16 -9
- package/src/fonts/dev-middleware.ts +16 -5
- package/src/fonts/fallbacks.ts +0 -23
- package/src/fonts/google.ts +7 -3
- package/src/fonts/local.ts +7 -6
- package/src/fonts/paths.ts +58 -0
- package/src/fonts/pipeline.ts +27 -48
- package/src/fonts/transform.ts +27 -0
- package/src/fonts/virtual-modules.ts +1 -57
- package/src/index.ts +5 -11
- package/src/params/resolve-schema.ts +2 -34
- package/src/plugin-context.ts +95 -17
- package/src/plugins/cache.ts +64 -17
- package/src/plugins/dev-server.ts +61 -9
- package/src/plugins/fonts.ts +21 -6
- package/src/plugins/routing.ts +5 -348
- package/src/routing/codegen-types.ts +4 -0
- package/src/routing/codegen.ts +66 -8
- package/src/routing/convention-lint.ts +4 -3
- package/src/routing/file-cache.ts +23 -0
- package/src/routing/interception.ts +2 -21
- package/src/routing/manifest-codegen.ts +290 -0
- package/src/routing/scanner.ts +85 -80
- package/src/routing/schema-validation.ts +2 -2
- package/src/schema-bridge.ts +63 -2
- package/src/search-params/define.ts +34 -2
- package/src/search-params/wrappers.ts +21 -3
- package/src/segment-params/define.ts +2 -62
- package/src/server/access-gate.tsx +8 -0
- package/src/server/actions.ts +8 -31
- package/src/server/build-manifest.ts +0 -56
- package/src/server/canonicalize.ts +6 -3
- package/src/server/deny-boundary.ts +106 -13
- package/src/server/deny-renderer.ts +46 -10
- package/src/server/error-boundary-wrapper.ts +1 -14
- package/src/server/html-injector-core.ts +19 -0
- package/src/server/html-injectors.ts +14 -4
- package/src/server/internal.ts +1 -18
- package/src/server/node-stream-transforms.ts +5 -72
- package/src/server/render-utils.ts +31 -0
- package/src/server/route-element-builder.ts +77 -47
- package/src/server/route-matcher.ts +22 -6
- package/src/server/rsc-entry/error-renderer.ts +2 -12
- package/src/server/rsc-entry/helpers.ts +6 -34
- package/src/server/rsc-entry/index.ts +11 -0
- package/src/server/rsc-entry/render-route.ts +7 -9
- package/src/server/rsc-entry/rsc-payload.ts +2 -1
- package/src/server/rsc-entry/ssr-renderer.ts +2 -2
- package/src/server/ssr-render.ts +1 -11
- package/src/server/status-code-resolver.ts +3 -2
- package/src/server/tree-match.ts +15 -5
- package/src/server/utils/mdx-file.ts +22 -0
- package/src/shared/href-validation.ts +8 -0
- package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-DH-M8Poy.js +0 -2
- package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
- package/dist/_chunks/define-DMVhsipD.js.map +0 -1
- package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
- package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
- package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
- package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
- package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
- package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
- package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
- package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
- package/dist/_chunks/warnings-CNPXaypM.js +0 -125
- package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
- package/dist/client/link-pending-store.d.ts +0 -65
- package/dist/client/link-pending-store.d.ts.map +0 -1
- package/dist/client/nav-link-store.d.ts +0 -27
- package/dist/client/nav-link-store.d.ts.map +0 -1
- package/dist/dev-tools/warnings.d.ts +0 -109
- package/dist/dev-tools/warnings.d.ts.map +0 -1
- package/dist/server/flush.d.ts +0 -74
- package/dist/server/flush.d.ts.map +0 -1
- package/src/client/link-pending-store.ts +0 -116
- package/src/client/nav-link-store.ts +0 -37
- package/src/dev-tools/warnings.ts +0 -238
- package/src/server/flush.ts +0 -183
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { useQueryStates as clientUseQueryStates } from '../client/use-query-states.js';
|
|
14
|
-
import { fromSchema, isStandardSchema, isCodec } from '../schema-bridge.js';
|
|
14
|
+
import { fromSchema, isStandardSchema, isCodec, validateSync } from '../schema-bridge.js';
|
|
15
15
|
import type { StandardSchemaV1 } from '../schema-bridge.js';
|
|
16
16
|
import type { Codec } from '../codec.js';
|
|
17
17
|
|
|
@@ -184,7 +184,7 @@ function normalizeRaw(
|
|
|
184
184
|
raw: URLSearchParams | Record<string, string | string[] | undefined>
|
|
185
185
|
): Record<string, string | string[] | undefined> {
|
|
186
186
|
if (raw instanceof URLSearchParams) {
|
|
187
|
-
const result: Record<string, string | string[] | undefined> =
|
|
187
|
+
const result: Record<string, string | string[] | undefined> = Object.create(null);
|
|
188
188
|
for (const key of new Set(raw.keys())) {
|
|
189
189
|
const values = raw.getAll(key);
|
|
190
190
|
result[key] = values.length === 1 ? values[0] : values;
|
|
@@ -220,6 +220,20 @@ function resolveField(
|
|
|
220
220
|
|
|
221
221
|
// Auto-detect Standard Schema
|
|
222
222
|
if (isStandardSchema(value)) {
|
|
223
|
+
// Validate that the schema handles absent params (undefined input).
|
|
224
|
+
// Search params are optional — the URL might not contain the param.
|
|
225
|
+
// If the schema rejects undefined and has no default, the codec would
|
|
226
|
+
// return `undefined as T`, making the type lie. Catch this at definition
|
|
227
|
+
// time instead. design/23-search-params.md §"Default Validation"
|
|
228
|
+
const absentCheck = validateSync(value, undefined);
|
|
229
|
+
if (absentCheck.issues) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`[timber] defineSearchParams: field '${fieldName}' has no default value and rejects undefined input.\n` +
|
|
232
|
+
` Search params are optional — the URL might not contain ?${fieldName}=anything.\n` +
|
|
233
|
+
` Add .default() to your schema: z.coerce.number().int().min(1).default(1)\n` +
|
|
234
|
+
` Or use .optional() if the field is truly optional.`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
223
237
|
return { codec: fromSchema(value) };
|
|
224
238
|
}
|
|
225
239
|
|
|
@@ -470,6 +484,11 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
470
484
|
// Merge URL keys: base keys + new codec urlKeys from withUrlKey
|
|
471
485
|
const combinedUrlKeys: Record<string, string> = { ...urlKeys, ...newUrlKeys };
|
|
472
486
|
|
|
487
|
+
// Same definition-time guard as defineSearchParams — a throw-on-absent
|
|
488
|
+
// codec added via extend() gets the friendly guidance error, not a
|
|
489
|
+
// crash on first parse (TIM-1066).
|
|
490
|
+
validateDefaults(combinedCodecs as Record<string, SearchParamCodec<unknown>>);
|
|
491
|
+
|
|
473
492
|
return buildDefinition<Combined>(combinedCodecs, combinedUrlKeys);
|
|
474
493
|
}
|
|
475
494
|
|
|
@@ -479,12 +498,25 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
479
498
|
const pickedUrlKeys: Record<string, string> = {};
|
|
480
499
|
|
|
481
500
|
for (const key of keys) {
|
|
501
|
+
// Explicit guard — TypeScript prevents this, but JS callers and
|
|
502
|
+
// casts reach here, and "Cannot read properties of undefined
|
|
503
|
+
// (reading 'serialize')" is no help (TIM-1066).
|
|
504
|
+
if (!(key in codecMap)) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`[timber] pick('${key}'): unknown key. ` +
|
|
507
|
+
`Available keys: ${Object.keys(codecMap)
|
|
508
|
+
.map((k) => `'${k}'`)
|
|
509
|
+
.join(', ')}.`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
482
512
|
pickedCodecs[key] = codecMap[key] as SearchParamCodec<unknown>;
|
|
483
513
|
if (key in urlKeys) {
|
|
484
514
|
pickedUrlKeys[key] = urlKeys[key];
|
|
485
515
|
}
|
|
486
516
|
}
|
|
487
517
|
|
|
518
|
+
validateDefaults(pickedCodecs);
|
|
519
|
+
|
|
488
520
|
return buildDefinition<Pick<T, K>>(
|
|
489
521
|
pickedCodecs as unknown as CodecMap<Pick<T, K>>,
|
|
490
522
|
pickedUrlKeys
|
|
@@ -9,7 +9,23 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { SearchParamCodec, SearchParamCodecWithUrlKey, SearchParamField } from './define.js';
|
|
12
|
-
import { isCodec, isStandardSchema, fromSchema } from '../schema-bridge.js';
|
|
12
|
+
import { isCodec, isStandardSchema, fromSchema, validateSync } from '../schema-bridge.js';
|
|
13
|
+
import type { StandardSchemaV1 } from '../schema-bridge.js';
|
|
14
|
+
|
|
15
|
+
function validateSchemaAbsentParam<T>(
|
|
16
|
+
schema: StandardSchemaV1<T>,
|
|
17
|
+
fieldHint: string
|
|
18
|
+
): SearchParamCodec<T> {
|
|
19
|
+
const absentCheck = validateSync(schema, undefined);
|
|
20
|
+
if (absentCheck.issues) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`[timber] withUrlKey: schema for '${fieldHint}' has no default value and rejects undefined input.\n` +
|
|
23
|
+
` Search params are optional — the URL might not contain ?${fieldHint}=anything.\n` +
|
|
24
|
+
` Add .default() to your schema or use .optional().`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return fromSchema(schema);
|
|
28
|
+
}
|
|
13
29
|
|
|
14
30
|
// ---------------------------------------------------------------------------
|
|
15
31
|
// withDefault
|
|
@@ -78,11 +94,13 @@ export function withUrlKey<T>(
|
|
|
78
94
|
codecOrSchema: SearchParamField<T>,
|
|
79
95
|
urlKey: string
|
|
80
96
|
): SearchParamCodecWithUrlKey<T> {
|
|
81
|
-
// Auto-detect Standard Schema (Zod, Valibot, ArkType) and wrap
|
|
97
|
+
// Auto-detect Standard Schema (Zod, Valibot, ArkType) and wrap.
|
|
98
|
+
// Validate absent-param handling here since withUrlKey converts to a codec
|
|
99
|
+
// before resolveField sees it. design/23-search-params.md §"Default Validation"
|
|
82
100
|
const codec: SearchParamCodec<T> = isCodec(codecOrSchema)
|
|
83
101
|
? codecOrSchema
|
|
84
102
|
: isStandardSchema(codecOrSchema)
|
|
85
|
-
?
|
|
103
|
+
? validateSchemaAbsentParam(codecOrSchema, urlKey)
|
|
86
104
|
: (codecOrSchema as SearchParamCodec<T>);
|
|
87
105
|
return {
|
|
88
106
|
parse: codec.parse.bind(codec),
|
|
@@ -14,12 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { Codec } from '../codec.js';
|
|
17
|
-
import {
|
|
18
|
-
type StandardSchemaV1,
|
|
19
|
-
validateSync,
|
|
20
|
-
isStandardSchema,
|
|
21
|
-
isCodec,
|
|
22
|
-
} from '../schema-bridge.js';
|
|
17
|
+
import { type StandardSchemaV1, resolveCodecOrSchema } from '../schema-bridge.js';
|
|
23
18
|
import { useSegmentParams as clientUseSegmentParams } from '../client/use-segment-params.js';
|
|
24
19
|
|
|
25
20
|
// ---------------------------------------------------------------------------
|
|
@@ -114,61 +109,6 @@ export interface ParamsDefinition<T extends Record<string, unknown>> {
|
|
|
114
109
|
// Internal helpers
|
|
115
110
|
// ---------------------------------------------------------------------------
|
|
116
111
|
|
|
117
|
-
/**
|
|
118
|
-
* Wrap a Standard Schema into a Codec for route params.
|
|
119
|
-
*
|
|
120
|
-
* Unlike fromSchema for search params:
|
|
121
|
-
* - Parse throws on failure (no fallback to default)
|
|
122
|
-
* - Serialize returns string (not null)
|
|
123
|
-
*/
|
|
124
|
-
function fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {
|
|
125
|
-
return {
|
|
126
|
-
parse(value: string | string[] | undefined): T {
|
|
127
|
-
// Route params are always strings (single segment) or string[] (catch-all)
|
|
128
|
-
const input = Array.isArray(value) ? value : value;
|
|
129
|
-
|
|
130
|
-
const result = validateSync(schema, input);
|
|
131
|
-
if (!result.issues) {
|
|
132
|
-
return result.value;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// For route params, parse failure means the param is invalid → throw
|
|
136
|
-
const messages = result.issues.map((i) => i.message).join(', ');
|
|
137
|
-
throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);
|
|
138
|
-
},
|
|
139
|
-
|
|
140
|
-
serialize(value: T): string | null {
|
|
141
|
-
if (value === null || value === undefined) {
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
// Catch-all segments produce arrays — join with '/' for path reconstruction
|
|
145
|
-
if (Array.isArray(value)) {
|
|
146
|
-
return value.join('/');
|
|
147
|
-
}
|
|
148
|
-
return String(value);
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Resolve a field value to a Codec. Auto-detects Standard Schema objects.
|
|
155
|
-
*/
|
|
156
|
-
function resolveField(fieldName: string, value: ParamField): Codec<unknown> {
|
|
157
|
-
if (isCodec(value)) {
|
|
158
|
-
return value;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (isStandardSchema(value)) {
|
|
162
|
-
return fromParamSchema(fieldName, value);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
throw new Error(
|
|
166
|
-
`[timber] defineSegmentParams: field '${fieldName}' is not a valid codec or Standard Schema. ` +
|
|
167
|
-
`Expected an object with { parse, serialize } methods, or a Standard Schema object ` +
|
|
168
|
-
`(Zod, Valibot, ArkType).`
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
112
|
/**
|
|
173
113
|
* Validate that no codec's serialize returns null.
|
|
174
114
|
* Route params are structural — they must produce a valid path segment.
|
|
@@ -224,7 +164,7 @@ export function defineSegmentParams<C extends Record<string, ParamField>>(
|
|
|
224
164
|
const resolvedCodecs: Record<string, Codec<unknown>> = {};
|
|
225
165
|
|
|
226
166
|
for (const [key, value] of Object.entries(codecs)) {
|
|
227
|
-
resolvedCodecs[key] =
|
|
167
|
+
resolvedCodecs[key] = resolveCodecOrSchema(key, value, 'param');
|
|
228
168
|
}
|
|
229
169
|
|
|
230
170
|
// Validate that serialize doesn't return null
|
|
@@ -45,6 +45,14 @@ export function AccessGate(props: AccessGateProps): ReactNode | Promise<ReactNod
|
|
|
45
45
|
if (verdict === 'pass') {
|
|
46
46
|
return children;
|
|
47
47
|
}
|
|
48
|
+
// Render deny page in-tree when possible (same as the fallback path).
|
|
49
|
+
if (verdict instanceof DenySignal && denyPages) {
|
|
50
|
+
const denyElement = renderMatchingDenyPage(denyPages, verdict.status, verdict.data);
|
|
51
|
+
if (denyElement) {
|
|
52
|
+
setDenyStatus(verdict.status);
|
|
53
|
+
return denyElement;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
48
56
|
throw verdict;
|
|
49
57
|
}
|
|
50
58
|
|
package/src/server/actions.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See design/08-forms-and-actions.md
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { cache } from '../cache/cache-api';
|
|
18
18
|
import { DenySignal, RedirectSignal } from './primitives';
|
|
19
19
|
import { withSpan } from './tracing';
|
|
20
20
|
import { revalidationAls, type RevalidationState } from './als-registry.js';
|
|
@@ -161,12 +161,14 @@ export async function executeAction(
|
|
|
161
161
|
}
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
-
// Process tag invalidation
|
|
165
|
-
//
|
|
166
|
-
//
|
|
164
|
+
// Process tag invalidation through cache.invalidate so it records the
|
|
165
|
+
// invalidation epoch — an in-flight cached fn must not re-store data this
|
|
166
|
+
// mutation just invalidated (TIM-1028). cache.invalidate resolves the
|
|
167
|
+
// module-level handler singleton: setCacheHandler() is called at boot from
|
|
168
|
+
// rsc-entry when timber.config.ts provides a cacheHandler; otherwise falls
|
|
169
|
+
// back to in-memory LRU (TIM-599).
|
|
167
170
|
if (state.tags.length > 0) {
|
|
168
|
-
|
|
169
|
-
await Promise.all(state.tags.map((tag) => handler.invalidate({ tag })));
|
|
171
|
+
await Promise.all(state.tags.map((tag) => cache.invalidate({ tag })));
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
// Process path revalidation — build element tree (not yet serialized)
|
|
@@ -212,28 +214,3 @@ export async function executeAction(
|
|
|
212
214
|
...(redirectTo ? { redirectTo, redirectStatus } : {}),
|
|
213
215
|
};
|
|
214
216
|
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Build an HTTP Response for a no-JS form submission.
|
|
218
|
-
* Standard POST → 302 redirect pattern.
|
|
219
|
-
*
|
|
220
|
-
* @param redirectPath - Where to redirect after the action executes.
|
|
221
|
-
*/
|
|
222
|
-
export function buildNoJsResponse(redirectPath: string, status: number = 302): Response {
|
|
223
|
-
return new Response(null, {
|
|
224
|
-
status,
|
|
225
|
-
headers: { Location: redirectPath },
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Detect whether the incoming request is an RSC action request (with JS)
|
|
231
|
-
* or a plain HTML form POST (no JS).
|
|
232
|
-
*
|
|
233
|
-
* RSC action requests use Accept: text/x-component or Content-Type: text/x-component.
|
|
234
|
-
*/
|
|
235
|
-
export function isRscActionRequest(req: Request): boolean {
|
|
236
|
-
const accept = req.headers.get('Accept') ?? '';
|
|
237
|
-
const contentType = req.headers.get('Content-Type') ?? '';
|
|
238
|
-
return accept.includes('text/x-component') || contentType.includes('text/x-component');
|
|
239
|
-
}
|
|
@@ -101,19 +101,6 @@ export function buildCssLinkTags(cssUrls: string[]): string {
|
|
|
101
101
|
return cssUrls.map((url) => `<link rel="stylesheet" href="${url}">`).join('');
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
* Generate a Link header value for CSS preload hints.
|
|
106
|
-
*
|
|
107
|
-
* Cloudflare CDN automatically converts Link headers with rel=preload
|
|
108
|
-
* into 103 Early Hints responses. This avoids platform-specific 103
|
|
109
|
-
* sending code.
|
|
110
|
-
*
|
|
111
|
-
* Example output: `</assets/root.css>; as=style; rel=preload, </assets/page.css>; as=style; rel=preload`
|
|
112
|
-
*/
|
|
113
|
-
export function buildLinkHeaders(cssUrls: string[]): string {
|
|
114
|
-
return cssUrls.map((url) => `<${url}>; as=style; rel=preload`).join(', ');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
104
|
// ─── Font utilities ──────────────────────────────────────────────────────
|
|
118
105
|
|
|
119
106
|
/**
|
|
@@ -161,44 +148,8 @@ export function buildFontPreloadTags(fonts: ManifestFontEntry[]): string {
|
|
|
161
148
|
.join('');
|
|
162
149
|
}
|
|
163
150
|
|
|
164
|
-
/**
|
|
165
|
-
* Generate Link header values for font preload hints.
|
|
166
|
-
*
|
|
167
|
-
* Cloudflare CDN converts Link headers with rel=preload into 103 Early Hints.
|
|
168
|
-
*
|
|
169
|
-
* Example: `</fonts/inter.woff2>; as=font; rel=preload; crossorigin`
|
|
170
|
-
*/
|
|
171
|
-
export function buildFontLinkHeaders(fonts: ManifestFontEntry[]): string {
|
|
172
|
-
return fonts.map((f) => `<${f.href}>; as=font; rel=preload; crossorigin`).join(', ');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
151
|
// ─── JS chunk utilities ──────────────────────────────────────────────────
|
|
176
152
|
|
|
177
|
-
/**
|
|
178
|
-
* Collect JS chunk URLs for a matched route's segment chain.
|
|
179
|
-
*
|
|
180
|
-
* Walks segments root → leaf, collecting the JS chunk for each layout
|
|
181
|
-
* and page. Deduplicates while preserving order.
|
|
182
|
-
*/
|
|
183
|
-
export function collectRouteJs(segments: SegmentWithFiles[], manifest: BuildManifest): string[] {
|
|
184
|
-
const seen = new Set<string>();
|
|
185
|
-
const result: string[] = [];
|
|
186
|
-
|
|
187
|
-
for (const segment of segments) {
|
|
188
|
-
for (const file of [segment.layout, segment.page]) {
|
|
189
|
-
if (!file) continue;
|
|
190
|
-
const jsUrl = manifest.js[file.filePath];
|
|
191
|
-
if (!jsUrl) continue;
|
|
192
|
-
if (!seen.has(jsUrl)) {
|
|
193
|
-
seen.add(jsUrl);
|
|
194
|
-
result.push(jsUrl);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return result;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
153
|
/**
|
|
203
154
|
* Collect modulepreload URLs for a matched route's segment chain.
|
|
204
155
|
*
|
|
@@ -238,10 +189,3 @@ export function collectRouteModulepreloads(
|
|
|
238
189
|
export function buildModulepreloadTags(urls: string[]): string {
|
|
239
190
|
return urls.map((url) => `<link rel="modulepreload" href="${url}">`).join('');
|
|
240
191
|
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Generate a <script type="module"> tag for a JS entry point.
|
|
244
|
-
*/
|
|
245
|
-
export function buildEntryScriptTag(url: string): string {
|
|
246
|
-
return `<script type="module" src="${url}"></script>`;
|
|
247
|
-
}
|
|
@@ -13,11 +13,14 @@ export type CanonicalizeResult = { ok: true; pathname: string } | { ok: false; s
|
|
|
13
13
|
/**
|
|
14
14
|
* Encoded separators that produce a 400 rejection.
|
|
15
15
|
* %2f (/) and %5c (\) cause path-confusion attacks.
|
|
16
|
+
*
|
|
17
|
+
* Shared between the runtime canonicalizer and the build-time route scanner
|
|
18
|
+
* to ensure both enforce identical security rules. See design/13-security.md.
|
|
16
19
|
*/
|
|
17
|
-
const ENCODED_SEPARATOR_RE = /%2f|%5c/i;
|
|
20
|
+
export const ENCODED_SEPARATOR_RE = /%2f|%5c/i;
|
|
18
21
|
|
|
19
|
-
/** Null byte — rejected. */
|
|
20
|
-
const NULL_BYTE_RE = /%00/i;
|
|
22
|
+
/** Null byte — rejected. Shared with the route scanner. */
|
|
23
|
+
export const NULL_BYTE_RE = /%00/i;
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* Canonicalize a URL pathname.
|
|
@@ -31,10 +31,14 @@
|
|
|
31
31
|
|
|
32
32
|
import { createElement } from 'react';
|
|
33
33
|
|
|
34
|
+
import { ErrorReconstituter } from '../client/error-reconstituter.js';
|
|
35
|
+
import type { SerializableError } from '../client/error-reconstituter.js';
|
|
34
36
|
import { requestContextAls } from './als-registry.js';
|
|
35
37
|
import { DenySignal } from './primitives.js';
|
|
36
38
|
import { loadModule } from './safe-load.js';
|
|
39
|
+
import { LEGACY_FILE_TO_STATUS } from './status-code-resolver.js';
|
|
37
40
|
import { withSpan } from './tracing.js';
|
|
41
|
+
import { isMdxFilePath } from './utils/mdx-file.js';
|
|
38
42
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
39
43
|
|
|
40
44
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
@@ -45,6 +49,15 @@ export interface DenyPageEntry {
|
|
|
45
49
|
status: number | null;
|
|
46
50
|
/** The component to render (server or client — both work). */
|
|
47
51
|
component: (...args: unknown[]) => unknown;
|
|
52
|
+
/**
|
|
53
|
+
* How the entry matched: a status-code file (404.tsx/4xx.tsx), a legacy
|
|
54
|
+
* compat file (not-found.tsx/forbidden.tsx/unauthorized.tsx), or the
|
|
55
|
+
* error.tsx catch-all. error.tsx entries render with their documented
|
|
56
|
+
* { error, digest, reset } contract, not bare deny props. See TIM-1081.
|
|
57
|
+
*/
|
|
58
|
+
kind: 'status' | 'legacy' | 'error';
|
|
59
|
+
/** MDX files are server components — rendered with plain props, never ErrorReconstituter. */
|
|
60
|
+
isMdx: boolean;
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
// ─── Chain Construction ──────────────────────────────────────────────────
|
|
@@ -56,10 +69,14 @@ export interface DenyPageEntry {
|
|
|
56
69
|
* status-code file components in fallback order:
|
|
57
70
|
* 1. Specific status files (403.tsx, 404.tsx) — exact match
|
|
58
71
|
* 2. Category catch-alls (4xx.tsx) — matches any 4xx
|
|
59
|
-
* 3.
|
|
72
|
+
* 3. Legacy compat files (not-found.tsx → 404, forbidden.tsx → 403,
|
|
73
|
+
* unauthorized.tsx → 401) — exact match
|
|
74
|
+
* 4. error.tsx — catches everything
|
|
60
75
|
*
|
|
61
76
|
* Each segment is checked in this order. The chain is ordered so the
|
|
62
|
-
* FIRST match wins at catch time.
|
|
77
|
+
* FIRST match wins at catch time. This mirrors resolveStatusFile's 4xx
|
|
78
|
+
* component chain (status-code-resolver.ts) — the two walks must stay in
|
|
79
|
+
* sync or in-tree and re-render deny paths resolve different files.
|
|
63
80
|
*/
|
|
64
81
|
export async function buildDenyPageChain(
|
|
65
82
|
segments: ManifestSegmentNode[],
|
|
@@ -82,7 +99,12 @@ export async function buildDenyPageChain(
|
|
|
82
99
|
if (!isNaN(status)) {
|
|
83
100
|
const mod = await loadModule(file).catch(() => null);
|
|
84
101
|
if (mod?.default) {
|
|
85
|
-
chain.push({
|
|
102
|
+
chain.push({
|
|
103
|
+
status,
|
|
104
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
105
|
+
kind: 'status',
|
|
106
|
+
isMdx: isMdxFilePath(file.filePath),
|
|
107
|
+
});
|
|
86
108
|
}
|
|
87
109
|
}
|
|
88
110
|
}
|
|
@@ -97,21 +119,50 @@ export async function buildDenyPageChain(
|
|
|
97
119
|
chain.push({
|
|
98
120
|
status: categoryStatus,
|
|
99
121
|
component: mod.default as (...args: unknown[]) => unknown,
|
|
122
|
+
kind: 'status',
|
|
123
|
+
isMdx: isMdxFilePath(file.filePath),
|
|
100
124
|
});
|
|
101
125
|
}
|
|
102
126
|
}
|
|
103
127
|
}
|
|
104
128
|
}
|
|
105
129
|
|
|
106
|
-
// Pass 2:
|
|
107
|
-
//
|
|
108
|
-
//
|
|
130
|
+
// Pass 2: legacy compat files (not-found.tsx → 404, forbidden.tsx → 403,
|
|
131
|
+
// unauthorized.tsx → 401). Lower priority than status files (a root
|
|
132
|
+
// 4xx.tsx beats a leaf not-found.tsx) but higher than error.tsx — the
|
|
133
|
+
// same ordering as resolveStatusFile's resolve4xx. See TIM-1081.
|
|
134
|
+
for (let i = startIndex; i >= 0; i--) {
|
|
135
|
+
const segment = segments[i];
|
|
136
|
+
if (!segment.legacyStatusFiles) continue;
|
|
137
|
+
for (const [name, status] of Object.entries(LEGACY_FILE_TO_STATUS)) {
|
|
138
|
+
const file = segment.legacyStatusFiles[name];
|
|
139
|
+
if (!file) continue;
|
|
140
|
+
const mod = await loadModule(file).catch(() => null);
|
|
141
|
+
if (mod?.default) {
|
|
142
|
+
chain.push({
|
|
143
|
+
status,
|
|
144
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
145
|
+
kind: 'legacy',
|
|
146
|
+
isMdx: isMdxFilePath(file.filePath),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Pass 3: error.tsx files — lowest priority catch-all.
|
|
153
|
+
// Only added AFTER all status and legacy files so they never shadow a
|
|
154
|
+
// more specific file from an ancestor segment.
|
|
109
155
|
for (let i = startIndex; i >= 0; i--) {
|
|
110
156
|
const segment = segments[i];
|
|
111
157
|
if (segment.error) {
|
|
112
158
|
const mod = await loadModule(segment.error).catch(() => null);
|
|
113
159
|
if (mod?.default) {
|
|
114
|
-
chain.push({
|
|
160
|
+
chain.push({
|
|
161
|
+
status: null,
|
|
162
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
163
|
+
kind: 'error',
|
|
164
|
+
isMdx: isMdxFilePath(segment.error.filePath),
|
|
165
|
+
});
|
|
115
166
|
}
|
|
116
167
|
}
|
|
117
168
|
}
|
|
@@ -130,25 +181,67 @@ export function renderMatchingDenyPage(
|
|
|
130
181
|
status: number,
|
|
131
182
|
data: unknown
|
|
132
183
|
): React.ReactElement | null {
|
|
133
|
-
const h = createElement as (...args: unknown[]) => React.ReactElement;
|
|
134
|
-
|
|
135
184
|
for (const entry of chain) {
|
|
136
185
|
if (entry.status === status) {
|
|
137
|
-
return
|
|
186
|
+
return renderDenyEntry(entry, status, data);
|
|
138
187
|
}
|
|
139
188
|
if (entry.status === 400 && status >= 400 && status <= 499) {
|
|
140
|
-
return
|
|
189
|
+
return renderDenyEntry(entry, status, data);
|
|
141
190
|
}
|
|
142
191
|
if (entry.status === 500 && status >= 500 && status <= 599) {
|
|
143
|
-
return
|
|
192
|
+
return renderDenyEntry(entry, status, data);
|
|
144
193
|
}
|
|
145
194
|
if (entry.status === null) {
|
|
146
|
-
return
|
|
195
|
+
return renderDenyEntry(entry, status, data);
|
|
147
196
|
}
|
|
148
197
|
}
|
|
149
198
|
return null;
|
|
150
199
|
}
|
|
151
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Build the element for a matched deny chain entry with the props contract
|
|
203
|
+
* the file's convention documents:
|
|
204
|
+
*
|
|
205
|
+
* - Status-code and legacy files: { status, dangerouslyPassData }
|
|
206
|
+
* - error.tsx (TSX): { error, digest, reset } via ErrorReconstituter, plus
|
|
207
|
+
* { status, dangerouslyPassData } for dual-shape implementations. Without
|
|
208
|
+
* the Error prop, any error.tsx written per the docs (reading
|
|
209
|
+
* error.message) crashes — turning a clean deny(404) into a 500. TIM-1081.
|
|
210
|
+
* - error.mdx: plain { status } — server component, static content.
|
|
211
|
+
*/
|
|
212
|
+
export function renderDenyEntry(
|
|
213
|
+
entry: DenyPageEntry,
|
|
214
|
+
status: number,
|
|
215
|
+
data: unknown
|
|
216
|
+
): React.ReactElement {
|
|
217
|
+
const h = createElement as (...args: unknown[]) => React.ReactElement;
|
|
218
|
+
|
|
219
|
+
if (entry.kind !== 'error') {
|
|
220
|
+
return h(entry.component, { status, dangerouslyPassData: data });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// MDX error pages receive plain props — no Error serialization possible.
|
|
224
|
+
if (entry.isMdx) {
|
|
225
|
+
return h(entry.component, { status });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The message is derived solely from the status code (already on the
|
|
229
|
+
// wire) — safe to cross the RSC→client boundary in production. No stack,
|
|
230
|
+
// no user data. See design/13-security.md §"Errors don't leak".
|
|
231
|
+
const serializableDeny: SerializableError = {
|
|
232
|
+
message: `Access denied with status ${status}`,
|
|
233
|
+
name: 'DenySignal',
|
|
234
|
+
};
|
|
235
|
+
return h(ErrorReconstituter, {
|
|
236
|
+
error: serializableDeny,
|
|
237
|
+
digest: null,
|
|
238
|
+
reset: undefined,
|
|
239
|
+
component: entry.component,
|
|
240
|
+
status,
|
|
241
|
+
dangerouslyPassData: data,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
152
245
|
// ─── Page Boundary ────────────────────────────────────────────────────────
|
|
153
246
|
|
|
154
247
|
/**
|
|
@@ -19,8 +19,11 @@ import { createElement } from 'react';
|
|
|
19
19
|
import { renderToReadableStream } from '../rsc-runtime/rsc.js';
|
|
20
20
|
|
|
21
21
|
import { DenySignal } from './primitives.js';
|
|
22
|
+
import { renderDenyEntry, type DenyPageEntry } from './deny-boundary.js';
|
|
23
|
+
import { getCookiesForSsr } from './cookie-context.js';
|
|
22
24
|
import { logRenderError } from './logger.js';
|
|
23
25
|
import { loadModule } from './safe-load.js';
|
|
26
|
+
import { isMdxFilePath } from './utils/mdx-file.js';
|
|
24
27
|
import { isDebug } from './debug.js';
|
|
25
28
|
import { escapeHtml } from './utils/escape-html.js';
|
|
26
29
|
import { resolveMetadata, renderMetadataToElements } from './metadata.js';
|
|
@@ -62,6 +65,29 @@ function isApiRoute(segments: ReadonlyArray<ManifestSegmentNode>): boolean {
|
|
|
62
65
|
return !!leaf?.route;
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Build the deny page element for a resolved status file with the correct
|
|
70
|
+
* props contract for the file's convention. Delegates to renderDenyEntry
|
|
71
|
+
* (deny-boundary.ts) so the in-tree and re-render deny paths render
|
|
72
|
+
* identical elements — in particular, error.tsx serving a deny gets
|
|
73
|
+
* { error, digest, reset } via ErrorReconstituter, not bare deny props.
|
|
74
|
+
* See TIM-1081.
|
|
75
|
+
*/
|
|
76
|
+
function buildDenyElement(
|
|
77
|
+
deny: DenySignal,
|
|
78
|
+
component: (...args: unknown[]) => unknown,
|
|
79
|
+
resolutionKind: 'exact' | 'category' | 'legacy' | 'error',
|
|
80
|
+
filePath: string
|
|
81
|
+
): React.ReactElement {
|
|
82
|
+
const entry: DenyPageEntry = {
|
|
83
|
+
status: deny.status,
|
|
84
|
+
component,
|
|
85
|
+
kind: resolutionKind === 'error' ? 'error' : resolutionKind === 'legacy' ? 'legacy' : 'status',
|
|
86
|
+
isMdx: isMdxFilePath(filePath),
|
|
87
|
+
};
|
|
88
|
+
return renderDenyEntry(entry, deny.status, deny.data);
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
/**
|
|
66
92
|
* Render a status-code error page for a DenySignal.
|
|
67
93
|
*
|
|
@@ -120,12 +146,15 @@ export async function renderDenyPage(
|
|
|
120
146
|
const ErrorPageComponent = mod.default as (...args: unknown[]) => unknown;
|
|
121
147
|
const h = createElement as (...args: unknown[]) => React.ReactElement;
|
|
122
148
|
|
|
123
|
-
// 4xx status-code pages receive { status, dangerouslyPassData }
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
149
|
+
// 4xx status-code pages receive { status, dangerouslyPassData }; an
|
|
150
|
+
// error.tsx match gets { error, digest, reset } via ErrorReconstituter.
|
|
151
|
+
// See design/10-error-handling.md §"Status-Code File Props" and TIM-1081.
|
|
152
|
+
let element = buildDenyElement(
|
|
153
|
+
deny,
|
|
154
|
+
ErrorPageComponent,
|
|
155
|
+
resolution.kind,
|
|
156
|
+
resolution.file.filePath
|
|
157
|
+
);
|
|
129
158
|
|
|
130
159
|
// Always wrap in layout shell.
|
|
131
160
|
// NOTE: Shell opt-out (export const shell = false) is a future feature
|
|
@@ -156,6 +185,7 @@ export async function renderDenyPage(
|
|
|
156
185
|
|
|
157
186
|
// Render the error page to a fresh RSC Flight stream.
|
|
158
187
|
const rscStream = renderToReadableStream(element, {
|
|
188
|
+
signal: req.signal,
|
|
159
189
|
onError(error: unknown) {
|
|
160
190
|
logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
|
|
161
191
|
},
|
|
@@ -173,6 +203,8 @@ export async function renderDenyPage(
|
|
|
173
203
|
headHtml: headHtml + flightInitScript(),
|
|
174
204
|
bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
|
|
175
205
|
rscStream: inlineStream,
|
|
206
|
+
signal: req.signal,
|
|
207
|
+
cookies: getCookiesForSsr(),
|
|
176
208
|
};
|
|
177
209
|
|
|
178
210
|
return callSsr(ssrStream, navContext);
|
|
@@ -188,6 +220,7 @@ export async function renderDenyPageAsRsc(
|
|
|
188
220
|
deny: DenySignal,
|
|
189
221
|
segments: ManifestSegmentNode[],
|
|
190
222
|
layoutComponents: LayoutEntry[],
|
|
223
|
+
req: Request,
|
|
191
224
|
responseHeaders: Headers,
|
|
192
225
|
createDebugChannelSink: DebugChannelFactory
|
|
193
226
|
): Promise<Response> {
|
|
@@ -207,10 +240,12 @@ export async function renderDenyPageAsRsc(
|
|
|
207
240
|
const ErrorPageComponent = mod.default as (...args: unknown[]) => unknown;
|
|
208
241
|
const h = createElement as (...args: unknown[]) => React.ReactElement;
|
|
209
242
|
|
|
210
|
-
let element =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
243
|
+
let element = buildDenyElement(
|
|
244
|
+
deny,
|
|
245
|
+
ErrorPageComponent,
|
|
246
|
+
resolution.kind,
|
|
247
|
+
resolution.file.filePath
|
|
248
|
+
);
|
|
214
249
|
|
|
215
250
|
// Always wrap in layout shell.
|
|
216
251
|
const resolvedSegments = new Set(segments.slice(0, resolution.segmentIndex + 1));
|
|
@@ -221,6 +256,7 @@ export async function renderDenyPageAsRsc(
|
|
|
221
256
|
}
|
|
222
257
|
|
|
223
258
|
const rscStream = renderToReadableStream(element, {
|
|
259
|
+
signal: req.signal,
|
|
224
260
|
onError(error: unknown) {
|
|
225
261
|
console.error('[timber] Error page RSC render error:', error);
|
|
226
262
|
},
|