@timber-js/app 0.2.0-alpha.147 → 0.2.0-alpha.149
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/LICENSE +8 -0
- 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-CQaxupHv.js → cli-schema-sync-DJDdyEae.js} +111 -260
- 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-Ck4Y45XN.js → walkers-DvWDj453.js} +107 -82
- 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/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 +610 -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/plugins/server-bundle.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-renderer.d.ts +1 -1
- package/dist/server/deny-renderer.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 +12 -171
- 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/shared/href-validation.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +7 -8
- 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/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/plugins/server-bundle.ts +1 -0
- package/src/routing/codegen.ts +3 -2
- 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 +5 -17
- 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-renderer.ts +6 -0
- 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/rsc-entry/error-renderer.ts +1 -0
- 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/shared/href-validation.ts +8 -0
- package/dist/_chunks/cli-schema-sync-CBiMEtqa.js +0 -2
- package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +0 -1
- 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-Ck4Y45XN.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
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.
|
|
@@ -20,6 +20,7 @@ import { renderToReadableStream } from '../rsc-runtime/rsc.js';
|
|
|
20
20
|
|
|
21
21
|
import { DenySignal } from './primitives.js';
|
|
22
22
|
import { renderDenyEntry, type DenyPageEntry } from './deny-boundary.js';
|
|
23
|
+
import { getCookiesForSsr } from './cookie-context.js';
|
|
23
24
|
import { logRenderError } from './logger.js';
|
|
24
25
|
import { loadModule } from './safe-load.js';
|
|
25
26
|
import { isMdxFilePath } from './utils/mdx-file.js';
|
|
@@ -184,6 +185,7 @@ export async function renderDenyPage(
|
|
|
184
185
|
|
|
185
186
|
// Render the error page to a fresh RSC Flight stream.
|
|
186
187
|
const rscStream = renderToReadableStream(element, {
|
|
188
|
+
signal: req.signal,
|
|
187
189
|
onError(error: unknown) {
|
|
188
190
|
logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
|
|
189
191
|
},
|
|
@@ -201,6 +203,8 @@ export async function renderDenyPage(
|
|
|
201
203
|
headHtml: headHtml + flightInitScript(),
|
|
202
204
|
bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
|
|
203
205
|
rscStream: inlineStream,
|
|
206
|
+
signal: req.signal,
|
|
207
|
+
cookies: getCookiesForSsr(),
|
|
204
208
|
};
|
|
205
209
|
|
|
206
210
|
return callSsr(ssrStream, navContext);
|
|
@@ -216,6 +220,7 @@ export async function renderDenyPageAsRsc(
|
|
|
216
220
|
deny: DenySignal,
|
|
217
221
|
segments: ManifestSegmentNode[],
|
|
218
222
|
layoutComponents: LayoutEntry[],
|
|
223
|
+
req: Request,
|
|
219
224
|
responseHeaders: Headers,
|
|
220
225
|
createDebugChannelSink: DebugChannelFactory
|
|
221
226
|
): Promise<Response> {
|
|
@@ -251,6 +256,7 @@ export async function renderDenyPageAsRsc(
|
|
|
251
256
|
}
|
|
252
257
|
|
|
253
258
|
const rscStream = renderToReadableStream(element, {
|
|
259
|
+
signal: req.signal,
|
|
254
260
|
onError(error: unknown) {
|
|
255
261
|
console.error('[timber] Error page RSC render error:', error);
|
|
256
262
|
},
|
|
@@ -302,6 +302,7 @@ export class FlightInjectorCore {
|
|
|
302
302
|
private readonly errorSignal?: AbortSignal;
|
|
303
303
|
private readonly decoder = new TextDecoder('utf-8', { fatal: true });
|
|
304
304
|
private pullPromise: Promise<void> | null = null;
|
|
305
|
+
private _aborted = false;
|
|
305
306
|
|
|
306
307
|
constructor(
|
|
307
308
|
rscStream: ReadableStream<Uint8Array>,
|
|
@@ -363,6 +364,22 @@ export class FlightInjectorCore {
|
|
|
363
364
|
return this.machine.state.phase === 'error' ? this.machine.state.error : null;
|
|
364
365
|
}
|
|
365
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Abort the pull loop and release the RSC reader. Called by stream
|
|
369
|
+
* wrappers when the HTML pipeline is torn down (Node `destroy()`,
|
|
370
|
+
* Web `cancel()`). Safe to call multiple times.
|
|
371
|
+
*/
|
|
372
|
+
abort(reason?: unknown): void {
|
|
373
|
+
if (this._aborted) return;
|
|
374
|
+
this._aborted = true;
|
|
375
|
+
this.rscReader.cancel(reason).catch(() => {});
|
|
376
|
+
this.pending.length = 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
get isAborted(): boolean {
|
|
380
|
+
return this._aborted;
|
|
381
|
+
}
|
|
382
|
+
|
|
366
383
|
private async runPullLoop(onYieldDrain: () => void): Promise<void> {
|
|
367
384
|
// Yield once so the first HTML shell chunk flows through the wrapper
|
|
368
385
|
// before we start reading RSC data.
|
|
@@ -370,6 +387,8 @@ export class FlightInjectorCore {
|
|
|
370
387
|
|
|
371
388
|
try {
|
|
372
389
|
for (;;) {
|
|
390
|
+
if (this._aborted) return;
|
|
391
|
+
|
|
373
392
|
// Guard each read with a timeout so a permanently hung RSC stream
|
|
374
393
|
// eventually aborts. See design/02 §"Streaming Constraints".
|
|
375
394
|
const readPromise = this.rscReader.read();
|
|
@@ -271,8 +271,11 @@ function createFlightInjectionTransform(
|
|
|
271
271
|
if (_controller) drain(_controller);
|
|
272
272
|
};
|
|
273
273
|
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
// The cast is needed because TypeScript's lib types don't include the
|
|
275
|
+
// `cancel` method on Transformer, even though it's part of the WHATWG
|
|
276
|
+
// Streams spec and supported in all target runtimes.
|
|
277
|
+
const transformer = {
|
|
278
|
+
transform(chunk: Uint8Array, controller: TransformStreamDefaultController<Uint8Array>) {
|
|
276
279
|
_controller = controller;
|
|
277
280
|
|
|
278
281
|
if (core.isInit) {
|
|
@@ -287,7 +290,7 @@ function createFlightInjectionTransform(
|
|
|
287
290
|
controller.enqueue(chunk);
|
|
288
291
|
if (core.pending.length > 0) drain(controller);
|
|
289
292
|
},
|
|
290
|
-
flush(controller) {
|
|
293
|
+
flush(controller: TransformStreamDefaultController<Uint8Array>) {
|
|
291
294
|
// All HTML chunks emitted. Pull loop stops yielding.
|
|
292
295
|
core.notifyHtmlDone();
|
|
293
296
|
|
|
@@ -299,7 +302,14 @@ function createFlightInjectionTransform(
|
|
|
299
302
|
}
|
|
300
303
|
return core.ensurePullLoop(onYieldDrain).then(finish);
|
|
301
304
|
},
|
|
302
|
-
|
|
305
|
+
cancel(reason: unknown) {
|
|
306
|
+
core.abort(reason ?? 'transform cancelled');
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return new TransformStream<Uint8Array, Uint8Array>(
|
|
311
|
+
transformer as Transformer<Uint8Array, Uint8Array>
|
|
312
|
+
);
|
|
303
313
|
}
|
|
304
314
|
|
|
305
315
|
/**
|
package/src/server/internal.ts
CHANGED
|
@@ -86,10 +86,6 @@ export type {
|
|
|
86
86
|
SlotDeniedKind,
|
|
87
87
|
} from './status-code-resolver.js';
|
|
88
88
|
|
|
89
|
-
// ── Flush Controller ─────────────────────────────────────────────────────
|
|
90
|
-
export { flushResponse } from './flush.js';
|
|
91
|
-
export type { FlushOptions, FlushResult, RenderFn, RenderResult } from './flush.js';
|
|
92
|
-
|
|
93
89
|
// ── CSRF Protection ──────────────────────────────────────────────────────
|
|
94
90
|
export { validateCsrf } from './csrf.js';
|
|
95
91
|
export type { CsrfConfig, CsrfResult } from './csrf.js';
|
|
@@ -123,7 +119,7 @@ export type {
|
|
|
123
119
|
} from './metadata-routes.js';
|
|
124
120
|
|
|
125
121
|
// ── Server Actions (pipeline internals) ──────────────────────────────────
|
|
126
|
-
export { executeAction
|
|
122
|
+
export { executeAction } from './actions.js';
|
|
127
123
|
export type {
|
|
128
124
|
RevalidateRenderer,
|
|
129
125
|
RevalidationState,
|
|
@@ -160,19 +156,6 @@ export type {
|
|
|
160
156
|
InstrumentationErrorContext,
|
|
161
157
|
} from './instrumentation.js';
|
|
162
158
|
|
|
163
|
-
// ── Dev Warnings ─────────────────────────────────────────────────────────
|
|
164
|
-
export {
|
|
165
|
-
warnSuspenseWrappingChildren,
|
|
166
|
-
warnDenyInSuspense,
|
|
167
|
-
warnRedirectInSuspense,
|
|
168
|
-
warnRedirectInAccess,
|
|
169
|
-
warnStaticRequestApi,
|
|
170
|
-
warnSlowSlotWithoutSuspense,
|
|
171
|
-
setViteServer,
|
|
172
|
-
WarningId,
|
|
173
|
-
} from '../dev-tools/warnings.js';
|
|
174
|
-
export type { DevWarningConfig } from '../dev-tools/warnings.js';
|
|
175
|
-
|
|
176
159
|
// ── Route Handler ────────────────────────────────────────────────────────
|
|
177
160
|
export { handleRouteRequest, resolveAllowedMethods } from './route-handler.js';
|
|
178
161
|
export type { RouteModule, RouteHandler, HttpMethod } from './route-handler.js';
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
|
|
21
21
|
import { PassThrough, Transform, type Readable } from 'node:stream';
|
|
22
22
|
import { pipeline } from 'node:stream/promises';
|
|
23
|
-
import { createGzip, constants } from 'node:zlib';
|
|
24
23
|
|
|
25
24
|
import {
|
|
26
25
|
BufferAggregator,
|
|
@@ -35,6 +34,7 @@ import {
|
|
|
35
34
|
} from './html-injector-core.js';
|
|
36
35
|
import { logStreamingError } from './logger.js';
|
|
37
36
|
import { createTickCoalescer } from './coalesce-tick.js';
|
|
37
|
+
import { isAbortError, NOINDEX_SCRIPT } from './render-utils.js';
|
|
38
38
|
|
|
39
39
|
// ─── Buffered Transform ──────────────────────────────────────────────────────
|
|
40
40
|
|
|
@@ -229,6 +229,10 @@ export function createNodeFlightInjector(
|
|
|
229
229
|
if (wasInit) core.ensurePullLoop(drain);
|
|
230
230
|
callback();
|
|
231
231
|
},
|
|
232
|
+
destroy(err, callback) {
|
|
233
|
+
core.abort(err ?? 'pipeline destroyed');
|
|
234
|
+
callback(err);
|
|
235
|
+
},
|
|
232
236
|
flush(callback) {
|
|
233
237
|
// All HTML chunks have been emitted. Pull loop stops yielding.
|
|
234
238
|
core.notifyHtmlDone();
|
|
@@ -263,15 +267,6 @@ export function createNodeFlightInjector(
|
|
|
263
267
|
|
|
264
268
|
// ─── Error Handling ──────────────────────────────────────────────────────────
|
|
265
269
|
|
|
266
|
-
const NOINDEX_SCRIPT =
|
|
267
|
-
'<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
|
|
268
|
-
|
|
269
|
-
function isAbortError(error: unknown): boolean {
|
|
270
|
-
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
|
271
|
-
if (error instanceof Error && error.name === 'AbortError') return true;
|
|
272
|
-
return false;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
270
|
/**
|
|
276
271
|
* Run the Node SSR post-processing pipeline with post-shell error handling.
|
|
277
272
|
*
|
|
@@ -334,65 +329,3 @@ export function runNodePipelineWithErrorHandling(
|
|
|
334
329
|
|
|
335
330
|
return output;
|
|
336
331
|
}
|
|
337
|
-
|
|
338
|
-
// ─── Compression ─────────────────────────────────────────────────────────────
|
|
339
|
-
|
|
340
|
-
const COMPRESSIBLE_TYPES = new Set([
|
|
341
|
-
'text/html',
|
|
342
|
-
'text/css',
|
|
343
|
-
'text/plain',
|
|
344
|
-
'text/xml',
|
|
345
|
-
'text/javascript',
|
|
346
|
-
'text/x-component',
|
|
347
|
-
'application/json',
|
|
348
|
-
'application/javascript',
|
|
349
|
-
'application/xml',
|
|
350
|
-
'application/xhtml+xml',
|
|
351
|
-
'application/rss+xml',
|
|
352
|
-
'image/svg+xml',
|
|
353
|
-
]);
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Create a Node.js gzip Transform using native node:zlib.
|
|
357
|
-
*
|
|
358
|
-
* Uses `createGzip()` which is backed by C++ zlib — significantly faster
|
|
359
|
-
* than the Web Streams `CompressionStream` API (which is a JS wrapper
|
|
360
|
-
* around the same zlib but with per-chunk Promise overhead).
|
|
361
|
-
*
|
|
362
|
-
* Returns null if the response shouldn't be compressed (wrong content type,
|
|
363
|
-
* client doesn't accept gzip, already encoded, etc.).
|
|
364
|
-
*/
|
|
365
|
-
export function createNodeGzipCompressor(
|
|
366
|
-
requestHeaders: Headers,
|
|
367
|
-
responseHeaders: Headers
|
|
368
|
-
): Transform | null {
|
|
369
|
-
// Check Accept-Encoding
|
|
370
|
-
const acceptEncoding = requestHeaders.get('accept-encoding') || '';
|
|
371
|
-
if (!acceptEncoding.includes('gzip')) return null;
|
|
372
|
-
|
|
373
|
-
// Check content type is compressible
|
|
374
|
-
const contentType = responseHeaders.get('content-type') || '';
|
|
375
|
-
const mimeType = contentType.split(';')[0].trim().toLowerCase();
|
|
376
|
-
if (!COMPRESSIBLE_TYPES.has(mimeType)) return null;
|
|
377
|
-
|
|
378
|
-
// Don't double-compress
|
|
379
|
-
if (responseHeaders.has('content-encoding')) return null;
|
|
380
|
-
|
|
381
|
-
// Set response headers for gzip
|
|
382
|
-
responseHeaders.set('content-encoding', 'gzip');
|
|
383
|
-
responseHeaders.delete('content-length');
|
|
384
|
-
const existingVary = responseHeaders.get('vary');
|
|
385
|
-
if (existingVary) {
|
|
386
|
-
if (!existingVary.toLowerCase().includes('accept-encoding')) {
|
|
387
|
-
responseHeaders.set('vary', existingVary + ', Accept-Encoding');
|
|
388
|
-
}
|
|
389
|
-
} else {
|
|
390
|
-
responseHeaders.set('vary', 'Accept-Encoding');
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Z_SYNC_FLUSH ensures each chunk is flushed to the output immediately.
|
|
394
|
-
// Without it, gzip buffers internally and the browser doesn't receive
|
|
395
|
-
// the HTML shell until the gzip stream closes — breaking streaming.
|
|
396
|
-
// ~2–5% size overhead vs Z_NO_FLUSH but preserves correct streaming.
|
|
397
|
-
return createGzip({ flush: constants.Z_SYNC_FLUSH });
|
|
398
|
-
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared render-phase utilities — used by both the Node.js and Web
|
|
3
|
+
* stream wrappers. Pure functions only; no stream-shape dependencies.
|
|
4
|
+
*
|
|
5
|
+
* Consolidated from duplicated copies in node-stream-transforms.ts,
|
|
6
|
+
* ssr-render.ts, and rsc-entry/helpers.ts. See TIM-1071 item 1.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if an error is an abort error (connection closed by client).
|
|
11
|
+
*
|
|
12
|
+
* When the browser aborts a request (page refresh, navigation away),
|
|
13
|
+
* the AbortSignal fires and React/streams throw an AbortError. This
|
|
14
|
+
* is not an application error — suppress it from error boundaries and logs.
|
|
15
|
+
*/
|
|
16
|
+
export function isAbortError(error: unknown): boolean {
|
|
17
|
+
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
|
18
|
+
if (error instanceof Error && error.name === 'AbortError') return true;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Inline script that adds `<meta name="robots" content="noindex">` to the
|
|
24
|
+
* document head. Injected when a render-phase error replaces the shell so
|
|
25
|
+
* search engines don't index error pages.
|
|
26
|
+
*
|
|
27
|
+
* MUST be byte-identical across all stream wrappers (Node and Web) to
|
|
28
|
+
* ensure consistent Content-Length headers.
|
|
29
|
+
*/
|
|
30
|
+
export const NOINDEX_SCRIPT =
|
|
31
|
+
'<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
|
|
@@ -6,12 +6,11 @@
|
|
|
6
6
|
* rsc-entry.ts under the 500-line limit.
|
|
7
7
|
*
|
|
8
8
|
* This module handles:
|
|
9
|
-
* 1.
|
|
10
|
-
* 2.
|
|
11
|
-
* 3. Resolving metadata (
|
|
9
|
+
* 1. Running access.ts checks eagerly to gate metadata resolution (TIM-1027)
|
|
10
|
+
* 2. Loading page/layout components from the segment chain
|
|
11
|
+
* 3. Resolving metadata (skipped for denied segments to prevent side effects)
|
|
12
12
|
* 4. Building the React element tree (page → error boundaries → access gates → layouts)
|
|
13
13
|
* 5. Resolving parallel slots
|
|
14
|
-
* 6. Wrapping the tree with AccessPreRunner for React.cache-scoped access checks
|
|
15
14
|
*
|
|
16
15
|
* See design/02-rendering-pipeline.md, design/04-authorization.md
|
|
17
16
|
*/
|
|
@@ -154,24 +153,6 @@ export interface RouteElementResult {
|
|
|
154
153
|
skippedSegments: string[];
|
|
155
154
|
}
|
|
156
155
|
|
|
157
|
-
/**
|
|
158
|
-
* Wraps a DenySignal or RedirectSignal with the layout components loaded
|
|
159
|
-
* so far, enabling the caller to render deny pages inside the layout shell.
|
|
160
|
-
*
|
|
161
|
-
* @deprecated No longer thrown by buildRouteElement since TIM-662. Access
|
|
162
|
-
* checks now run inside AccessPreRunner during renderToReadableStream, and
|
|
163
|
-
* signals are caught by onError. Kept for backward compat with external code.
|
|
164
|
-
*/
|
|
165
|
-
export class RouteSignalWithContext extends Error {
|
|
166
|
-
constructor(
|
|
167
|
-
public readonly signal: DenySignal | RedirectSignal,
|
|
168
|
-
public readonly layoutComponents: LayoutComponentEntry[],
|
|
169
|
-
public readonly segments: ManifestSegmentNode[]
|
|
170
|
-
) {
|
|
171
|
-
super(signal.message);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
156
|
// ─── Module Processing Helpers ─────────────────────────────────────────────
|
|
176
157
|
|
|
177
158
|
/**
|
|
@@ -235,16 +216,18 @@ function extractDeferSuspenseFor(mod: Record<string, unknown>, current: number):
|
|
|
235
216
|
/**
|
|
236
217
|
* Build a React element tree from a matched route.
|
|
237
218
|
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
219
|
+
* Runs access checks eagerly to gate metadata resolution, then loads
|
|
220
|
+
* modules, resolves metadata (skipping denied segments), and constructs
|
|
221
|
+
* the element tree. DenySignal and RedirectSignal from access are stored
|
|
222
|
+
* as AccessGate verdicts for synchronous replay during render.
|
|
241
223
|
*
|
|
242
224
|
* Does NOT serialize to RSC Flight — the caller decides whether to render
|
|
243
225
|
* to a stream or use the element directly (e.g., for action revalidation).
|
|
244
226
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
227
|
+
* For passing segments, AccessGate re-runs access during render so that
|
|
228
|
+
* React.cache is populated for layout dedup (TIM-662). For denied
|
|
229
|
+
* segments, the pre-computed verdict is replayed — no re-execution,
|
|
230
|
+
* no metadata side effects, no head leak (TIM-1027).
|
|
248
231
|
*/
|
|
249
232
|
export async function buildRouteElement(
|
|
250
233
|
req: Request,
|
|
@@ -255,7 +238,46 @@ export async function buildRouteElement(
|
|
|
255
238
|
): Promise<RouteElementResult> {
|
|
256
239
|
const segments = match.segments;
|
|
257
240
|
|
|
258
|
-
//
|
|
241
|
+
// ── Access pre-pass ──────────────────────────────────────────────────
|
|
242
|
+
// Run access checks eagerly to determine which segments are denied.
|
|
243
|
+
// This gates metadata() resolution: denied segments' metadata functions
|
|
244
|
+
// are never called, preventing unauthorized side effects (DB queries)
|
|
245
|
+
// and metadata content leaking into the <head> of denied responses.
|
|
246
|
+
//
|
|
247
|
+
// Verdicts are passed to AccessGate for synchronous replay (DenySignal
|
|
248
|
+
// renders the deny page in-tree; RedirectSignal propagates). For PASSING
|
|
249
|
+
// segments, no verdict is passed — AccessGate re-runs access during
|
|
250
|
+
// renderToReadableStream to populate React.cache for layout dedup.
|
|
251
|
+
//
|
|
252
|
+
// See TIM-1027, design/04-authorization.md, design/13-security.md.
|
|
253
|
+
const accessVerdicts = new Map<number, DenySignal | RedirectSignal>();
|
|
254
|
+
let firstDeniedIndex = Infinity;
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < segments.length; i++) {
|
|
257
|
+
const segment = segments[i];
|
|
258
|
+
if (!segment.access || i >= firstDeniedIndex) continue;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const accessMod = await loadModule(segment.access);
|
|
262
|
+
const accessFn = accessMod.default as (() => Promise<void>) | undefined;
|
|
263
|
+
if (accessFn) {
|
|
264
|
+
await withSpan(
|
|
265
|
+
'timber.access.pre',
|
|
266
|
+
{ 'timber.segment': segment.segmentName ?? 'unknown' },
|
|
267
|
+
() => accessFn()
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
} catch (error: unknown) {
|
|
271
|
+
if (error instanceof DenySignal || error instanceof RedirectSignal) {
|
|
272
|
+
accessVerdicts.set(i, error);
|
|
273
|
+
firstDeniedIndex = i;
|
|
274
|
+
} else {
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Module loading + metadata resolution ─────────────────────────────
|
|
259
281
|
const metadataEntries: Array<{ metadata: Metadata; isPage: boolean }> = [];
|
|
260
282
|
const layoutComponents: LayoutComponentEntry[] = [];
|
|
261
283
|
let PageComponent: ((...args: unknown[]) => unknown) | null = null;
|
|
@@ -264,6 +286,7 @@ export async function buildRouteElement(
|
|
|
264
286
|
for (let i = 0; i < segments.length; i++) {
|
|
265
287
|
const segment = segments[i];
|
|
266
288
|
const isLeaf = i === segments.length - 1;
|
|
289
|
+
const isDenied = i >= firstDeniedIndex;
|
|
267
290
|
|
|
268
291
|
// Load layout
|
|
269
292
|
if (segment.layout) {
|
|
@@ -279,9 +302,11 @@ export async function buildRouteElement(
|
|
|
279
302
|
// middleware and rendering. See coerceSegmentParams() in pipeline.ts.
|
|
280
303
|
|
|
281
304
|
rejectLegacyGenerateMetadata(mod, segment.layout.filePath ?? segment.urlPath);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
305
|
+
if (!isDenied) {
|
|
306
|
+
const layoutMetadata = await extractMetadata(mod, segment);
|
|
307
|
+
if (layoutMetadata) {
|
|
308
|
+
metadataEntries.push({ metadata: layoutMetadata, isPage: false });
|
|
309
|
+
}
|
|
285
310
|
}
|
|
286
311
|
deferSuspenseFor = extractDeferSuspenseFor(mod, deferSuspenseFor);
|
|
287
312
|
}
|
|
@@ -297,9 +322,11 @@ export async function buildRouteElement(
|
|
|
297
322
|
PageComponent = mod.default as (...args: unknown[]) => unknown;
|
|
298
323
|
}
|
|
299
324
|
rejectLegacyGenerateMetadata(mod, segment.page.filePath ?? segment.urlPath);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
325
|
+
if (!isDenied) {
|
|
326
|
+
const pageMetadata = await extractMetadata(mod, segment);
|
|
327
|
+
if (pageMetadata) {
|
|
328
|
+
metadataEntries.push({ metadata: pageMetadata, isPage: true });
|
|
329
|
+
}
|
|
303
330
|
}
|
|
304
331
|
deferSuspenseFor = extractDeferSuspenseFor(mod, deferSuspenseFor);
|
|
305
332
|
}
|
|
@@ -317,16 +344,6 @@ export async function buildRouteElement(
|
|
|
317
344
|
);
|
|
318
345
|
}
|
|
319
346
|
|
|
320
|
-
// Access checks are NOT run here. AccessGate components in the element
|
|
321
|
-
// tree call accessFn directly during renderToReadableStream, sharing the
|
|
322
|
-
// same React.cache scope as layout/page components. A requireUser() call
|
|
323
|
-
// in access.ts populates React.cache; the same call in a layout is a hit.
|
|
324
|
-
//
|
|
325
|
-
// Previously (before TIM-662), access checks ran eagerly in a pre-render
|
|
326
|
-
// loop OUTSIDE renderToReadableStream, breaking React.cache dedup.
|
|
327
|
-
//
|
|
328
|
-
// See design/04-authorization.md §"Pre-Render Pass and Verdict Replay"
|
|
329
|
-
|
|
330
347
|
// Build deny page fallback chains for each segment position.
|
|
331
348
|
// When AccessGate or PageDenyBoundary catches a DenySignal, they render
|
|
332
349
|
// the matching deny page in-tree instead of throwing into React Flight.
|
|
@@ -359,8 +376,11 @@ export async function buildRouteElement(
|
|
|
359
376
|
? new URL(requestUrl.origin)
|
|
360
377
|
: resolvedMetadata.metadataBase;
|
|
361
378
|
|
|
362
|
-
for (
|
|
379
|
+
for (let si = 0; si < segments.length; si++) {
|
|
380
|
+
const segment = segments[si];
|
|
363
381
|
if (!segment.metadataRoutes) continue;
|
|
382
|
+
// Skip auto-linking for denied segments — same gate as metadata().
|
|
383
|
+
if (si >= firstDeniedIndex) continue;
|
|
364
384
|
for (const baseName of Object.keys(segment.metadataRoutes)) {
|
|
365
385
|
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
366
386
|
if (!convention) continue;
|
|
@@ -461,7 +481,7 @@ export async function buildRouteElement(
|
|
|
461
481
|
//
|
|
462
482
|
// When clientStateTree is provided (from X-Timber-State-Tree header on
|
|
463
483
|
// client navigation), sync layouts the client already has are skipped.
|
|
464
|
-
// Access.ts
|
|
484
|
+
// Access.ts was pre-checked eagerly above for metadata gating (TIM-1027).
|
|
465
485
|
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
466
486
|
//
|
|
467
487
|
// hasRenderedLayoutBelow tracks whether a non-skipped layout has been
|
|
@@ -517,10 +537,15 @@ export async function buildRouteElement(
|
|
|
517
537
|
const accessMod = await loadModule(segment.access);
|
|
518
538
|
const accessFn = accessMod.default as (() => unknown) | undefined;
|
|
519
539
|
if (accessFn) {
|
|
540
|
+
// Pass verdict for denied/redirected segments so AccessGate replays
|
|
541
|
+
// without re-execution. Passing segments omit verdict so AccessGate
|
|
542
|
+
// re-runs access during render for React.cache population.
|
|
543
|
+
const verdict = accessVerdicts.get(i);
|
|
520
544
|
element = h(AccessGate, {
|
|
521
545
|
accessFn,
|
|
522
546
|
segmentName: segment.segmentName,
|
|
523
547
|
denyPages: denyPageChains.get(i),
|
|
548
|
+
...(verdict ? { verdict } : {}),
|
|
524
549
|
children: element,
|
|
525
550
|
});
|
|
526
551
|
}
|
|
@@ -661,10 +686,15 @@ export async function buildRouteElement(
|
|
|
661
686
|
const accessMod = await loadModule(segment.access);
|
|
662
687
|
const accessFn = accessMod.default as (() => unknown) | undefined;
|
|
663
688
|
if (accessFn) {
|
|
689
|
+
// Pass verdict for denied/redirected segments so AccessGate replays
|
|
690
|
+
// without re-execution. Passing segments omit verdict so AccessGate
|
|
691
|
+
// re-runs access during render for React.cache population.
|
|
692
|
+
const verdict = accessVerdicts.get(i);
|
|
664
693
|
element = h(AccessGate, {
|
|
665
694
|
accessFn,
|
|
666
695
|
segmentName: segment.segmentName,
|
|
667
696
|
denyPages: denyPageChains.get(i),
|
|
697
|
+
...(verdict ? { verdict } : {}),
|
|
668
698
|
children: element,
|
|
669
699
|
});
|
|
670
700
|
}
|