@timber-js/app 0.2.0-alpha.209 → 0.2.0-alpha.210
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-BerlqoXA.js → actions-Rjk4htmA.js} +19 -25
- package/dist/_chunks/{actions-BerlqoXA.js.map → actions-Rjk4htmA.js.map} +1 -1
- package/dist/_chunks/als-registry-DaxkVjt5.js.map +1 -1
- package/dist/_chunks/{cache-api-CR23J_NC.js → cache-api-DGdYfNJn.js} +4 -4
- package/dist/_chunks/{cache-api-CR23J_NC.js.map → cache-api-DGdYfNJn.js.map} +1 -1
- package/dist/_chunks/{chains-CpFg56UB.js → chains-BoO51joc.js} +2 -2
- package/dist/_chunks/{chains-CpFg56UB.js.map → chains-BoO51joc.js.map} +1 -1
- package/dist/_chunks/{cli-check-C6Ev6wBO.js → cli-check-ajNY3B2e.js} +3 -3
- package/dist/_chunks/{cli-check-C6Ev6wBO.js.map → cli-check-ajNY3B2e.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js → cli-schema-sync-D2eI8jEg.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js.map → cli-schema-sync-D2eI8jEg.js.map} +1 -1
- package/dist/_chunks/{file-cache-Dw6BJPG7.js → codegen-Bps1sLKJ.js} +3 -29
- package/dist/_chunks/codegen-Bps1sLKJ.js.map +1 -0
- package/dist/_chunks/{convention-lint-jKTwKwPe.js → convention-lint-DLmhGsRS.js} +54 -316
- package/dist/_chunks/convention-lint-DLmhGsRS.js.map +1 -0
- package/dist/_chunks/{dev-server-C4WZdB7L.js → dev-server-v97rQH4b.js} +99 -9
- package/dist/_chunks/dev-server-v97rQH4b.js.map +1 -0
- package/dist/_chunks/{error-boundary-tA7kVfs4.js → error-boundary-DsNScGRM.js} +4 -4
- package/dist/_chunks/{error-boundary-tA7kVfs4.js.map → error-boundary-DsNScGRM.js.map} +1 -1
- package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js → json-lossy-check-CVuRs2hG.js} +2 -2
- package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js.map → json-lossy-check-CVuRs2hG.js.map} +1 -1
- package/dist/_chunks/{live-graph-Dv-JJCZw.js → live-graph-9cSnn_h9.js} +3 -3
- package/dist/_chunks/{live-graph-Dv-JJCZw.js.map → live-graph-9cSnn_h9.js.map} +1 -1
- package/dist/_chunks/{logger-DiDt5ppH.js → logger-BP0LN6vP.js} +17 -2
- package/dist/_chunks/{logger-DiDt5ppH.js.map → logger-BP0LN6vP.js.map} +1 -1
- package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -1
- package/dist/_chunks/navigation-root-BQfo1-kG.js.map +1 -1
- package/dist/_chunks/{poison-scan-CpeT6_OJ.js → poison-scan-vGV7Re0B.js} +2 -2
- package/dist/_chunks/{poison-scan-CpeT6_OJ.js.map → poison-scan-vGV7Re0B.js.map} +1 -1
- package/dist/_chunks/{scanner-Bw0oq1HB.js → scanner-DmqdxzbW.js} +392 -7
- package/dist/_chunks/scanner-DmqdxzbW.js.map +1 -0
- package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
- package/dist/_chunks/{sizeof-UwzwB1uM.js → sizeof-BM1409x2.js} +2 -2
- package/dist/_chunks/{sizeof-UwzwB1uM.js.map → sizeof-BM1409x2.js.map} +1 -1
- package/dist/_chunks/{status-page-marker-gaihi0KZ.js → status-page-marker-BRX9Ib-d.js} +1 -45
- package/dist/_chunks/status-page-marker-BRX9Ib-d.js.map +1 -0
- package/dist/_chunks/{walkers-BXExhzzk.js → walkers-Czu2jXFq.js} +3 -3
- package/dist/_chunks/{walkers-BXExhzzk.js.map → walkers-Czu2jXFq.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/analyze/crawl-entry.js +2 -2
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cache/index.js +2 -2
- package/dist/cache/stores/memory.js +1 -1
- package/dist/cli.js +3 -3
- 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.js +1 -1
- package/dist/client/history.d.ts +0 -9
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +9 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +25 -38
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +22 -0
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts +14 -2
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +9 -1
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/navigation-transition.d.ts +6 -1
- package/dist/client/navigation-transition.d.ts.map +1 -1
- package/dist/client/react-root.d.ts.map +1 -1
- package/dist/client/router-effects.d.ts +10 -3
- package/dist/client/router-effects.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +3 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +65 -9
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +0 -15
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-router.d.ts +13 -6
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/config-validation.d.ts +19 -2
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -7
- package/dist/index.js.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/export-detect.d.ts +19 -0
- package/dist/routing/export-detect.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/types.d.ts +7 -0
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +9 -4
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/csrf.d.ts +38 -19
- package/dist/server/csrf.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +8 -4
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/form-flash.d.ts +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +249 -16
- package/dist/server/internal.js.map +1 -1
- package/dist/server/logger.d.ts +16 -3
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/metadata-collector.d.ts +2 -0
- package/dist/server/metadata-collector.d.ts.map +1 -1
- package/dist/server/metadata-routes.d.ts +12 -1
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +29 -1
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +8 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +8 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/{wrap-action-dispatch.d.ts → action-dispatcher.d.ts} +18 -40
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/safe-load.d.ts +5 -12
- package/dist/server/safe-load.d.ts.map +1 -1
- package/docs/api/30-api-server.mdx +1 -1
- package/docs/api/31-api-client.mdx +23 -20
- package/docs/api/34-api-config.mdx +4 -2
- package/package.json +1 -1
- package/src/client/browser-entry/action-dispatch.ts +28 -28
- package/src/client/browser-entry/post-hydration.ts +11 -1
- package/src/client/browser-entry/router-init.ts +10 -4
- package/src/client/history.ts +0 -22
- package/src/client/index.ts +2 -1
- package/src/client/link.tsx +30 -1
- package/src/client/navigation-api.ts +36 -3
- package/src/client/navigation-root.tsx +13 -1
- package/src/client/navigation-transition.ts +17 -7
- package/src/client/react-root.ts +11 -2
- package/src/client/router-effects.ts +11 -4
- package/src/client/router-pipeline.ts +4 -0
- package/src/client/router-types.ts +80 -6
- package/src/client/router.ts +36 -24
- package/src/client/segment-cache.ts +0 -65
- package/src/client/use-router.ts +25 -6
- package/src/config-validation.ts +121 -5
- package/src/index.ts +5 -8
- package/src/routing/convention-lint.ts +75 -0
- package/src/routing/export-detect.ts +88 -0
- package/src/routing/manifest-codegen.ts +6 -0
- package/src/routing/scanner.ts +9 -0
- package/src/routing/types.ts +7 -0
- package/src/server/action-handler.ts +14 -11
- package/src/server/actions.ts +37 -42
- package/src/server/als-registry.ts +1 -1
- package/src/server/csrf.ts +100 -72
- package/src/server/error-boundary-wrapper.ts +11 -12
- package/src/server/fallback-error.ts +13 -21
- package/src/server/form-flash.ts +1 -1
- package/src/server/logger.ts +19 -3
- package/src/server/metadata-collector.ts +4 -1
- package/src/server/metadata-routes.ts +23 -8
- package/src/server/pipeline-helpers.ts +89 -8
- package/src/server/pipeline.ts +27 -2
- package/src/server/route-element-builder.ts +1 -0
- package/src/server/route-matcher.ts +11 -0
- package/src/server/rsc-entry/{wrap-action-dispatch.ts → action-dispatcher.ts} +20 -62
- package/src/server/rsc-entry/index.ts +14 -27
- package/src/server/safe-load.ts +5 -12
- package/dist/_chunks/convention-lint-jKTwKwPe.js.map +0 -1
- package/dist/_chunks/dev-server-C4WZdB7L.js.map +0 -1
- package/dist/_chunks/file-cache-Dw6BJPG7.js.map +0 -1
- package/dist/_chunks/scanner-Bw0oq1HB.js.map +0 -1
- package/dist/_chunks/status-page-marker-gaihi0KZ.js.map +0 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +0 -1
|
@@ -62,9 +62,13 @@ export interface ErrorBoundaryProps {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* How to load a route file's module. Only `default` is read.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
65
|
+
* How to load a route file's module. Only `default` is read. A module that
|
|
66
|
+
* fails to load resolves to `null` — the loader must not reject, because
|
|
67
|
+
* building an error boundary must not itself throw (TIM-584). The server
|
|
68
|
+
* injects `loadFallbackModule`, which logs and reports the failed load before
|
|
69
|
+
* returning `null`, so a broken status or error page is never skipped
|
|
70
|
+
* silently (TIM-1458). The walk deliberately does not catch a rejection:
|
|
71
|
+
* a catch here would be the silent skip.
|
|
68
72
|
*/
|
|
69
73
|
type BoundaryModuleLoader<TFile> = (
|
|
70
74
|
file: TFile
|
|
@@ -74,7 +78,7 @@ type BoundaryModuleLoader<TFile> = (
|
|
|
74
78
|
export interface ErrorBoundaryWrapConfig<TFile, TElement> {
|
|
75
79
|
/** `React.createElement` or an equivalent element factory. */
|
|
76
80
|
createElement: (type: unknown, props: object) => TElement;
|
|
77
|
-
/** Loads a route file's module
|
|
81
|
+
/** Loads a route file's module; resolves to `null` when it fails. See `BoundaryModuleLoader`. */
|
|
78
82
|
loadModule: BoundaryModuleLoader<TFile>;
|
|
79
83
|
/**
|
|
80
84
|
* The error boundary component to instantiate (`TimberErrorBoundary`, or a
|
|
@@ -93,8 +97,8 @@ export interface ErrorBoundaryWrapConfig<TFile, TElement> {
|
|
|
93
97
|
* Load a status/error file's component, or `null` if the boundary should be skipped.
|
|
94
98
|
*
|
|
95
99
|
* Two ways to get `null`, both deliberate:
|
|
96
|
-
* - the module fails to load (syntax error in the error page) —
|
|
97
|
-
*
|
|
100
|
+
* - the module fails to load (syntax error in the error page) — the loader
|
|
101
|
+
* has already logged and reported it and resolved to `null`;
|
|
98
102
|
* - the default export is not a valid React component type, which would
|
|
99
103
|
* otherwise crash inside `createElement`.
|
|
100
104
|
*/
|
|
@@ -102,12 +106,7 @@ async function loadBoundaryComponent<TFile>(
|
|
|
102
106
|
file: TFile,
|
|
103
107
|
loadModule: BoundaryModuleLoader<TFile>
|
|
104
108
|
): Promise<LoadedComponent | null> {
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
mod = await loadModule(file);
|
|
108
|
-
} catch {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
109
|
+
const mod = await loadModule(file);
|
|
111
110
|
return isValidElementType(mod?.default) ? mod.default : null;
|
|
112
111
|
}
|
|
113
112
|
|
|
@@ -21,8 +21,7 @@ import type { ClientBootstrapConfig } from './html-injectors.ts';
|
|
|
21
21
|
import type { LayoutEntry } from './deny-renderer.ts';
|
|
22
22
|
import type { GlobalErrorFile } from './rsc-entry/error-renderer.ts';
|
|
23
23
|
import { swallow } from './logger.ts';
|
|
24
|
-
import {
|
|
25
|
-
import { loadModule } from './safe-load.ts';
|
|
24
|
+
import { loadFallbackModule } from './pipeline-helpers.ts';
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
27
|
* Render a fallback error page when the render pipeline throws.
|
|
@@ -58,25 +57,18 @@ export async function renderFallbackError(
|
|
|
58
57
|
const { renderErrorPage } = await import('./rsc-entry/error-renderer.ts');
|
|
59
58
|
const segments = [rootSegment];
|
|
60
59
|
const layoutComponents: LayoutEntry[] = [];
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
} catch (layoutError) {
|
|
76
|
-
// Layout failed to load — proceed without it. renderErrorPage will
|
|
77
|
-
// attempt segment-level error pages (without layout wrapping) and
|
|
78
|
-
// then fall through to global-error.tsx if those also fail.
|
|
79
|
-
reportRenderError(layoutError, req, { segments: match?.segments });
|
|
60
|
+
// If the root layout module itself fails to load (evaluation failure,
|
|
61
|
+
// syntax error), proceed without it: renderErrorPage still tries the
|
|
62
|
+
// segment-level error pages (without layout wrapping) and then falls
|
|
63
|
+
// through to global-error.tsx. loadFallbackModule logs and reports it.
|
|
64
|
+
const mod = rootSegment.layout
|
|
65
|
+
? await loadFallbackModule(rootSegment.layout, req, match?.segments)
|
|
66
|
+
: null;
|
|
67
|
+
if (mod?.default) {
|
|
68
|
+
layoutComponents.push({
|
|
69
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
70
|
+
segment: rootSegment,
|
|
71
|
+
});
|
|
80
72
|
}
|
|
81
73
|
const rootMatch: RouteMatch = {
|
|
82
74
|
segments,
|
package/src/server/form-flash.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The flash data is server-side only — never serialized to cookies or headers.
|
|
13
13
|
*
|
|
14
|
-
* See design/08-forms-and-actions.md §"No-JS
|
|
14
|
+
* See design/08-forms-and-actions.md §"No-JS Result Round-Trip"
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ValidationErrors } from './action-client.ts';
|
package/src/server/logger.ts
CHANGED
|
@@ -72,9 +72,10 @@ function withTraceContext(data?: Record<string, unknown>): Record<string, unknow
|
|
|
72
72
|
* so this line is the only place the rejected origin is visible (TIM-1459).
|
|
73
73
|
* `detail` names the observed origin and, where one exists, the expected one.
|
|
74
74
|
*
|
|
75
|
-
* The
|
|
76
|
-
*
|
|
77
|
-
* present (design/17-logging.md §"trace_id is
|
|
75
|
+
* The gate runs inside the pipeline's trace scope, so the line carries the
|
|
76
|
+
* request's `trace_id`. A caller outside any scope still gets one minted
|
|
77
|
+
* here — the field is always present (design/17-logging.md §"trace_id is
|
|
78
|
+
* Always Set").
|
|
78
79
|
*/
|
|
79
80
|
export function logCsrfRejected(
|
|
80
81
|
detail: string,
|
|
@@ -146,6 +147,21 @@ export function logRenderError(data: {
|
|
|
146
147
|
_logger.error('unhandled render-phase error', withTraceContext(data));
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Log a status page, error page, `global-error.tsx` or fallback layout that
|
|
152
|
+
* failed to load and was skipped: the next fallback tier answers in its place.
|
|
153
|
+
* Level: error — a boundary that vanished is a bug in the app, even though the
|
|
154
|
+
* request still gets a page. The caller writes it once per file and failure.
|
|
155
|
+
*/
|
|
156
|
+
export function logSkippedModule(data: {
|
|
157
|
+
method: string;
|
|
158
|
+
path: string;
|
|
159
|
+
file: string;
|
|
160
|
+
error: unknown;
|
|
161
|
+
}): void {
|
|
162
|
+
_logger.error('route file failed to load and was skipped', withTraceContext(data));
|
|
163
|
+
}
|
|
164
|
+
|
|
149
165
|
/** Log proxy.ts uncaught error. Level: error. */
|
|
150
166
|
export function logProxyError(data: { error: unknown }): void {
|
|
151
167
|
_logger.error('proxy.ts threw uncaught error', withTraceContext(data));
|
|
@@ -116,6 +116,8 @@ export function MetadataCollector(props: MetadataCollectorProps): ReactNode {
|
|
|
116
116
|
|
|
117
117
|
export interface MetadataHeadProps {
|
|
118
118
|
segments: ManifestSegmentNode[];
|
|
119
|
+
/** The matcher's raw (pre-coercion) params — fill the owning segment's URL for auto-links. */
|
|
120
|
+
rawSegmentParams: Record<string, string | string[]>;
|
|
119
121
|
requestUrl: string;
|
|
120
122
|
metadataRouteHashes?: Record<string, string>;
|
|
121
123
|
}
|
|
@@ -139,7 +141,7 @@ export interface MetadataHeadProps {
|
|
|
139
141
|
* See TIM-1367 for the concurrent resolution design.
|
|
140
142
|
*/
|
|
141
143
|
export async function MetadataHead(props: MetadataHeadProps): Promise<ReactNode> {
|
|
142
|
-
const { segments, requestUrl, metadataRouteHashes } = props;
|
|
144
|
+
const { segments, rawSegmentParams, requestUrl, metadataRouteHashes } = props;
|
|
143
145
|
const deferredEntries = getDeferredMetadataEntries();
|
|
144
146
|
|
|
145
147
|
// Resolve all deferred metadata concurrently. Rejected entries are
|
|
@@ -166,6 +168,7 @@ export async function MetadataHead(props: MetadataHeadProps): Promise<ReactNode>
|
|
|
166
168
|
Infinity,
|
|
167
169
|
resolved,
|
|
168
170
|
new URL(requestUrl),
|
|
171
|
+
rawSegmentParams,
|
|
169
172
|
metadataRouteHashes
|
|
170
173
|
);
|
|
171
174
|
headElements.push(...autoLinked);
|
|
@@ -14,6 +14,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
14
14
|
import type { HeadElement } from './metadata.ts';
|
|
15
15
|
import type { Metadata } from './types.ts';
|
|
16
16
|
import type { ManifestSegmentNode } from './route-matcher.ts';
|
|
17
|
+
import { extractUrlParts } from './chain-url-parts.ts';
|
|
17
18
|
|
|
18
19
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -357,6 +358,17 @@ function getDevNonce(): string {
|
|
|
357
358
|
* emits HeadElement descriptors for <link> and <meta> tags that React Float
|
|
358
359
|
* hoists into <head>.
|
|
359
360
|
*
|
|
361
|
+
* A metadata file is served under the URL of the segment that owns it, not
|
|
362
|
+
* under every descendant URL: a root `app/icon.tsx` rendered on `/map` links
|
|
363
|
+
* `/icon.png`, and `app/posts/[id]/opengraph-image.tsx` on `/posts/42/comments`
|
|
364
|
+
* links `/posts/42/opengraph-image.png`. The owner's prefix is the URL parts
|
|
365
|
+
* the chain has consumed through that segment (`extractUrlParts`, the same
|
|
366
|
+
* walk slot matching uses), filled with the request's raw param values — the
|
|
367
|
+
* parts the metadata route matcher walks back to the same owner.
|
|
368
|
+
*
|
|
369
|
+
* Segments at or below an intercepting segment are skipped: the metadata
|
|
370
|
+
* matcher only walks the canonical tree, so nothing serves their files.
|
|
371
|
+
*
|
|
360
372
|
* See design/16-metadata.md §"Auto-Linking"
|
|
361
373
|
*/
|
|
362
374
|
export function collectMetadataRouteHeadElements(
|
|
@@ -364,12 +376,12 @@ export function collectMetadataRouteHeadElements(
|
|
|
364
376
|
firstDeniedIndex: number,
|
|
365
377
|
resolvedMetadata: Metadata,
|
|
366
378
|
requestUrl: URL,
|
|
379
|
+
rawSegmentParams: Record<string, string | string[]>,
|
|
367
380
|
metadataRouteHashes?: Record<string, string>
|
|
368
381
|
): HeadElement[] {
|
|
369
382
|
const elements: HeadElement[] = [];
|
|
370
383
|
const hasUserOgImage = Boolean(resolvedMetadata.openGraph?.images);
|
|
371
384
|
const hasUserTwitterImage = Boolean(resolvedMetadata.twitter?.images);
|
|
372
|
-
const requestPathname = requestUrl.pathname;
|
|
373
385
|
// In dev mode, use the request origin so OG URLs resolve to localhost.
|
|
374
386
|
// In production, use metadataBase (the canonical domain).
|
|
375
387
|
const ogBase =
|
|
@@ -377,27 +389,30 @@ export function collectMetadataRouteHeadElements(
|
|
|
377
389
|
? new URL(requestUrl.origin)
|
|
378
390
|
: resolvedMetadata.metadataBase;
|
|
379
391
|
|
|
380
|
-
|
|
392
|
+
const interceptingIndex = segments.findIndex((s) => s.segmentType === 'intercepting');
|
|
393
|
+
const servedDepth = interceptingIndex === -1 ? segments.length : interceptingIndex;
|
|
394
|
+
|
|
395
|
+
for (let si = 0; si < servedDepth; si++) {
|
|
381
396
|
const segment = segments[si];
|
|
382
397
|
if (!segment.metadataRoutes) continue;
|
|
383
398
|
if (si >= firstDeniedIndex) continue;
|
|
399
|
+
// Params are canonical (decoded) values; encode each part so the href
|
|
400
|
+
// canonicalizes back to the pathname the matcher resolves to this owner.
|
|
401
|
+
const segmentPrefix = extractUrlParts(segments.slice(0, si + 1), rawSegmentParams)
|
|
402
|
+
.map((part) => `/${encodeURIComponent(part)}`)
|
|
403
|
+
.join('');
|
|
384
404
|
for (const baseName of Object.keys(segment.metadataRoutes)) {
|
|
385
405
|
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
386
406
|
if (!convention) continue;
|
|
387
407
|
if (!convention.nestable && segment.urlPath !== '/') continue;
|
|
388
408
|
if (convention.type === 'opengraph-image' && hasUserOgImage) continue;
|
|
389
|
-
const resolvedPrefix = convention.nestable
|
|
390
|
-
? requestPathname === '/'
|
|
391
|
-
? ''
|
|
392
|
-
: requestPathname
|
|
393
|
-
: '';
|
|
394
409
|
const metaFile = segment.metadataRoutes[baseName];
|
|
395
410
|
const fileServePath = metaFile?.filePath
|
|
396
411
|
? resolveServePathForFile(baseName, metaFile.filePath)
|
|
397
412
|
: convention.serveExtension
|
|
398
413
|
? `${convention.servePath}.${convention.serveExtension}`
|
|
399
414
|
: convention.servePath;
|
|
400
|
-
let href = `${
|
|
415
|
+
let href = `${segmentPrefix}/${fileServePath}`;
|
|
401
416
|
if (convention.type === 'opengraph-image') {
|
|
402
417
|
const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;
|
|
403
418
|
const cacheBust = fileHash ?? getDevNonce();
|
|
@@ -20,16 +20,52 @@ import { getTraceId } from './tracing.ts';
|
|
|
20
20
|
import { requestContextAls } from './als-registry.ts';
|
|
21
21
|
import { RedirectSignal } from './primitives.ts';
|
|
22
22
|
import { isControlFlowSignal } from './signal-identity.ts';
|
|
23
|
-
import { logRenderError, swallow } from './logger.ts';
|
|
23
|
+
import { logRenderError, logSkippedModule, swallow } from './logger.ts';
|
|
24
24
|
import { isAbortError } from './render-utils.ts';
|
|
25
25
|
import { loadModule, ModuleLoadError, type ManifestLoader } from './safe-load.ts';
|
|
26
26
|
import { getWaitUntil } from './waituntil-bridge.ts';
|
|
27
|
-
import { isApiRouteChain, type ManifestSegmentNode } from './route-matcher.ts';
|
|
28
|
-
import type { ProxyConfig } from './pipeline.ts';
|
|
27
|
+
import { isApiRouteChain, isCsrfExemptRoute, type ManifestSegmentNode } from './route-matcher.ts';
|
|
28
|
+
import type { ProxyConfig, RouteMatcher } from './pipeline.ts';
|
|
29
|
+
import { csrfRejectionResponse, validateCsrf, type CsrfConfig } from './csrf.ts';
|
|
29
30
|
import type { PhaseName } from './pipeline-outcome.ts';
|
|
30
31
|
|
|
31
32
|
// ─── Prototype-Pollution-Safe Sanitizer ────────────────────────────────────
|
|
32
33
|
|
|
34
|
+
// ─── CSRF Gate ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The pipeline-boundary CSRF gate: the 403 to answer with, or null to go on.
|
|
38
|
+
*
|
|
39
|
+
* Runs on every request after canonicalization and before proxy.ts, so it
|
|
40
|
+
* covers route.ts handlers and server actions alike, and no user code has
|
|
41
|
+
* run when it decides. The per-route exemption is looked up only for a
|
|
42
|
+
* request the check would reject — the common accepted request never pays
|
|
43
|
+
* for a match. The lookup uses the pipeline's own matcher on the canonical
|
|
44
|
+
* path, the same match `handleRequest` dispatches on, and reads a flag the
|
|
45
|
+
* scanner took from route.ts source, so deciding it loads no module.
|
|
46
|
+
*
|
|
47
|
+
* A matcher that throws leaves the request unexempted: fail closed.
|
|
48
|
+
*
|
|
49
|
+
* See design/08-forms-and-actions.md §"CSRF Protection".
|
|
50
|
+
*/
|
|
51
|
+
export function csrfGate(
|
|
52
|
+
req: Request,
|
|
53
|
+
canonicalPath: string,
|
|
54
|
+
csrf: CsrfConfig,
|
|
55
|
+
matchRoute: RouteMatcher
|
|
56
|
+
): Response | null {
|
|
57
|
+
const result = validateCsrf(req, csrf);
|
|
58
|
+
if (result.ok) return null;
|
|
59
|
+
let exempt = false;
|
|
60
|
+
try {
|
|
61
|
+
const match = matchRoute(canonicalPath);
|
|
62
|
+
exempt = match !== null && isCsrfExemptRoute(match.segments);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
swallow(err, 'csrf gate: route match for exemption lookup threw');
|
|
65
|
+
}
|
|
66
|
+
return exempt ? null : csrfRejectionResponse(req, result);
|
|
67
|
+
}
|
|
68
|
+
|
|
33
69
|
// ─── Proxy Resolver ────────────────────────────────────────────────────────
|
|
34
70
|
|
|
35
71
|
/**
|
|
@@ -338,18 +374,63 @@ export function reportRenderError(
|
|
|
338
374
|
* taking the page down. Every such load goes through here, whether it builds
|
|
339
375
|
* a boundary for a normal render or a fallback page, so a broken module is
|
|
340
376
|
* never skipped silently.
|
|
377
|
+
*
|
|
378
|
+
* Logged and reported once per broken file, not once per request: every
|
|
379
|
+
* request that renders under a broken `error.tsx` loads it again (TIM-1458).
|
|
380
|
+
* See `skippedFiles` for how "the same failure" is recognised. The first
|
|
381
|
+
* request to hit a failure is the one that reports it; if that request has
|
|
382
|
+
* already aborted, `fireOnRequestError()` drops the report and the failure is
|
|
383
|
+
* only logged.
|
|
384
|
+
*
|
|
385
|
+
* Never rejects, including when a user logger throws: building an error
|
|
386
|
+
* boundary must not itself throw (TIM-584), and the boundary walk does not
|
|
387
|
+
* catch for it.
|
|
341
388
|
*/
|
|
342
|
-
export function loadFallbackModule(
|
|
389
|
+
export async function loadFallbackModule(
|
|
343
390
|
file: ManifestLoader,
|
|
344
391
|
req: Request,
|
|
345
392
|
segments?: readonly ManifestSegmentNode[]
|
|
346
393
|
): Promise<Record<string, unknown> | null> {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
394
|
+
let loadError: unknown;
|
|
395
|
+
try {
|
|
396
|
+
const mod = await loadModule(file);
|
|
397
|
+
skippedFiles.delete(file.filePath);
|
|
398
|
+
return mod;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
loadError = error;
|
|
401
|
+
}
|
|
402
|
+
const failure = loadError instanceof Error ? loadError.message : String(loadError);
|
|
403
|
+
if (skippedFiles.get(file.filePath) === failure) return null;
|
|
404
|
+
skippedFiles.set(file.filePath, failure);
|
|
405
|
+
try {
|
|
406
|
+
logSkippedModule({
|
|
407
|
+
method: req.method,
|
|
408
|
+
path: new URL(req.url).pathname,
|
|
409
|
+
file: file.filePath,
|
|
410
|
+
error: loadError,
|
|
411
|
+
});
|
|
412
|
+
} catch (logError) {
|
|
413
|
+
swallow(logError, 'logger.error threw while logging a skipped route file');
|
|
414
|
+
}
|
|
415
|
+
fireInBackground(fireOnRequestError(loadError, req, 'render', segments));
|
|
416
|
+
return null;
|
|
351
417
|
}
|
|
352
418
|
|
|
419
|
+
/**
|
|
420
|
+
* The failure each broken fallback file was last logged with, keyed by the
|
|
421
|
+
* file and compared by message — not by error identity. In Vite dev a file
|
|
422
|
+
* that fails to transform (a syntax error) is fetched again on every import,
|
|
423
|
+
* and the module runner revives the server's error as a new `Error` each
|
|
424
|
+
* time (`reviveInvokeError`), so identity never repeats; the message does.
|
|
425
|
+
* Keyed by file so two files skipped over one broken import each log.
|
|
426
|
+
*
|
|
427
|
+
* A successful load clears the file's entry, so fixing a file and breaking
|
|
428
|
+
* it again — even with the same message — logs again. An edit that changes
|
|
429
|
+
* the error changes the message and logs too. It grows with the number of
|
|
430
|
+
* broken files, not with requests.
|
|
431
|
+
*/
|
|
432
|
+
const skippedFiles = new Map<string, string>();
|
|
433
|
+
|
|
353
434
|
/**
|
|
354
435
|
* Run a `cache.component` stale-while-revalidate re-render as a singleflight
|
|
355
436
|
* body, and log and report its failure once for the flight. The stale
|
package/src/server/pipeline.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Request pipeline — the central dispatch for all timber.js requests.
|
|
3
3
|
*
|
|
4
4
|
* Pipeline stages (in order):
|
|
5
|
-
* proxy.ts →
|
|
5
|
+
* canonicalize → CSRF gate → proxy.ts → route match → 103 Early Hints → middleware.ts → render
|
|
6
6
|
*
|
|
7
7
|
* The phase functions live in `pipeline-phases.ts` so each phase can be
|
|
8
8
|
* tested in isolation. The terminal `outcomeToResponse` translator and
|
|
@@ -32,7 +32,8 @@ import {
|
|
|
32
32
|
import { logRequestReceived, logRequestCompleted, logSlowRequest } from './logger.ts';
|
|
33
33
|
import { DenySignal } from './primitives.ts';
|
|
34
34
|
import type { ManifestSegmentNode } from './route-matcher.ts';
|
|
35
|
-
import { makeProxyResolver } from './pipeline-helpers.ts';
|
|
35
|
+
import { csrfGate, makeProxyResolver } from './pipeline-helpers.ts';
|
|
36
|
+
import type { CsrfConfig } from './csrf.ts';
|
|
36
37
|
import { handleRequest, runProxyPhase } from './pipeline-phases.ts';
|
|
37
38
|
import { outcomeToResponse } from './pipeline-outcome.ts';
|
|
38
39
|
import { canonicalize } from './canonicalize.ts';
|
|
@@ -131,6 +132,12 @@ export interface PipelineConfig {
|
|
|
131
132
|
proxy?: ProxyConfig | ProxyExport;
|
|
132
133
|
/** Route matcher — resolves a canonical pathname to a RouteMatch. */
|
|
133
134
|
matchRoute: RouteMatcher;
|
|
135
|
+
/**
|
|
136
|
+
* CSRF configuration (`allowedOrigins`, the `csrf: false` switch). The
|
|
137
|
+
* gate is on when this is omitted — protection is never opt-in. See
|
|
138
|
+
* design/08-forms-and-actions.md §"CSRF Protection".
|
|
139
|
+
*/
|
|
140
|
+
csrf?: CsrfConfig;
|
|
134
141
|
/** Metadata route matcher — resolves metadata route pathnames (sitemap.xml, robots.txt, etc.) */
|
|
135
142
|
matchMetadataRoute?: MetadataRouteMatcher;
|
|
136
143
|
/** Renderer — produces the final Response for a matched route. */
|
|
@@ -341,6 +348,10 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
341
348
|
` null bytes (%00), path traversal (..), or malformed percent-encoding.`
|
|
342
349
|
);
|
|
343
350
|
}
|
|
351
|
+
// Early returns set the span status themselves: the shared
|
|
352
|
+
// assignment below never runs for them, and a span with
|
|
353
|
+
// none reads as 200 in DevSpanProcessor.
|
|
354
|
+
await setSpanAttribute('http.response.status_code', canonResult.status);
|
|
344
355
|
return new Response(null, { status: canonResult.status });
|
|
345
356
|
}
|
|
346
357
|
const canonicalPath = canonResult.pathname;
|
|
@@ -355,6 +366,20 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
355
366
|
canonicalReq = new Request(canonicalUrl.toString(), req);
|
|
356
367
|
}
|
|
357
368
|
|
|
369
|
+
// Stage 0b: CSRF gate — every unsafe-method request, before
|
|
370
|
+
// proxy.ts or any other user code, and before action
|
|
371
|
+
// dispatch, so route.ts handlers are covered too (LOCAL-773).
|
|
372
|
+
const csrfRejection = csrfGate(
|
|
373
|
+
canonicalReq,
|
|
374
|
+
canonicalPath,
|
|
375
|
+
config.csrf ?? {},
|
|
376
|
+
config.matchRoute
|
|
377
|
+
);
|
|
378
|
+
if (csrfRejection) {
|
|
379
|
+
await setSpanAttribute('http.response.status_code', csrfRejection.status);
|
|
380
|
+
return csrfRejection;
|
|
381
|
+
}
|
|
382
|
+
|
|
358
383
|
// Build the inner handler — runs after proxy, before route
|
|
359
384
|
// matching. Checks for server action POSTs and dispatches them
|
|
360
385
|
// without entering the route match → middleware → render chain.
|
|
@@ -131,6 +131,17 @@ export function isApiRouteChain(segments: readonly ManifestSegmentNode[]): boole
|
|
|
131
131
|
return !!leaf?.route && !leaf.page;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Whether a matched chain is a route.ts handler that opted out of the CSRF
|
|
136
|
+
* check with `export const csrf = false`. Keyed on the same leaf
|
|
137
|
+
* `isApiRouteChain` dispatches on, so the exemption can only ever apply to
|
|
138
|
+
* the route.ts that will actually run — never to a page, and so never to a
|
|
139
|
+
* server action. See design/08-forms-and-actions.md §"Per-route exemption".
|
|
140
|
+
*/
|
|
141
|
+
export function isCsrfExemptRoute(segments: readonly ManifestSegmentNode[]): boolean {
|
|
142
|
+
return isApiRouteChain(segments) && segments[segments.length - 1]?.csrfExempt === true;
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
// ─── Metadata Route Matcher ─────────────────────────────────────────────
|
|
135
146
|
|
|
136
147
|
/** Result of matching a metadata route. */
|
|
@@ -1,36 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Server action dispatcher — the `dispatchAction` callback for
|
|
3
|
+
* `PipelineConfig`.
|
|
3
4
|
*
|
|
4
5
|
* Extracted from rsc-entry/index.ts so the wiring can be unit-tested in
|
|
5
6
|
* isolation from Vite's virtual modules.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* **Pipeline-boundary CSRF validation** — runs on EVERY unsafe-method
|
|
12
|
-
* request, before any dispatch decision. This is the only line of
|
|
13
|
-
* defense for `route.ts` API handlers and server actions. See LOCAL-773.
|
|
14
|
-
*
|
|
15
|
-
* Server action dispatch has moved INTO the pipeline (`PipelineConfig.
|
|
16
|
-
* dispatchAction`) so that `proxy.ts` runs on action POSTs — matching the
|
|
17
|
-
* design doc contract: "proxy.ts runs on every request, no exclusions."
|
|
18
|
-
*
|
|
19
|
-
* The `buildActionDispatcher` function constructs the `dispatchAction`
|
|
20
|
-
* callback that `createPipeline` calls after proxy.ts runs. It
|
|
21
|
-
* encapsulates action detection, route-type check, action handler
|
|
8
|
+
* `createPipeline` calls it after proxy.ts runs (TIM-1213), so proxy.ts
|
|
9
|
+
* sees action POSTs — "proxy.ts runs on every request, no exclusions." It
|
|
10
|
+
* encapsulates action detection, the route-type check, action handler
|
|
22
11
|
* dispatch, and the no-JS validation rerender path.
|
|
23
12
|
*
|
|
13
|
+
* The CSRF gate is not here: it runs in `createPipeline` before proxy.ts
|
|
14
|
+
* (`csrfGate` in pipeline-helpers.ts). The action handler repeats the
|
|
15
|
+
* check as defense-in-depth, with no per-route exemption.
|
|
16
|
+
*
|
|
24
17
|
* Per-segment `middleware.ts` does NOT run on action POSTs (TIM-1134).
|
|
25
|
-
* Actions are dispatched by action ID, not by route segment.
|
|
26
|
-
* validation
|
|
27
|
-
* belongs in `createActionClient({ middleware })`.
|
|
18
|
+
* Actions are dispatched by action ID, not by route segment. Per-action
|
|
19
|
+
* auth/validation belongs in `createActionClient({ middleware })`.
|
|
28
20
|
*/
|
|
29
21
|
|
|
30
22
|
import type { FormRerender } from '../action-handler.ts';
|
|
31
23
|
import { handleActionRequest, isActionRequest } from '../action-handler.ts';
|
|
32
24
|
import type { BodyLimitsConfig } from '../body-limits.ts';
|
|
33
|
-
import {
|
|
25
|
+
import type { CsrfConfig } from '../csrf.ts';
|
|
34
26
|
import { runWithFormFlash } from '../form-flash.ts';
|
|
35
27
|
import type { RouteMatcher } from '../pipeline.ts';
|
|
36
28
|
import { isApiRouteChain } from '../route-matcher.ts';
|
|
@@ -50,12 +42,6 @@ import type { RevalidateRenderer } from '../actions.ts';
|
|
|
50
42
|
*/
|
|
51
43
|
export type RevalidateRendererFactory = (req: Request) => RevalidateRenderer;
|
|
52
44
|
|
|
53
|
-
/** Dependencies for the CSRF wrapper. */
|
|
54
|
-
export interface ActionDispatchDeps {
|
|
55
|
-
/** CSRF configuration (Origin allow-list, on/off switch). */
|
|
56
|
-
csrfConfig: CsrfConfig;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
45
|
/** Dependencies for the action dispatcher (used inside the pipeline). */
|
|
60
46
|
export interface ActionDispatcherDeps {
|
|
61
47
|
/** CSRF configuration — defense-in-depth check inside the action handler. */
|
|
@@ -68,38 +54,12 @@ export interface ActionDispatcherDeps {
|
|
|
68
54
|
buildRevalidateRenderer: RevalidateRendererFactory;
|
|
69
55
|
/**
|
|
70
56
|
* Route matcher — used for the route-type check that skips action
|
|
71
|
-
* detection on `route.ts` API handlers (TIM-870).
|
|
57
|
+
* detection on `route.ts` API handlers (TIM-870). Required: without it,
|
|
58
|
+
* every form POST to a route.ts would enter the action handler, whose
|
|
59
|
+
* CSRF check has no per-route exemption, and a `csrf = false` route
|
|
60
|
+
* would 403 its cross-site callbacks.
|
|
72
61
|
*/
|
|
73
|
-
matchRoute
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ─── CSRF Wrapper ─────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Wrap a pipeline function with pipeline-boundary CSRF validation.
|
|
80
|
-
*
|
|
81
|
-
* The returned handler is the framework's outermost request entry point.
|
|
82
|
-
* It validates the `Origin` header on every unsafe-method request before
|
|
83
|
-
* any dispatch decision — this is the ONLY line of defense for `route.ts`
|
|
84
|
-
* API handlers, which never see the action handler.
|
|
85
|
-
*
|
|
86
|
-
* Action dispatch has moved inside the pipeline (TIM-1213) so proxy.ts
|
|
87
|
-
* runs on every request. This wrapper now only handles CSRF.
|
|
88
|
-
*/
|
|
89
|
-
export function wrapPipelineWithActionDispatch(
|
|
90
|
-
pipeline: (req: Request) => Promise<Response>,
|
|
91
|
-
deps: ActionDispatchDeps
|
|
92
|
-
): (req: Request) => Promise<Response> {
|
|
93
|
-
return async (req: Request): Promise<Response> => {
|
|
94
|
-
// Pipeline-boundary CSRF validation (LOCAL-773).
|
|
95
|
-
// Runs on EVERY unsafe-method request, before any dispatch decision.
|
|
96
|
-
const csrfResult = validateCsrf(req, deps.csrfConfig);
|
|
97
|
-
if (!csrfResult.ok) {
|
|
98
|
-
return csrfRejectionResponse(req, csrfResult);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return pipeline(req);
|
|
102
|
-
};
|
|
62
|
+
matchRoute: RouteMatcher;
|
|
103
63
|
}
|
|
104
64
|
|
|
105
65
|
// ─── Action Dispatcher ────────────────────────────────────────────────────
|
|
@@ -136,11 +96,9 @@ export function buildActionDispatcher(
|
|
|
136
96
|
//
|
|
137
97
|
// Use `canonicalPath` from the pipeline — NOT `new URL(req.url).pathname`,
|
|
138
98
|
// which re-encodes the path. The matcher expects the decoded canonical form.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
99
|
+
const match = deps.matchRoute(canonicalPath);
|
|
100
|
+
if (match && isApiRouteChain(match.segments)) {
|
|
101
|
+
return null;
|
|
144
102
|
}
|
|
145
103
|
|
|
146
104
|
// Action handler dispatch
|