@timber-js/app 0.2.0-alpha.167 → 0.2.0-alpha.169
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-TSxpXLHJ.js → actions-O_LsyCE4.js} +3 -3
- package/dist/_chunks/{actions-TSxpXLHJ.js.map → actions-O_LsyCE4.js.map} +1 -1
- package/dist/_chunks/{cache-api-DzpQQOEx.js → cache-api-B-lhk9p4.js} +56 -11
- package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-wX-i90Og.js → cli-schema-sync-B73L6pMq.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-wX-i90Og.js.map → cli-schema-sync-B73L6pMq.js.map} +1 -1
- package/dist/_chunks/json-lossy-check-ClNvBM_3.js +63 -0
- package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
- package/dist/_chunks/{logger-t3uxAmbX.js → logger-AWfuX-KJ.js} +2 -19
- package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
- package/dist/_chunks/{walkers-Cfwvl-UC.js → walkers-CoOC8Hga.js} +2 -2
- package/dist/_chunks/{walkers-Cfwvl-UC.js.map → walkers-CoOC8Hga.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +9 -2
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/json-lossy-check.d.ts +11 -0
- package/dist/cache/json-lossy-check.d.ts.map +1 -0
- package/dist/cache/redis-handler.d.ts +42 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/stores/cloudflare-kv.d.ts +1 -1
- package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -1
- package/dist/cache/stores/memory.d.ts +1 -1
- package/dist/cache/stores/memory.d.ts.map +1 -1
- package/dist/cache/stores/redis.d.ts +1 -1
- package/dist/cache/stores/redis.d.ts.map +1 -1
- package/dist/cache/stores/vercel.d.ts +1 -1
- package/dist/cache/stores/vercel.d.ts.map +1 -1
- package/dist/cache/tag-aware-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cdn/cloudflare-purge.d.ts.map +1 -1
- package/dist/cdn/fastly-purge.d.ts.map +1 -1
- package/dist/cdn/workers-cache-purge.d.ts.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -2
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +1 -0
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/child-segment-context.d.ts +2 -2
- package/dist/client/child-segment-context.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/internal.js +4 -3
- package/dist/client/internal.js.map +1 -1
- package/dist/client/router.d.ts +6 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-update-context.d.ts +3 -3
- package/dist/client/segment-update-context.d.ts.map +1 -1
- package/dist/client/slot-outlet.d.ts +1 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +2 -1
- package/dist/codec.js.map +1 -1
- package/dist/config-types.d.ts +16 -37
- package/dist/config-types.d.ts.map +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/dev-tools/instrumentation.d.ts.map +1 -1
- package/dist/fonts/pipeline.d.ts +19 -0
- 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.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +344 -84
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/adapter-build.d.ts +1 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +27 -3
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/action-client.d.ts +1 -1
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/body-limits.d.ts.map +1 -1
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/index.js +20 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +24 -40
- package/dist/server/internal.js.map +1 -1
- package/dist/server/middleware-runner.d.ts +0 -17
- package/dist/server/middleware-runner.d.ts.map +1 -1
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts +6 -3
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +18 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/prebuilt/capture-state.d.ts.map +1 -1
- package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/render-timeout.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-middleware-runner.d.ts +14 -14
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +1 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +44 -74
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
- package/dist/server/safe-load.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/shared/redirect-type.d.ts +2 -2
- package/dist/shared/redirect-type.d.ts.map +1 -1
- package/dist/shims/font-google.d.ts.map +1 -1
- package/dist/shims/image.d.ts +120 -120
- package/dist/shims/image.d.ts.map +1 -1
- package/docs/api/32-api-cache.mdx +112 -0
- package/docs/api/34-api-config.mdx +0 -26
- package/docs/learn/09-caching.mdx +121 -5
- package/docs/learn/12-client-navigation.mdx +9 -1
- package/docs/learn/13-configuration.mdx +6 -8
- package/docs/learn/14-deploying.mdx +18 -18
- package/docs/more/04-metadata-and-fonts.mdx +1 -1
- package/package.json +6 -6
- package/src/adapters/cloudflare-kv-cache.ts +27 -6
- package/src/cache/index.ts +1 -1
- package/src/cache/json-lossy-check.ts +75 -0
- package/src/cache/redis-handler.ts +65 -14
- package/src/cache/timber-cache.ts +10 -2
- package/src/client/browser-entry/index.ts +2 -0
- package/src/client/browser-entry/router-init.ts +2 -0
- package/src/client/router.ts +13 -3
- package/src/codec.ts +3 -1
- package/src/config-types.ts +16 -37
- package/src/config-validation.ts +7 -5
- package/src/fonts/pipeline.ts +32 -0
- package/src/fonts/transform.ts +30 -24
- package/src/plugins/adapter-build.ts +131 -13
- package/src/plugins/entries.ts +40 -43
- package/src/plugins/fonts.ts +30 -0
- package/src/plugins/server-bundle.ts +7 -15
- package/src/server/action-client.ts +1 -1
- package/src/server/middleware-runner.ts +0 -44
- package/src/server/param-coercion.ts +1 -0
- package/src/server/pipeline-phases.ts +11 -13
- package/src/server/pipeline.ts +51 -3
- package/src/server/route-element-builder.ts +10 -1
- package/src/server/rsc-entry/action-middleware-runner.ts +14 -14
- package/src/server/rsc-entry/index.ts +30 -40
- package/src/server/rsc-entry/render-route.ts +3 -1
- package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
- package/dist/_chunks/cache-api-DzpQQOEx.js.map +0 -1
- package/dist/_chunks/logger-t3uxAmbX.js.map +0 -1
- package/dist/_chunks/tree-match-D2l830j2.js +0 -102
- package/dist/_chunks/tree-match-D2l830j2.js.map +0 -1
|
@@ -2,41 +2,29 @@
|
|
|
2
2
|
* Action-dispatch wrapper around the route pipeline.
|
|
3
3
|
*
|
|
4
4
|
* Extracted from rsc-entry/index.ts so the wiring can be unit-tested in
|
|
5
|
-
* isolation from Vite's virtual modules.
|
|
6
|
-
* four things, in order:
|
|
5
|
+
* isolation from Vite's virtual modules.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* request, before any dispatch decision. This is the only line of
|
|
10
|
-
* defense for `route.ts` API handlers, which never see the action
|
|
11
|
-
* handler. See LOCAL-773.
|
|
7
|
+
* ## Architecture (TIM-1213)
|
|
12
8
|
*
|
|
13
|
-
*
|
|
14
|
-
* action POST and `actions.runMiddleware !== false`, the matched
|
|
15
|
-
* route's `middleware.ts` chain runs BEFORE the action body. This
|
|
16
|
-
* closes the Next.js CVE-2025-29927 class of bug, where developers
|
|
17
|
-
* reasonably believe `middleware.ts` runs on every request and find
|
|
18
|
-
* out (the hard way) that actions silently bypass it. Middleware can
|
|
19
|
-
* short-circuit with a `Response`, `redirect()`, or `deny()`; can
|
|
20
|
-
* mutate cookies; and can inject request headers visible to the
|
|
21
|
-
* action body via `getHeaders()`. See TIM-871.
|
|
9
|
+
* The wrapper is responsible for ONE thing:
|
|
22
10
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* returns either an RSC response or a no-JS rerender signal.
|
|
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.
|
|
27
14
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* so server components can read the flash. The synthetic GET is
|
|
32
|
-
* marked via `markRequestBypassMiddleware` so the pipeline does not
|
|
33
|
-
* double-execute middleware on it. See TIM-836 / TIM-837 / TIM-871.
|
|
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."
|
|
34
18
|
*
|
|
35
|
-
*
|
|
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
|
|
22
|
+
* dispatch, and the no-JS validation rerender path.
|
|
36
23
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
24
|
+
* Per-segment `middleware.ts` does NOT run on action POSTs (TIM-1134).
|
|
25
|
+
* Actions are dispatched by action ID, not by route segment. CSRF
|
|
26
|
+
* validation runs at the pipeline boundary. Per-action auth/validation
|
|
27
|
+
* belongs in `createActionClient({ middleware })`.
|
|
40
28
|
*/
|
|
41
29
|
|
|
42
30
|
import type { FormRerender } from '../action-handler.js';
|
|
@@ -45,19 +33,9 @@ import type { BodyLimitsConfig } from '../body-limits.js';
|
|
|
45
33
|
import { validateCsrf, type CsrfConfig } from '../csrf.js';
|
|
46
34
|
import { runWithFormFlash } from '../form-flash.js';
|
|
47
35
|
import { seedRequestCookies } from '../cookie-context.js';
|
|
48
|
-
import {
|
|
49
|
-
import { DenySignal } from '../primitives.js';
|
|
50
|
-
import { canonicalize } from '../canonicalize.js';
|
|
51
|
-
import {
|
|
52
|
-
buildRedirectResponse,
|
|
53
|
-
cloneWithMutableHeaders,
|
|
54
|
-
fireOnRequestError,
|
|
55
|
-
} from '../pipeline-helpers.js';
|
|
56
|
-
import { logRenderError } from '../logger.js';
|
|
57
|
-
import type { RouteMatch, RouteMatcher } from '../pipeline.js';
|
|
36
|
+
import type { RouteMatcher } from '../pipeline.js';
|
|
58
37
|
import type { SensitiveFieldsOption } from '../sensitive-fields.js';
|
|
59
38
|
import type { RevalidateRenderer } from '../actions.js';
|
|
60
|
-
import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-middleware-runner.js';
|
|
61
39
|
|
|
62
40
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
63
41
|
|
|
@@ -72,18 +50,16 @@ import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-mid
|
|
|
72
50
|
*/
|
|
73
51
|
export type RevalidateRendererFactory = (req: Request) => RevalidateRenderer;
|
|
74
52
|
|
|
75
|
-
/**
|
|
76
|
-
export type RenderDenyFallbackFn = (
|
|
77
|
-
deny: DenySignal,
|
|
78
|
-
req: Request,
|
|
79
|
-
responseHeaders: Headers,
|
|
80
|
-
match?: RouteMatch
|
|
81
|
-
) => Response | Promise<Response>;
|
|
82
|
-
|
|
83
|
-
/** Dependencies for the action-dispatch wrapper. */
|
|
53
|
+
/** Dependencies for the CSRF wrapper. */
|
|
84
54
|
export interface ActionDispatchDeps {
|
|
85
55
|
/** CSRF configuration (Origin allow-list, on/off switch). */
|
|
86
56
|
csrfConfig: CsrfConfig;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Dependencies for the action dispatcher (used inside the pipeline). */
|
|
60
|
+
export interface ActionDispatcherDeps {
|
|
61
|
+
/** CSRF configuration — defense-in-depth check inside the action handler. */
|
|
62
|
+
csrfConfig: CsrfConfig;
|
|
87
63
|
/** Body size limits forwarded to `handleActionRequest`. */
|
|
88
64
|
bodyLimits?: BodyLimitsConfig['limits'];
|
|
89
65
|
/** Sensitive-field deny-list forwarded to `handleActionRequest`. */
|
|
@@ -91,359 +67,120 @@ export interface ActionDispatchDeps {
|
|
|
91
67
|
/** Per-request factory that builds a `RevalidateRenderer`. */
|
|
92
68
|
buildRevalidateRenderer: RevalidateRendererFactory;
|
|
93
69
|
/**
|
|
94
|
-
* Route matcher —
|
|
95
|
-
*
|
|
96
|
-
* middleware path may omit this; the wrapper falls back to the legacy
|
|
97
|
-
* "actions skip middleware" behavior.
|
|
70
|
+
* Route matcher — used for the route-type check that skips action
|
|
71
|
+
* detection on `route.ts` API handlers (TIM-870).
|
|
98
72
|
*/
|
|
99
73
|
matchRoute?: RouteMatcher;
|
|
100
|
-
/**
|
|
101
|
-
* Segment-param coercer — runs the matched route's `params.ts` codecs
|
|
102
|
-
* so the middleware context sees typed `segmentParams`. Required when
|
|
103
|
-
* `matchRoute` is provided.
|
|
104
|
-
*/
|
|
105
|
-
coerceSegmentParams?: CoerceSegmentParamsFn;
|
|
106
|
-
/**
|
|
107
|
-
* Renderer for fallback deny pages — used when middleware throws a
|
|
108
|
-
* `DenySignal` and the framework wants to render `403.tsx` / `404.tsx`
|
|
109
|
-
* instead of returning a bare empty Response. Optional — when omitted
|
|
110
|
-
* the wrapper falls back to a bare status response.
|
|
111
|
-
*/
|
|
112
|
-
renderDenyFallback?: RenderDenyFallbackFn;
|
|
113
|
-
/**
|
|
114
|
-
* Whether to run `middleware.ts` on server action requests. Defaults
|
|
115
|
-
* to `true` (the safe default). Controlled by
|
|
116
|
-
* `actions.runMiddleware` in `timber.config.ts`. See TIM-871.
|
|
117
|
-
*/
|
|
118
|
-
runMiddleware?: boolean;
|
|
119
|
-
/**
|
|
120
|
-
* Whether to strip trailing slashes during pathname canonicalization
|
|
121
|
-
* for route matching. Mirrors `PipelineConfig.stripTrailingSlash`;
|
|
122
|
-
* defaults to `true` when omitted, matching the page pipeline. The
|
|
123
|
-
* wrapper MUST canonicalize before matching so non-canonical action
|
|
124
|
-
* POSTs (`/admin/`, `/admin//`, encoded separators) cannot bypass the
|
|
125
|
-
* middleware gate by missing the route match and falling through to
|
|
126
|
-
* the legacy path. See TIM-871 / codex review.
|
|
127
|
-
*/
|
|
128
|
-
stripTrailingSlash?: boolean;
|
|
129
74
|
}
|
|
130
75
|
|
|
131
|
-
// ───
|
|
76
|
+
// ─── CSRF Wrapper ─────────────────────────────────────────────────────────
|
|
132
77
|
|
|
133
78
|
/**
|
|
134
|
-
* Wrap a pipeline function with CSRF validation
|
|
79
|
+
* Wrap a pipeline function with pipeline-boundary CSRF validation.
|
|
135
80
|
*
|
|
136
81
|
* The returned handler is the framework's outermost request entry point.
|
|
137
|
-
*
|
|
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.
|
|
138
85
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* remains safe to call from any future entry point that bypasses this
|
|
142
|
-
* wrapper. See LOCAL-773.
|
|
86
|
+
* Action dispatch has moved inside the pipeline (TIM-1213) so proxy.ts
|
|
87
|
+
* runs on every request. This wrapper now only handles CSRF.
|
|
143
88
|
*/
|
|
144
89
|
export function wrapPipelineWithActionDispatch(
|
|
145
90
|
pipeline: (req: Request) => Promise<Response>,
|
|
146
91
|
deps: ActionDispatchDeps
|
|
147
92
|
): (req: Request) => Promise<Response> {
|
|
148
|
-
const middlewareEnabled = deps.runMiddleware !== false;
|
|
149
|
-
const stripTrailingSlash = deps.stripTrailingSlash ?? true;
|
|
150
|
-
|
|
151
93
|
return async (req: Request): Promise<Response> => {
|
|
152
|
-
//
|
|
153
|
-
//
|
|
94
|
+
// Pipeline-boundary CSRF validation (LOCAL-773).
|
|
154
95
|
// Runs on EVERY unsafe-method request, before any dispatch decision.
|
|
155
|
-
// Without this, `route.ts` PUT/PATCH/DELETE handlers and POSTs with
|
|
156
|
-
// `Content-Type: application/json` or `text/plain` would reach the
|
|
157
|
-
// route handler with no Origin check at all — `isActionRequest` only
|
|
158
|
-
// matches POST + form/multipart/x-rsc-action.
|
|
159
|
-
//
|
|
160
|
-
// `text/plain` POST is a CORS-simple request, so a cross-site
|
|
161
|
-
// `<form enctype="text/plain">` submission carries `SameSite=Lax`
|
|
162
|
-
// cookies (the framework's own default).
|
|
163
96
|
const csrfResult = validateCsrf(req, deps.csrfConfig);
|
|
164
97
|
if (!csrfResult.ok) {
|
|
165
98
|
return new Response(null, { status: csrfResult.status });
|
|
166
99
|
}
|
|
167
100
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
// Canonicalize and match the route BEFORE any body parsing. If the
|
|
173
|
-
// matched route is an API route (route.ts), skip action detection
|
|
174
|
-
// entirely and dispatch straight to the pipeline. Server actions
|
|
175
|
-
// only live on page routes; POSTs to route.ts are API requests
|
|
176
|
-
// whose body must reach the handler untouched — not pre-parsed
|
|
177
|
-
// looking for $ACTION_REF fields.
|
|
178
|
-
//
|
|
179
|
-
// This also avoids allocating a full formData() parse over large
|
|
180
|
-
// multipart uploads (e.g. 50 MB file uploads to /api/upload) that
|
|
181
|
-
// would otherwise be buffered and discarded by handleFormAction.
|
|
182
|
-
//
|
|
183
|
-
// The same canonicalized match is reused for the middleware-on-
|
|
184
|
-
// actions path below, avoiding a redundant match.
|
|
185
|
-
let match: RouteMatch | null = null;
|
|
101
|
+
return pipeline(req);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
186
104
|
|
|
187
|
-
|
|
188
|
-
// Canonicalize the pathname BEFORE matching, with the exact same
|
|
189
|
-
// rules the page pipeline uses (`canonicalize()` in
|
|
190
|
-
// `pipeline-phases.ts` stage 1). Without this, an attacker could
|
|
191
|
-
// POST to a non-canonical variant of an authenticated route —
|
|
192
|
-
// `/admin/` with a trailing slash, `/admin//` with a doubled
|
|
193
|
-
// slash, `/adm%69n` with percent-escapes, `/admin%2fnested` with
|
|
194
|
-
// an encoded separator — fail the match here, fall through to
|
|
195
|
-
// the legacy "no middleware" path, and still reach the action
|
|
196
|
-
// handler. The canonicalization step closes that bypass and
|
|
197
|
-
// guarantees the wrapper matches EXACTLY the same route the page
|
|
198
|
-
// pipeline would match. A canonicalize failure (encoded
|
|
199
|
-
// separator, null byte, malformed escape, `..` escaping root)
|
|
200
|
-
// returns the canonicalizer's status directly — the request is
|
|
201
|
-
// malformed, never dispatched. See TIM-871 (codex review).
|
|
202
|
-
const url = new URL(req.url);
|
|
203
|
-
const canonical = canonicalize(url.pathname, stripTrailingSlash);
|
|
204
|
-
if (!canonical.ok) {
|
|
205
|
-
return new Response(null, { status: canonical.status });
|
|
206
|
-
}
|
|
207
|
-
match = deps.matchRoute(canonical.pathname);
|
|
105
|
+
// ─── Action Dispatcher ────────────────────────────────────────────────────
|
|
208
106
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Build the `dispatchAction` callback for `PipelineConfig`.
|
|
109
|
+
*
|
|
110
|
+
* Called after proxy.ts runs (inside the pipeline) for every request.
|
|
111
|
+
* Checks if the request is a server action POST, handles the route-type
|
|
112
|
+
* check (TIM-870), dispatches to the action handler, and handles the
|
|
113
|
+
* no-JS validation rerender path.
|
|
114
|
+
*
|
|
115
|
+
* Returns `Response` if the request was handled as an action, `null` to
|
|
116
|
+
* fall through to normal routing.
|
|
117
|
+
*/
|
|
118
|
+
export function buildActionDispatcher(
|
|
119
|
+
deps: ActionDispatcherDeps
|
|
120
|
+
): (
|
|
121
|
+
req: Request,
|
|
122
|
+
canonicalPath: string,
|
|
123
|
+
reenter: (req: Request) => Promise<Response>
|
|
124
|
+
) => Promise<Response | null> {
|
|
125
|
+
return async (
|
|
126
|
+
req: Request,
|
|
127
|
+
canonicalPath: string,
|
|
128
|
+
reenter: (req: Request) => Promise<Response>
|
|
129
|
+
): Promise<Response | null> => {
|
|
130
|
+
if (!isActionRequest(req)) return null;
|
|
131
|
+
|
|
132
|
+
// Route-type check (TIM-870): match the route BEFORE any body parsing.
|
|
133
|
+
// If the matched route is an API route (route.ts), skip action detection
|
|
134
|
+
// entirely — server actions only live on page routes; POSTs to route.ts
|
|
135
|
+
// are API requests whose body must reach the handler untouched.
|
|
136
|
+
//
|
|
137
|
+
// Use `canonicalPath` from the pipeline — NOT `new URL(req.url).pathname`,
|
|
138
|
+
// which re-encodes the path. The matcher expects the decoded canonical form.
|
|
139
|
+
if (deps.matchRoute) {
|
|
140
|
+
const match = deps.matchRoute(canonicalPath);
|
|
141
|
+
if (match) {
|
|
142
|
+
const leaf = match.segments[match.segments.length - 1];
|
|
143
|
+
if (leaf?.route) {
|
|
144
|
+
return null;
|
|
218
145
|
}
|
|
219
146
|
}
|
|
147
|
+
}
|
|
220
148
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// Bad segment params → 404. Matches the page pipeline.
|
|
245
|
-
return new Response(null, { status: 404 });
|
|
246
|
-
}
|
|
247
|
-
if (outcome.kind === 'error') {
|
|
248
|
-
// Unhandled middleware error → 500.
|
|
249
|
-
return new Response(null, { status: 500 });
|
|
250
|
-
}
|
|
251
|
-
if (outcome.kind === 'short-circuit') {
|
|
252
|
-
// Middleware returned a Response. Apply its Set-Cookie
|
|
253
|
-
// snapshot before returning. Clone unconditionally so the
|
|
254
|
-
// header bag is mutable.
|
|
255
|
-
const finalResponse = cloneWithMutableHeaders(outcome.response);
|
|
256
|
-
for (const value of outcome.setCookieHeaders) {
|
|
257
|
-
finalResponse.headers.append('Set-Cookie', value);
|
|
258
|
-
}
|
|
259
|
-
return finalResponse;
|
|
260
|
-
}
|
|
261
|
-
if (outcome.kind === 'redirect') {
|
|
262
|
-
// RedirectSignal from middleware → standard redirect Response.
|
|
263
|
-
// Use `buildRedirectResponse` so the with-JS path produces a
|
|
264
|
-
// 204 + X-Timber-Redirect (SPA navigation) and the no-JS path
|
|
265
|
-
// produces a real HTTP 30x — exactly the same translation
|
|
266
|
-
// the page pipeline applies in `outcomeToResponse`.
|
|
267
|
-
const headers = new Headers();
|
|
268
|
-
for (const value of outcome.setCookieHeaders) {
|
|
269
|
-
headers.append('Set-Cookie', value);
|
|
270
|
-
}
|
|
271
|
-
return buildRedirectResponse(outcome.signal, req, headers);
|
|
272
|
-
}
|
|
273
|
-
if (outcome.kind === 'deny') {
|
|
274
|
-
// DenySignal from middleware → render the matched route's
|
|
275
|
-
// colocated deny page if available, otherwise a bare empty
|
|
276
|
-
// status response. Mirrors the page pipeline's deny handling.
|
|
277
|
-
const headers = new Headers();
|
|
278
|
-
for (const value of outcome.setCookieHeaders) {
|
|
279
|
-
headers.append('Set-Cookie', value);
|
|
280
|
-
}
|
|
281
|
-
if (deps.renderDenyFallback) {
|
|
282
|
-
try {
|
|
283
|
-
return cloneWithMutableHeaders(
|
|
284
|
-
await deps.renderDenyFallback(outcome.signal, req, headers, outcome.match)
|
|
285
|
-
);
|
|
286
|
-
} catch (denyRenderError) {
|
|
287
|
-
// Deny page rendering failed — log before falling through to bare
|
|
288
|
-
// response. Without this, a crashing deny page on the action path
|
|
289
|
-
// produces a blank response with zero server-side signal. See TIM-876.
|
|
290
|
-
const url = new URL(req.url);
|
|
291
|
-
logRenderError({ method: req.method, path: url.pathname, error: denyRenderError });
|
|
292
|
-
await fireOnRequestError(denyRenderError, req, 'render');
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return new Response(null, { status: outcome.signal.status, headers });
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ─── Continue: middleware passed ──────────────────────────
|
|
299
|
-
//
|
|
300
|
-
// Build a downstream Request that the action handler will see:
|
|
301
|
-
// - Original headers + middleware's request-header overlay
|
|
302
|
-
// merged on top, so `getHeaders()` inside the action body
|
|
303
|
-
// observes the injected values.
|
|
304
|
-
// - Cookie header dropped; the post-middleware RYW cookie
|
|
305
|
-
// state is threaded directly into the action handler's
|
|
306
|
-
// request context via `seedRequestCookies`. This avoids
|
|
307
|
-
// a Cookie-header round-trip that would re-parse via
|
|
308
|
-
// `parseCookieHeader` — the same H-3 smuggling primitive
|
|
309
|
-
// mitigated by TIM-868. The action's own cookie reads
|
|
310
|
-
// therefore observe both the original cookies and any
|
|
311
|
-
// middleware mutations, with no cleartext encoding step
|
|
312
|
-
// in between.
|
|
313
|
-
// - Body forwarded as-is so the action handler can decode it.
|
|
314
|
-
// - Method preserved (POST).
|
|
315
|
-
//
|
|
316
|
-
// We hold the middleware Set-Cookie snapshot to prepend to the
|
|
317
|
-
// final response below — middleware writes precede action
|
|
318
|
-
// writes in the response order so the browser's last-wins
|
|
319
|
-
// resolution lets the action override middleware on conflict.
|
|
320
|
-
const mergedHeaders = new Headers(req.headers);
|
|
321
|
-
outcome.overlay.forEach((value, key) => {
|
|
322
|
-
mergedHeaders.set(key, value);
|
|
323
|
-
});
|
|
324
|
-
mergedHeaders.delete('cookie');
|
|
325
|
-
downstreamReq = new Request(req.url, {
|
|
326
|
-
method: req.method,
|
|
327
|
-
headers: mergedHeaders,
|
|
328
|
-
body: req.body,
|
|
329
|
-
// Required by undici when constructing a Request with a
|
|
330
|
-
// streaming body — `req.body` is a ReadableStream and the
|
|
331
|
-
// fetch spec needs an explicit half-duplex declaration.
|
|
332
|
-
// @ts-expect-error — `duplex` is not in the standard Request init type yet.
|
|
333
|
-
duplex: 'half',
|
|
149
|
+
// Action handler dispatch
|
|
150
|
+
const actionResponse = await handleActionRequest(req, {
|
|
151
|
+
csrf: deps.csrfConfig,
|
|
152
|
+
bodyLimits: { limits: deps.bodyLimits },
|
|
153
|
+
sensitiveFields: deps.sensitiveFields,
|
|
154
|
+
revalidateRenderer: deps.buildRevalidateRenderer(req),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (actionResponse) {
|
|
158
|
+
// No-JS validation rerender
|
|
159
|
+
if ('rerender' in actionResponse) {
|
|
160
|
+
const formRerender = actionResponse as FormRerender;
|
|
161
|
+
// Build a synthetic GET request for the rerender pipeline:
|
|
162
|
+
// - Same URL (so route matching lands on the same page)
|
|
163
|
+
// - Cookie header DROPPED entirely. The post-action RYW state
|
|
164
|
+
// is threaded into the rerender request context as a parsed
|
|
165
|
+
// `Map<string, string>` via `seedRequestCookies` below.
|
|
166
|
+
// - Method GET because the rerender is conceptually a page render.
|
|
167
|
+
const rerenderHeaders = new Headers(req.headers);
|
|
168
|
+
rerenderHeaders.delete('cookie');
|
|
169
|
+
const rerenderReq = new Request(req.url, {
|
|
170
|
+
method: 'GET',
|
|
171
|
+
headers: rerenderHeaders,
|
|
334
172
|
});
|
|
335
|
-
seedRequestCookies(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
// The revalidate renderer is built from the ORIGINAL request, not
|
|
342
|
-
// `downstreamReq`. The downstream request had its `cookie` header
|
|
343
|
-
// stripped (the post-middleware RYW cookie state is threaded
|
|
344
|
-
// through ALS via `seedRequestCookies` instead, to preserve the
|
|
345
|
-
// H-3 smuggling invariant from TIM-868). But the revalidate
|
|
346
|
-
// renderer in `rsc-entry/index.ts` forwards `req.headers` onto the
|
|
347
|
-
// synthetic revalidation Request it builds for the target path —
|
|
348
|
-
// if we passed `downstreamReq` here, that synthetic request would
|
|
349
|
-
// carry no cookies, and an action that calls `revalidatePath()`
|
|
350
|
-
// would re-render the target route with no session / tenant / auth
|
|
351
|
-
// context. Using `req` (the unmodified inbound request) preserves
|
|
352
|
-
// the original cookie header for the revalidation side channel
|
|
353
|
-
// while `seedRequestCookies` handles the middleware RYW state for
|
|
354
|
-
// the action body itself. See TIM-871 (codex review).
|
|
355
|
-
const actionResponse = await handleActionRequest(downstreamReq, {
|
|
356
|
-
csrf: deps.csrfConfig,
|
|
357
|
-
bodyLimits: { limits: deps.bodyLimits },
|
|
358
|
-
sensitiveFields: deps.sensitiveFields,
|
|
359
|
-
revalidateRenderer: deps.buildRevalidateRenderer(req),
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
if (actionResponse) {
|
|
363
|
-
// ─── 3. No-JS validation rerender ─────────────────────────────
|
|
364
|
-
if ('rerender' in actionResponse) {
|
|
365
|
-
const formRerender = actionResponse as FormRerender;
|
|
366
|
-
// Build a synthetic GET request for the rerender pipeline:
|
|
367
|
-
// - Same URL (so route matching lands on the same page)
|
|
368
|
-
// - Cookie header DROPPED entirely. The post-action RYW state
|
|
369
|
-
// is threaded into the rerender request context as a parsed
|
|
370
|
-
// `Map<string, string>` via `seedRequestCookies` below, so
|
|
371
|
-
// `parseCookieHeader` is never called for this request.
|
|
372
|
-
// This eliminates the value-smuggling primitive that the
|
|
373
|
-
// previous string round-trip exposed: a `;`-laden cookie
|
|
374
|
-
// value would otherwise split into sibling cookies during
|
|
375
|
-
// re-parse and let an attacker inject `role=admin` /
|
|
376
|
-
// forged session cookies into the rerender response. See
|
|
377
|
-
// ONGOING_SECURITY.md H-3 (TIM-868) and TIM-837.
|
|
378
|
-
// - Method GET because the rerender is conceptually a page
|
|
379
|
-
// render, not a re-POST. The pipeline doesn't branch on
|
|
380
|
-
// method for page rendering, and constructing a POST without
|
|
381
|
-
// a body is awkward across Request implementations.
|
|
382
|
-
// - Marked via `markRequestBypassMiddleware` so the pipeline
|
|
383
|
-
// skips its middleware phase on this synthetic request.
|
|
384
|
-
// Middleware already ran once on the inbound POST (above);
|
|
385
|
-
// letting the pipeline run it again would double-execute
|
|
386
|
-
// auth, rate limiting, and request-header injection. See
|
|
387
|
-
// TIM-871.
|
|
388
|
-
const rerenderHeaders = new Headers(req.headers);
|
|
389
|
-
rerenderHeaders.delete('cookie');
|
|
390
|
-
const rerenderReq = new Request(req.url, {
|
|
391
|
-
method: 'GET',
|
|
392
|
-
headers: rerenderHeaders,
|
|
393
|
-
});
|
|
394
|
-
// Seed BEFORE pipeline() runs — runWithRequestContext consumes
|
|
395
|
-
// the seed when it constructs the per-request store.
|
|
396
|
-
seedRequestCookies(rerenderReq, formRerender.cookies);
|
|
397
|
-
markRequestBypassMiddleware(rerenderReq);
|
|
398
|
-
const response = await runWithFormFlash(formRerender.rerender, () =>
|
|
399
|
-
pipeline(rerenderReq)
|
|
400
|
-
);
|
|
401
|
-
// Apply Set-Cookie headers snapshotted from the action's ALS scope.
|
|
402
|
-
// The pipeline above runs in its own request context with a fresh
|
|
403
|
-
// cookie jar, so cookies set inside the action would otherwise be
|
|
404
|
-
// silently dropped on the no-JS rerender path. See TIM-836
|
|
405
|
-
// (LOCAL-740). Middleware-set cookies are prepended first so
|
|
406
|
-
// browser last-wins still lets action writes override.
|
|
407
|
-
for (const value of middlewareSetCookieHeaders) {
|
|
408
|
-
response.headers.append('Set-Cookie', value);
|
|
409
|
-
}
|
|
410
|
-
for (const value of formRerender.setCookieHeaders) {
|
|
411
|
-
response.headers.append('Set-Cookie', value);
|
|
412
|
-
}
|
|
413
|
-
return response;
|
|
173
|
+
seedRequestCookies(rerenderReq, formRerender.cookies);
|
|
174
|
+
const response = await runWithFormFlash(formRerender.rerender, () => reenter(rerenderReq));
|
|
175
|
+
// Apply Set-Cookie headers snapshotted from the action's ALS scope.
|
|
176
|
+
for (const value of formRerender.setCookieHeaders) {
|
|
177
|
+
response.headers.append('Set-Cookie', value);
|
|
414
178
|
}
|
|
415
|
-
|
|
416
|
-
// response. Action's own cookies were already appended inside
|
|
417
|
-
// `handleActionRequest` via `getSetCookieHeaders()` before the
|
|
418
|
-
// action ALS scope exited; we prepend middleware writes so
|
|
419
|
-
// browser last-wins lets action writes take precedence on
|
|
420
|
-
// conflicting names.
|
|
421
|
-
if (middlewareSetCookieHeaders.length > 0) {
|
|
422
|
-
// Middleware writes go FIRST in the response order, so we
|
|
423
|
-
// build a fresh Headers and rebuild the Response. Cloning the
|
|
424
|
-
// Response keeps the body stream intact.
|
|
425
|
-
const mergedHeaders = new Headers();
|
|
426
|
-
for (const value of middlewareSetCookieHeaders) {
|
|
427
|
-
mergedHeaders.append('Set-Cookie', value);
|
|
428
|
-
}
|
|
429
|
-
actionResponse.headers.forEach((value, key) => {
|
|
430
|
-
if (key.toLowerCase() === 'set-cookie') {
|
|
431
|
-
mergedHeaders.append('Set-Cookie', value);
|
|
432
|
-
} else {
|
|
433
|
-
mergedHeaders.set(key, value);
|
|
434
|
-
}
|
|
435
|
-
});
|
|
436
|
-
return new Response(actionResponse.body, {
|
|
437
|
-
status: actionResponse.status,
|
|
438
|
-
statusText: actionResponse.statusText,
|
|
439
|
-
headers: mergedHeaders,
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
return actionResponse;
|
|
179
|
+
return response;
|
|
443
180
|
}
|
|
181
|
+
return actionResponse;
|
|
444
182
|
}
|
|
445
183
|
|
|
446
|
-
|
|
447
|
-
return pipeline(req);
|
|
184
|
+
return null;
|
|
448
185
|
};
|
|
449
186
|
}
|