@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
|
@@ -8,20 +8,7 @@
|
|
|
8
8
|
import { TimberErrorBoundary } from '../client/error-boundary.js';
|
|
9
9
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
10
10
|
import { loadModule } from './safe-load.js';
|
|
11
|
-
|
|
12
|
-
/** MDX/markdown extensions — server components that cannot be passed as function props. */
|
|
13
|
-
const MDX_EXTENSIONS = new Set(['.mdx', '.md']);
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Check if a manifest file path ends with an MDX/markdown extension.
|
|
17
|
-
* MDX components are server components and cannot cross the RSC→client
|
|
18
|
-
* boundary as function props to TimberErrorBoundary.
|
|
19
|
-
*/
|
|
20
|
-
function isMdxFilePath(filePath: string): boolean {
|
|
21
|
-
const dotIndex = filePath.lastIndexOf('.');
|
|
22
|
-
if (dotIndex === -1) return false;
|
|
23
|
-
return MDX_EXTENSIONS.has(filePath.slice(dotIndex));
|
|
24
|
-
}
|
|
11
|
+
import { isMdxFilePath } from './utils/mdx-file.js';
|
|
25
12
|
|
|
26
13
|
/**
|
|
27
14
|
* Wrap an element in error boundaries defined by a route segment.
|
|
@@ -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
|
}
|
|
@@ -185,7 +185,7 @@ function matchMetadataPathname(
|
|
|
185
185
|
// Non-nestable routes (robots, manifest, favicon) only serve from root
|
|
186
186
|
if (!convention.nestable && segment !== root) return null;
|
|
187
187
|
|
|
188
|
-
const file = findMetadataRouteInSegment(segment, baseName);
|
|
188
|
+
const file = findMetadataRouteInSegment(segment, baseName, convention.nestable);
|
|
189
189
|
if (!file) return null;
|
|
190
190
|
|
|
191
191
|
const ext = file.filePath.slice(file.filePath.lastIndexOf('.') + 1);
|
|
@@ -205,19 +205,35 @@ function matchMetadataPathname(
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
/**
|
|
208
|
-
* Find a metadata route file in a segment, including
|
|
209
|
-
*
|
|
208
|
+
* Find a metadata route file in a segment, including children that match
|
|
209
|
+
* zero URL segments at the segment's own path:
|
|
210
|
+
*
|
|
211
|
+
* - Group children are transparent — they don't add URL depth.
|
|
212
|
+
* - Optional catch-all children ([[...slug]]) match zero segments, so
|
|
213
|
+
* `app/docs/[[...path]]/icon.tsx` serves `/docs/icon`. The non-zero
|
|
214
|
+
* case is handled by the tree walker (requireTerminal:false), but the
|
|
215
|
+
* zero-segment case never reaches the walker — the parent node accepts
|
|
216
|
+
* the fully-consumed prefix first, so the fallback lives here.
|
|
217
|
+
*
|
|
218
|
+
* Optional catch-all descent applies to NESTABLE conventions only. A
|
|
219
|
+
* non-nestable file (robots, manifest, favicon) inside `[[...slug]]` is
|
|
220
|
+
* not at the root — serving it there would bypass the root-only rule.
|
|
221
|
+
* Group descent is unconditional: groups are transparent and a group
|
|
222
|
+
* child of the root IS at the root.
|
|
223
|
+
*
|
|
224
|
+
* The segment's own metadata file wins over a zero-segment child's.
|
|
210
225
|
*/
|
|
211
226
|
function findMetadataRouteInSegment(
|
|
212
227
|
node: ManifestSegmentNode,
|
|
213
|
-
baseName: string
|
|
228
|
+
baseName: string,
|
|
229
|
+
nestable: boolean
|
|
214
230
|
): ManifestFile | null {
|
|
215
231
|
if (node.metadataRoutes?.[baseName]) {
|
|
216
232
|
return node.metadataRoutes[baseName];
|
|
217
233
|
}
|
|
218
234
|
for (const child of node.children) {
|
|
219
|
-
if (child.segmentType === 'group') {
|
|
220
|
-
const found = findMetadataRouteInSegment(child, baseName);
|
|
235
|
+
if (child.segmentType === 'group' || (nestable && child.segmentType === 'optional-catch-all')) {
|
|
236
|
+
const found = findMetadataRouteInSegment(child, baseName, nestable);
|
|
221
237
|
if (found) return found;
|
|
222
238
|
}
|
|
223
239
|
}
|
|
@@ -33,6 +33,7 @@ import { ErrorReconstituter } from '../../client/error-reconstituter.js';
|
|
|
33
33
|
import type { SerializableError } from '../../client/error-reconstituter.js';
|
|
34
34
|
import { loadModule } from '../safe-load.js';
|
|
35
35
|
import { sourceMapError } from '../dev-source-map.js';
|
|
36
|
+
import { isMdxFilePath as isMdxFile } from '../utils/mdx-file.js';
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* A manifest file reference with lazy import and path.
|
|
@@ -44,18 +45,6 @@ export interface GlobalErrorFile {
|
|
|
44
45
|
filePath: string;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/** MDX/markdown extensions — server components that need RSC rendering. */
|
|
48
|
-
const MDX_EXTENSIONS = new Set(['.mdx', '.md']);
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Check if a file path ends with an MDX/markdown extension.
|
|
52
|
-
*/
|
|
53
|
-
function isMdxFile(filePath: string): boolean {
|
|
54
|
-
const dotIndex = filePath.lastIndexOf('.');
|
|
55
|
-
if (dotIndex === -1) return false;
|
|
56
|
-
return MDX_EXTENSIONS.has(filePath.slice(dotIndex));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
48
|
/**
|
|
60
49
|
* Result of walking the segment chain for an error file.
|
|
61
50
|
*/
|
|
@@ -385,6 +374,7 @@ export async function renderNoMatchPage(
|
|
|
385
374
|
deny,
|
|
386
375
|
segments,
|
|
387
376
|
layoutComponents,
|
|
377
|
+
req,
|
|
388
378
|
responseHeaders,
|
|
389
379
|
createDebugChannelSink
|
|
390
380
|
);
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
8
|
-
import { RedirectSignal } from '../primitives.js';
|
|
9
8
|
import { swallow } from '../logger.js';
|
|
10
9
|
|
|
11
10
|
/** RSC content type for client navigation payload requests. */
|
|
@@ -221,39 +220,12 @@ export function isRscPayloadRequest(req: Request): boolean {
|
|
|
221
220
|
return accept.includes(RSC_CONTENT_TYPE);
|
|
222
221
|
}
|
|
223
222
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
* client detect the redirect and perform a soft SPA navigation.
|
|
231
|
-
*/
|
|
232
|
-
export function buildRedirectResponse(
|
|
233
|
-
req: Request,
|
|
234
|
-
signal: RedirectSignal,
|
|
235
|
-
responseHeaders: Headers
|
|
236
|
-
): Response {
|
|
237
|
-
if (isRscPayloadRequest(req)) {
|
|
238
|
-
responseHeaders.set('X-Timber-Redirect', signal.location);
|
|
239
|
-
return new Response(null, { status: 204, headers: responseHeaders });
|
|
240
|
-
}
|
|
241
|
-
responseHeaders.set('Location', signal.location);
|
|
242
|
-
return new Response(null, { status: signal.status, headers: responseHeaders });
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Check if an error is an abort error (connection closed by client).
|
|
247
|
-
*
|
|
248
|
-
* When the browser aborts a request (page refresh, navigation away),
|
|
249
|
-
* the AbortSignal fires and React/streams throw an AbortError. This
|
|
250
|
-
* is not an application error — suppress it from error boundaries and logs.
|
|
251
|
-
*/
|
|
252
|
-
export function isAbortError(error: unknown): boolean {
|
|
253
|
-
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
|
254
|
-
if (error instanceof Error && error.name === 'AbortError') return true;
|
|
255
|
-
return false;
|
|
256
|
-
}
|
|
223
|
+
// Re-export consolidated helpers from their single source of truth.
|
|
224
|
+
// Callers within rsc-entry/ import from this file; the implementations
|
|
225
|
+
// live in pipeline-helpers.ts (buildRedirectResponse) and render-utils.ts
|
|
226
|
+
// (isAbortError). See TIM-1071 item 1.
|
|
227
|
+
export { buildRedirectResponse } from '../pipeline-helpers.js';
|
|
228
|
+
export { isAbortError } from '../render-utils.js';
|
|
257
229
|
|
|
258
230
|
// escapeHtml is now imported from server/utils/escape-html.ts
|
|
259
231
|
export { escapeHtml } from '../utils/escape-html.js';
|
|
@@ -130,6 +130,16 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
130
130
|
setCacheHandler(userCacheHandler as Parameters<typeof setCacheHandler>[0]);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Propagate renderTimeoutMs into the default singleflight so cache calls
|
|
134
|
+
// without a per-call timeoutMs still get deadlock protection (TIM-1032).
|
|
135
|
+
const renderTimeoutMs = (runtimeConfig as Record<string, unknown>).renderTimeoutMs as
|
|
136
|
+
| number
|
|
137
|
+
| undefined;
|
|
138
|
+
if (renderTimeoutMs) {
|
|
139
|
+
const { setDefaultSingleflightTimeout } = await import('../../cache/timber-cache.js');
|
|
140
|
+
setDefaultSingleflightTimeout(renderTimeoutMs);
|
|
141
|
+
}
|
|
142
|
+
|
|
133
143
|
const matchRoute = createRouteMatcher(manifest);
|
|
134
144
|
const matchMetadataRoute = createMetadataRouteMatcher(manifest);
|
|
135
145
|
|
|
@@ -369,6 +379,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
369
379
|
deny,
|
|
370
380
|
chain,
|
|
371
381
|
layoutComponents,
|
|
382
|
+
req,
|
|
372
383
|
responseHeaders,
|
|
373
384
|
createDebugChannelSink
|
|
374
385
|
);
|