@timber-js/app 0.2.0-alpha.145 → 0.2.0-alpha.147
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-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
- package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-DH-M8Poy.js → cli-schema-sync-CBiMEtqa.js} +1 -1
- package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-CQaxupHv.js} +47 -54
- package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +1 -0
- package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
- package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
- package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
- package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
- package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-Ck4Y45XN.js} +49 -8
- package/dist/_chunks/walkers-Ck4Y45XN.js.map +1 -0
- package/dist/adapters/cloudflare.d.ts +1 -15
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +10 -28
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +18 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +39 -12
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/redis-handler.d.ts +72 -8
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/stable-stringify.d.ts +9 -0
- package/dist/cache/stable-stringify.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +3 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/browser-entry/scroll.d.ts +9 -0
- package/dist/client/browser-entry/scroll.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +24 -10
- 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/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +99 -24
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +13 -0
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +7 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/dev-tools/404-page.d.ts +6 -2
- package/dist/dev-tools/404-page.d.ts.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +77 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
- package/dist/dev-tools/error-page.d.ts +4 -30
- package/dist/dev-tools/error-page.d.ts.map +1 -1
- package/dist/dev-tools/index.d.ts +2 -1
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/dev-tools/instrumentation.d.ts +11 -1
- package/dist/dev-tools/instrumentation.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +432 -255
- package/dist/index.js.map +1 -1
- package/dist/plugins/cache.d.ts +43 -9
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/cloudflare-wasm.d.ts +4 -1
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts +16 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts +23 -0
- package/dist/plugins/shims.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/index.js +2 -2
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +3 -2
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/cookie-parsing.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.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts +2 -2
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +227 -179
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +24 -6
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +11 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +1 -11
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +6 -4
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.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/tracing.d.ts +2 -1
- package/dist/server/tracing.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/head-header.d.ts +54 -0
- package/dist/shared/head-header.d.ts.map +1 -0
- package/dist/shared/href-validation.d.ts +11 -0
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shared/redirect-type.d.ts +17 -0
- package/dist/shared/redirect-type.d.ts.map +1 -0
- package/dist/shims/headers.d.ts +52 -1
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/navigation-client.d.ts +33 -11
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/package.json +11 -10
- package/src/adapters/cloudflare.ts +13 -47
- package/src/adapters/compress-module.ts +9 -3
- package/src/adapters/nitro.ts +9 -2
- package/src/cache/redis-handler.ts +77 -13
- package/src/cache/stable-stringify.ts +65 -2
- package/src/cache/timber-cache.ts +25 -13
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/action-dispatch.ts +7 -2
- package/src/client/browser-entry/router-init.ts +2 -1
- package/src/client/browser-entry/scroll.ts +23 -0
- package/src/client/error-boundary.tsx +33 -5
- package/src/client/error-reconstituter.tsx +15 -1
- package/src/client/link.tsx +4 -1
- package/src/client/navigation-api.ts +5 -1
- package/src/client/navigation-root.tsx +16 -0
- package/src/client/router.ts +107 -29
- package/src/client/rsc-fetch.ts +6 -6
- package/src/dev-tools/404-page.ts +22 -123
- package/src/dev-tools/dev-page-shell.ts +256 -0
- package/src/dev-tools/error-page.ts +27 -203
- package/src/dev-tools/index.ts +7 -1
- package/src/dev-tools/instrumentation.ts +39 -2
- package/src/index.ts +1 -0
- package/src/plugins/cache.ts +79 -21
- package/src/plugins/cloudflare-wasm.ts +48 -10
- package/src/plugins/dev-server.ts +32 -8
- package/src/plugins/server-bundle.ts +25 -9
- package/src/plugins/shims.ts +94 -52
- package/src/routing/codegen-types.ts +4 -0
- package/src/routing/codegen.ts +63 -6
- package/src/routing/scanner.ts +80 -63
- package/src/server/action-handler.ts +10 -2
- package/src/server/actions.ts +3 -2
- package/src/server/compress.ts +11 -2
- package/src/server/cookie-context.ts +11 -0
- package/src/server/cookie-parsing.ts +16 -4
- package/src/server/deny-boundary.ts +106 -13
- package/src/server/deny-renderer.ts +40 -10
- package/src/server/error-boundary-wrapper.ts +1 -14
- package/src/server/fallback-error.ts +9 -4
- package/src/server/node-stream-transforms.ts +61 -24
- package/src/server/pipeline.ts +12 -0
- package/src/server/primitives.ts +10 -31
- package/src/server/route-matcher.ts +22 -6
- package/src/server/rsc-entry/error-renderer.ts +1 -12
- package/src/server/rsc-entry/index.ts +7 -2
- package/src/server/rsc-entry/rsc-payload.ts +4 -3
- package/src/server/slot-resolver.ts +27 -14
- package/src/server/ssr-entry.ts +17 -28
- package/src/server/status-code-resolver.ts +3 -2
- package/src/server/tracing.ts +17 -1
- package/src/server/tree-match.ts +15 -5
- package/src/server/utils/mdx-file.ts +22 -0
- package/src/shared/head-header.ts +69 -0
- package/src/shared/href-validation.ts +24 -0
- package/src/shared/redirect-type.ts +17 -0
- package/src/shims/headers.ts +119 -2
- package/src/shims/navigation-client.ts +48 -27
- package/LICENSE +0 -8
- package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
- package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
- package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
- package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
package/src/routing/codegen.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}):
|
|
|
48
48
|
const hasSchema = options.hasSchema ?? detectSchema(options.appDir);
|
|
49
49
|
|
|
50
50
|
const routes: RouteEntry[] = [];
|
|
51
|
-
collectRoutes(tree.root, [], [], '', routes, hasSchema);
|
|
51
|
+
collectRoutes(tree.root, [], [], '', routes, hasSchema, false);
|
|
52
52
|
|
|
53
53
|
// Sort routes alphabetically for deterministic output
|
|
54
54
|
routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
|
|
@@ -82,7 +82,8 @@ function collectRoutes(
|
|
|
82
82
|
ancestorParamsFiles: string[],
|
|
83
83
|
parentTreePath: string,
|
|
84
84
|
routes: RouteEntry[],
|
|
85
|
-
hasSchema: boolean
|
|
85
|
+
hasSchema: boolean,
|
|
86
|
+
insideSlot: boolean
|
|
86
87
|
): void {
|
|
87
88
|
// Build the tree path (includes groups, slots — uniquely identifies this segment).
|
|
88
89
|
// Root node has empty segmentName, producing treePath = ''.
|
|
@@ -156,6 +157,7 @@ function collectRoutes(
|
|
|
156
157
|
params: resolvedParams,
|
|
157
158
|
hasSearchParams: false,
|
|
158
159
|
isApiRoute,
|
|
160
|
+
isSlotRoute: insideSlot,
|
|
159
161
|
hasSchema,
|
|
160
162
|
};
|
|
161
163
|
|
|
@@ -175,15 +177,63 @@ function collectRoutes(
|
|
|
175
177
|
|
|
176
178
|
// Recurse into children
|
|
177
179
|
for (const child of node.children) {
|
|
178
|
-
collectRoutes(child, params, nextAncestorFiles, treePath, routes, hasSchema);
|
|
180
|
+
collectRoutes(child, params, nextAncestorFiles, treePath, routes, hasSchema, insideSlot);
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
// Recurse into slots (they share the parent's URL path, but may have their own pages)
|
|
182
184
|
for (const slot of Object.values(node.slots)) {
|
|
183
|
-
collectRoutes(slot, params, nextAncestorFiles, treePath, routes, hasSchema);
|
|
185
|
+
collectRoutes(slot, params, nextAncestorFiles, treePath, routes, hasSchema, true);
|
|
184
186
|
}
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Dedupe route entries by urlPath for urlPath-keyed emissions (the Routes
|
|
191
|
+
* interface, useQueryStates overloads, and typed Link variants).
|
|
192
|
+
*
|
|
193
|
+
* Parallel-slot pages share their parent's urlPath; emitting one entry per
|
|
194
|
+
* slot page produced duplicate interface keys (TS2300/TS2717 under
|
|
195
|
+
* skipLibCheck:false) and duplicate overloads. The non-slot entry is the
|
|
196
|
+
* canonical one; when only slot entries exist (parent has no page), the
|
|
197
|
+
* lexicographically smallest segmentPath wins so output is independent of
|
|
198
|
+
* slot scan order. A slot's searchParams definition is carried onto the
|
|
199
|
+
* merged entry when the canonical entry has none — never silently dropped.
|
|
200
|
+
*
|
|
201
|
+
* segmentPath-keyed emissions (getSegmentParams, useSegmentParams,
|
|
202
|
+
* TimberSegmentParams) must NOT use this — segmentPaths are already unique
|
|
203
|
+
* and slot entries feed those APIs directly.
|
|
204
|
+
*/
|
|
205
|
+
function dedupeRoutesByUrlPath(routes: RouteEntry[]): RouteEntry[] {
|
|
206
|
+
const byPath = new Map<string, RouteEntry>();
|
|
207
|
+
for (const route of routes) {
|
|
208
|
+
const existing = byPath.get(route.urlPath);
|
|
209
|
+
if (!existing) {
|
|
210
|
+
byPath.set(route.urlPath, route);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let winner = existing;
|
|
215
|
+
let loser = route;
|
|
216
|
+
const routeWins =
|
|
217
|
+
(existing.isSlotRoute && !route.isSlotRoute) ||
|
|
218
|
+
(!!existing.isSlotRoute === !!route.isSlotRoute &&
|
|
219
|
+
route.segmentPath.localeCompare(existing.segmentPath) < 0);
|
|
220
|
+
if (routeWins) {
|
|
221
|
+
winner = route;
|
|
222
|
+
loser = existing;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!winner.hasSearchParams && loser.hasSearchParams) {
|
|
226
|
+
winner = {
|
|
227
|
+
...winner,
|
|
228
|
+
hasSearchParams: true,
|
|
229
|
+
searchParamsPagePath: loser.searchParamsPagePath,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
byPath.set(route.urlPath, winner);
|
|
233
|
+
}
|
|
234
|
+
return [...byPath.values()];
|
|
235
|
+
}
|
|
236
|
+
|
|
187
237
|
/**
|
|
188
238
|
* Determine the TypeScript type for a segment's param.
|
|
189
239
|
*/
|
|
@@ -366,10 +416,14 @@ function formatDeclarationFile(
|
|
|
366
416
|
lines.push('');
|
|
367
417
|
}
|
|
368
418
|
|
|
419
|
+
// urlPath-keyed emissions use the deduped list (slot pages share their
|
|
420
|
+
// parent's urlPath); segmentPath-keyed emissions below keep all entries.
|
|
421
|
+
const uniqueByUrlPath = dedupeRoutesByUrlPath(routes);
|
|
422
|
+
|
|
369
423
|
lines.push("declare module '@timber-js/app' {");
|
|
370
424
|
lines.push(' interface Routes {');
|
|
371
425
|
|
|
372
|
-
for (const route of
|
|
426
|
+
for (const route of uniqueByUrlPath) {
|
|
373
427
|
const paramsType = formatParamsType(route.params, importBase, hasSchema);
|
|
374
428
|
const searchParamsType = formatSearchParamsType(route, importBase);
|
|
375
429
|
|
|
@@ -393,7 +447,10 @@ function formatDeclarationFile(
|
|
|
393
447
|
lines.push('}');
|
|
394
448
|
lines.push('');
|
|
395
449
|
|
|
396
|
-
|
|
450
|
+
// useQueryStates + typed Link are urlPath-keyed → deduped list.
|
|
451
|
+
const pageRoutes = uniqueByUrlPath.filter((r) => !r.isApiRoute);
|
|
452
|
+
// getSegmentParams / useSegmentParams are segmentPath-keyed → full list,
|
|
453
|
+
// so slot segments keep their own typed overloads.
|
|
397
454
|
const dynamicRoutes = routes.filter((r) => r.params.length > 0);
|
|
398
455
|
|
|
399
456
|
// Generate @timber-js/app/server augmentation — typed getSegmentParams
|
package/src/routing/scanner.ts
CHANGED
|
@@ -177,44 +177,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
177
177
|
|
|
178
178
|
// Page-extension conventions (page, layout, error, default, denied)
|
|
179
179
|
if (PAGE_EXT_CONVENTIONS.has(name) && extSet.has(ext)) {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
break;
|
|
185
|
-
case 'layout':
|
|
186
|
-
node.layout = file;
|
|
187
|
-
break;
|
|
188
|
-
case 'error':
|
|
189
|
-
node.error = file;
|
|
190
|
-
break;
|
|
191
|
-
case 'default':
|
|
192
|
-
node.default = file;
|
|
193
|
-
break;
|
|
194
|
-
case 'denied':
|
|
195
|
-
node.denied = file;
|
|
196
|
-
break;
|
|
180
|
+
const key = name as 'page' | 'layout' | 'error' | 'default' | 'denied';
|
|
181
|
+
const existing = node[key];
|
|
182
|
+
if (existing) {
|
|
183
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
197
184
|
}
|
|
185
|
+
node[key] = { filePath: fullPath, extension: ext };
|
|
198
186
|
continue;
|
|
199
187
|
}
|
|
200
188
|
|
|
201
|
-
// Fixed conventions (middleware, access, route) —
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
break;
|
|
211
|
-
case 'route':
|
|
212
|
-
node.route = file;
|
|
213
|
-
break;
|
|
214
|
-
case 'params':
|
|
215
|
-
node.params = file;
|
|
216
|
-
break;
|
|
189
|
+
// Fixed conventions (middleware, access, route, params) — only
|
|
190
|
+
// .ts/.tsx/.js/.jsx, never pageExtensions like .mdx. The regex is
|
|
191
|
+
// anchored: mts/cts/mjs/cjs (and stray tail matches like `.foots`)
|
|
192
|
+
// are NOT recognized. See design/07-routing.md §"Page Extensions".
|
|
193
|
+
if (FIXED_CONVENTIONS.has(name) && /^[jt]sx?$/.test(ext)) {
|
|
194
|
+
const key = name as 'middleware' | 'access' | 'route' | 'params';
|
|
195
|
+
const existing = node[key];
|
|
196
|
+
if (existing) {
|
|
197
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
217
198
|
}
|
|
199
|
+
node[key] = { filePath: fullPath, extension: ext };
|
|
218
200
|
continue;
|
|
219
201
|
}
|
|
220
202
|
|
|
@@ -233,6 +215,10 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
233
215
|
if (!node.statusFiles) {
|
|
234
216
|
node.statusFiles = {};
|
|
235
217
|
}
|
|
218
|
+
const existing = node.statusFiles[name];
|
|
219
|
+
if (existing) {
|
|
220
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
221
|
+
}
|
|
236
222
|
node.statusFiles[name] = { filePath: fullPath, extension: ext };
|
|
237
223
|
continue;
|
|
238
224
|
}
|
|
@@ -242,6 +228,10 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
242
228
|
if (!node.legacyStatusFiles) {
|
|
243
229
|
node.legacyStatusFiles = {};
|
|
244
230
|
}
|
|
231
|
+
const existing = node.legacyStatusFiles[name];
|
|
232
|
+
if (existing) {
|
|
233
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
234
|
+
}
|
|
245
235
|
node.legacyStatusFiles[name] = { filePath: fullPath, extension: ext };
|
|
246
236
|
continue;
|
|
247
237
|
}
|
|
@@ -249,6 +239,8 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
249
239
|
// Metadata route files (sitemap.ts, robots.ts, icon.tsx, opengraph-image.tsx, etc.)
|
|
250
240
|
// Both static (.xml, .txt, .png, .ico, etc.) and dynamic (.ts, .tsx) files are recognized.
|
|
251
241
|
// When both exist for the same base name, dynamic takes precedence.
|
|
242
|
+
// Two files of the SAME kind (static+static or dynamic+dynamic) are a
|
|
243
|
+
// build error — picking one by readdir order is platform-dependent.
|
|
252
244
|
// See design/16-metadata.md §"Metadata Routes"
|
|
253
245
|
const metaInfo = classifyMetadataRoute(entry);
|
|
254
246
|
if (metaInfo) {
|
|
@@ -257,11 +249,13 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
257
249
|
}
|
|
258
250
|
const existing = node.metadataRoutes[name];
|
|
259
251
|
if (existing) {
|
|
260
|
-
// Dynamic > static precedence: only overwrite if the new file is dynamic
|
|
261
|
-
// or the existing file is static (dynamic always wins).
|
|
262
252
|
const existingIsDynamic = isDynamicMetadataExtension(name, existing.extension);
|
|
263
253
|
const newIsDynamic = isDynamicMetadataExtension(name, ext);
|
|
264
|
-
if (
|
|
254
|
+
if (existingIsDynamic === newIsDynamic) {
|
|
255
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
256
|
+
}
|
|
257
|
+
// Dynamic > static precedence: overwrite only when the new file is dynamic.
|
|
258
|
+
if (newIsDynamic) {
|
|
265
259
|
node.metadataRoutes[name] = { filePath: fullPath, extension: ext };
|
|
266
260
|
}
|
|
267
261
|
} else {
|
|
@@ -281,6 +275,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
281
275
|
}
|
|
282
276
|
}
|
|
283
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Throw a build error for two files claiming the same convention slot in
|
|
280
|
+
* one segment (e.g. page.tsx + page.jsx). Which file readdir returns first
|
|
281
|
+
* is platform-dependent (APFS sorts entries, ext4 does not) — silently
|
|
282
|
+
* picking one would let macOS dev and Linux CI serve different
|
|
283
|
+
* implementations of the same route.
|
|
284
|
+
*/
|
|
285
|
+
function throwDuplicateConvention(
|
|
286
|
+
convention: string,
|
|
287
|
+
firstPath: string,
|
|
288
|
+
secondPath: string
|
|
289
|
+
): never {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Build error: duplicate '${convention}' files in the same route segment.\n` +
|
|
292
|
+
` File 1: ${firstPath}\n` +
|
|
293
|
+
` File 2: ${secondPath}\n` +
|
|
294
|
+
`Each segment may define '${convention}' at most once. Remove one of the files.`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
284
298
|
/**
|
|
285
299
|
* Recursively scan child directories and build the segment tree.
|
|
286
300
|
*/
|
|
@@ -359,57 +373,60 @@ function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<stri
|
|
|
359
373
|
* Two route groups like (auth)/login/page.tsx and (marketing)/login/page.tsx both claim
|
|
360
374
|
* /login — the scanner must detect and reject this at build time.
|
|
361
375
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
376
|
+
* Each parallel slot is validated as its OWN collision space: a slot's pages
|
|
377
|
+
* intentionally share URL paths with the parent tree (that's the point of
|
|
378
|
+
* parallel routes), but two route groups INSIDE one slot claiming the same
|
|
379
|
+
* URL — e.g. @side/(a)/x/page.tsx + @side/(b)/x/page.tsx — still collide.
|
|
364
380
|
*/
|
|
365
381
|
function validateRouteGroupCollisions(root: SegmentNode): void {
|
|
366
382
|
// Map from urlPath → { filePath, source } for the first page/route seen at that path
|
|
367
|
-
|
|
368
|
-
collectRoutableLeaves(root, seen, '', false);
|
|
383
|
+
collectRoutableLeaves(root, new Map(), '');
|
|
369
384
|
}
|
|
370
385
|
|
|
371
386
|
/**
|
|
372
387
|
* Walk the segment tree and collect all routable leaves (page or route files),
|
|
373
|
-
* throwing on collision.
|
|
374
|
-
*
|
|
388
|
+
* throwing on collision. Each slot recurses with a FRESH collision map —
|
|
389
|
+
* slots are parallel routes that intentionally share URL paths with their
|
|
390
|
+
* parent tree, but collisions within a single slot are real errors.
|
|
375
391
|
*/
|
|
376
392
|
function collectRoutableLeaves(
|
|
377
393
|
node: SegmentNode,
|
|
378
394
|
seen: Map<string, { filePath: string; segmentPath: string }>,
|
|
379
|
-
segmentPath: string
|
|
380
|
-
insideSlot: boolean
|
|
395
|
+
segmentPath: string
|
|
381
396
|
): void {
|
|
397
|
+
// Intercepting routes are resolved by the slot resolver, not URL matching,
|
|
398
|
+
// and their computed urlPaths intentionally mirror other routes (e.g. two
|
|
399
|
+
// interceptors in one slot both produce /gallery/[id]). Skip them entirely.
|
|
400
|
+
if (node.segmentType === 'intercepting') return;
|
|
401
|
+
|
|
382
402
|
const currentPath = segmentPath
|
|
383
403
|
? `${segmentPath}/${node.segmentName}`
|
|
384
404
|
: node.segmentName || '(root)';
|
|
385
405
|
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
const
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
`
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
`Rename or move one of the conflicting files.`
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });
|
|
406
|
+
const routableFile = node.page ?? node.route;
|
|
407
|
+
if (routableFile) {
|
|
408
|
+
const existing = seen.get(node.urlPath);
|
|
409
|
+
if (existing) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`Build error: route collision — multiple route groups produce a page/route at the same URL path.\n` +
|
|
412
|
+
` URL path: ${node.urlPath}\n` +
|
|
413
|
+
` File 1: ${existing.filePath} (via ${existing.segmentPath})\n` +
|
|
414
|
+
` File 2: ${routableFile.filePath} (via ${currentPath})\n` +
|
|
415
|
+
`Each URL path must map to exactly one page or route handler. ` +
|
|
416
|
+
`Rename or move one of the conflicting files.`
|
|
417
|
+
);
|
|
402
418
|
}
|
|
419
|
+
seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });
|
|
403
420
|
}
|
|
404
421
|
|
|
405
422
|
// Recurse into children
|
|
406
423
|
for (const child of node.children) {
|
|
407
|
-
collectRoutableLeaves(child, seen, currentPath
|
|
424
|
+
collectRoutableLeaves(child, seen, currentPath);
|
|
408
425
|
}
|
|
409
426
|
|
|
410
|
-
// Recurse into slots — each slot
|
|
427
|
+
// Recurse into slots — each slot gets a fresh map (its own collision space)
|
|
411
428
|
for (const slotNode of Object.values(node.slots)) {
|
|
412
|
-
collectRoutableLeaves(slotNode,
|
|
429
|
+
collectRoutableLeaves(slotNode, new Map(), currentPath);
|
|
413
430
|
}
|
|
414
431
|
}
|
|
415
432
|
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from './sensitive-fields.js';
|
|
36
36
|
import type { FormFlashData } from './form-flash.js';
|
|
37
37
|
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
38
|
+
import { encodeHeadHeader, HEAD_HEADER } from '../shared/head-header.js';
|
|
38
39
|
import { logActionError } from './logger.js';
|
|
39
40
|
|
|
40
41
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
@@ -299,9 +300,16 @@ async function handleRscAction(
|
|
|
299
300
|
_tree: result.revalidation.element,
|
|
300
301
|
};
|
|
301
302
|
headers['X-Timber-Revalidation'] = '1';
|
|
302
|
-
// Forward head elements
|
|
303
|
+
// Forward head elements (percent-encoded JSON) so the client can update
|
|
304
|
+
// <head>. Raw JSON.stringify is NOT safe here: header values must be
|
|
305
|
+
// ByteStrings, so a title with an em dash or emoji would make
|
|
306
|
+
// `new Response(...)` throw — a 500 after the action's mutation already
|
|
307
|
+
// committed, dropping Set-Cookie too. See TIM-1058.
|
|
303
308
|
if (result.revalidation.headElements.length > 0) {
|
|
304
|
-
|
|
309
|
+
const encodedHead = encodeHeadHeader(result.revalidation.headElements);
|
|
310
|
+
if (encodedHead !== null) {
|
|
311
|
+
headers[HEAD_HEADER] = encodedHead;
|
|
312
|
+
}
|
|
305
313
|
}
|
|
306
314
|
} else {
|
|
307
315
|
payload = result.actionResult;
|
package/src/server/actions.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { getCacheHandler } from '../cache/handler-store';
|
|
|
18
18
|
import { DenySignal, RedirectSignal } from './primitives';
|
|
19
19
|
import { withSpan } from './tracing';
|
|
20
20
|
import { revalidationAls, type RevalidationState } from './als-registry.js';
|
|
21
|
+
import type { HeadElementPayload } from '../shared/head-header.js';
|
|
21
22
|
|
|
22
23
|
// ─── Types ───────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -25,8 +26,8 @@ import { revalidationAls, type RevalidationState } from './als-registry.js';
|
|
|
25
26
|
export interface RevalidationResult {
|
|
26
27
|
/** React element tree (pre-serialization — passed to renderToReadableStream). */
|
|
27
28
|
element: unknown;
|
|
28
|
-
/** Resolved head elements for metadata. */
|
|
29
|
-
headElements:
|
|
29
|
+
/** Resolved head elements for metadata (forwarded via X-Timber-Head). */
|
|
30
|
+
headElements: HeadElementPayload[];
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** Renderer function that builds a React element tree for a given path. */
|
package/src/server/compress.ts
CHANGED
|
@@ -174,7 +174,7 @@ export function compressResponse(request: Request, response: Response): Response
|
|
|
174
174
|
// progressively.
|
|
175
175
|
|
|
176
176
|
import { createGzip, constants } from 'node:zlib';
|
|
177
|
-
import { Readable } from 'node:stream';
|
|
177
|
+
import { Readable, pipeline } from 'node:stream';
|
|
178
178
|
|
|
179
179
|
/**
|
|
180
180
|
* Compress a ReadableStream with gzip, flushing each chunk immediately.
|
|
@@ -186,7 +186,16 @@ import { Readable } from 'node:stream';
|
|
|
186
186
|
function compressWithGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
187
187
|
const gzip = createGzip({ flush: constants.Z_SYNC_FLUSH });
|
|
188
188
|
const nodeInput = Readable.fromWeb(body as import('stream/web').ReadableStream);
|
|
189
|
-
|
|
189
|
+
|
|
190
|
+
// pipeline() instead of bare pipe(): pipe() does not forward source errors,
|
|
191
|
+
// so an erroring body (streaming route handler, dropped upstream fetch)
|
|
192
|
+
// would emit an unhandled 'error' event and crash the process. pipeline()
|
|
193
|
+
// destroys gzip on source error — the returned web stream rejects — and
|
|
194
|
+
// tears down nodeInput when the consumer cancels the web stream, which
|
|
195
|
+
// cancels the source body via fromWeb. The error reaches the consumer
|
|
196
|
+
// through the web stream; the callback only marks it handled at the Node
|
|
197
|
+
// layer.
|
|
198
|
+
pipeline(nodeInput, gzip, () => {});
|
|
190
199
|
|
|
191
200
|
return Readable.toWeb(gzip) as ReadableStream<Uint8Array>;
|
|
192
201
|
}
|
|
@@ -195,6 +195,11 @@ export function getCookieJar(): RequestCookies {
|
|
|
195
195
|
maxAge: 0,
|
|
196
196
|
expires: new Date(0),
|
|
197
197
|
};
|
|
198
|
+
// delete() is a jar entry point like set() — caller-supplied
|
|
199
|
+
// path/domain are serialized verbatim into the Set-Cookie header,
|
|
200
|
+
// so they must pass the same attribute-injection guard. See
|
|
201
|
+
// design/29-cookies.md §"Cookie Attribute Validation" (TIM-1026).
|
|
202
|
+
assertValidCookieOptions(opts);
|
|
198
203
|
store.cookieJar.set(name, { name, value: '', options: opts });
|
|
199
204
|
// Remove from read view
|
|
200
205
|
map.delete(name);
|
|
@@ -460,6 +465,12 @@ function setRaw(
|
|
|
460
465
|
// the cookie jar / RYW map.
|
|
461
466
|
assertValidCookieName(name);
|
|
462
467
|
assertValidCookieValue(name, value);
|
|
468
|
+
// Options get the same entry-point guard as set()/delete(). For the
|
|
469
|
+
// setFromHeaders path this can only fire on attributes parseSetCookie
|
|
470
|
+
// cannot produce (it splits on `;` and normalizes Max-Age/SameSite),
|
|
471
|
+
// but setRaw is the jar entry point — the invariant lives here, not in
|
|
472
|
+
// the caller. See design/29-cookies.md §"Cookie Attribute Validation".
|
|
473
|
+
assertValidCookieOptions(options);
|
|
463
474
|
store.cookieJar.set(name, { name, value, options });
|
|
464
475
|
// Deletion cookies (Max-Age=0) should not appear in the read map.
|
|
465
476
|
if (options.maxAge === 0) {
|
|
@@ -110,15 +110,27 @@ export function parseSetCookie(
|
|
|
110
110
|
case 'domain':
|
|
111
111
|
options.domain = val;
|
|
112
112
|
break;
|
|
113
|
-
case 'max-age':
|
|
114
|
-
|
|
113
|
+
case 'max-age': {
|
|
114
|
+
// RFC 6265 §5.2.2: a non-numeric Max-Age is ignored, not fatal.
|
|
115
|
+
// Dropping NaN/Infinity here keeps this parser total and keeps
|
|
116
|
+
// `Max-Age=NaN` out of the serialized Set-Cookie header.
|
|
117
|
+
const maxAge = Number(val);
|
|
118
|
+
if (Number.isFinite(maxAge)) options.maxAge = maxAge;
|
|
115
119
|
break;
|
|
120
|
+
}
|
|
116
121
|
case 'expires':
|
|
117
122
|
options.expires = new Date(val);
|
|
118
123
|
break;
|
|
119
|
-
case 'samesite':
|
|
120
|
-
|
|
124
|
+
case 'samesite': {
|
|
125
|
+
// Unknown SameSite values are ignored (browsers treat them as
|
|
126
|
+
// unspecified) so forwarding a quirky upstream header via
|
|
127
|
+
// setFromHeaders never trips the entry-point validator.
|
|
128
|
+
const sameSite = val.toLowerCase();
|
|
129
|
+
if (sameSite === 'strict' || sameSite === 'lax' || sameSite === 'none') {
|
|
130
|
+
options.sameSite = sameSite;
|
|
131
|
+
}
|
|
121
132
|
break;
|
|
133
|
+
}
|
|
122
134
|
case 'secure':
|
|
123
135
|
options.secure = true;
|
|
124
136
|
break;
|
|
@@ -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
|
/**
|