@timber-js/app 0.2.0-alpha.210 → 0.2.0-alpha.212
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/agent-skill.md +10 -5
- package/dist/_chunks/{actions-Rjk4htmA.js → actions-CCdnVtWm.js} +8 -6
- package/dist/_chunks/actions-CCdnVtWm.js.map +1 -0
- package/dist/_chunks/{als-registry-DaxkVjt5.js → als-registry-BZqHCtq-.js} +2 -4
- package/dist/_chunks/als-registry-BZqHCtq-.js.map +1 -0
- package/dist/_chunks/{cache-api-DGdYfNJn.js → cache-api-LA3sBpUS.js} +5 -5
- package/dist/_chunks/{cache-api-DGdYfNJn.js.map → cache-api-LA3sBpUS.js.map} +1 -1
- package/dist/_chunks/{chains-BoO51joc.js → chains-BfoPFraI.js} +5 -146
- package/dist/_chunks/chains-BfoPFraI.js.map +1 -0
- package/dist/_chunks/classify-BT66U83D.js +147 -0
- package/dist/_chunks/classify-BT66U83D.js.map +1 -0
- package/dist/_chunks/{cli-check-ajNY3B2e.js → cli-check-BfQ54-UJ.js} +3 -3
- package/dist/_chunks/{cli-check-ajNY3B2e.js.map → cli-check-BfQ54-UJ.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js → cli-schema-sync-czh2dsLs.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js.map → cli-schema-sync-czh2dsLs.js.map} +1 -1
- package/dist/_chunks/client-dep-entries-CQwpb8dI.js +66 -0
- package/dist/_chunks/client-dep-entries-CQwpb8dI.js.map +1 -0
- package/dist/_chunks/{convention-lint-DLmhGsRS.js → convention-lint-BEVW4EID.js} +3 -3
- package/dist/_chunks/{convention-lint-DLmhGsRS.js.map → convention-lint-BEVW4EID.js.map} +1 -1
- package/dist/_chunks/{dev-server-v97rQH4b.js → dev-server-FKxptbnI.js} +2 -2
- package/dist/_chunks/{dev-server-v97rQH4b.js.map → dev-server-FKxptbnI.js.map} +1 -1
- package/dist/_chunks/{json-lossy-check-CVuRs2hG.js → json-lossy-check-C8zBY2uZ.js} +2 -2
- package/dist/_chunks/{json-lossy-check-CVuRs2hG.js.map → json-lossy-check-C8zBY2uZ.js.map} +1 -1
- package/dist/_chunks/{live-graph-9cSnn_h9.js → live-graph-C_4v-fHv.js} +4 -3
- package/dist/_chunks/{live-graph-9cSnn_h9.js.map → live-graph-C_4v-fHv.js.map} +1 -1
- package/dist/_chunks/{logger-BP0LN6vP.js → logger-CbLdcy-W.js} +2 -2
- package/dist/_chunks/{logger-BP0LN6vP.js.map → logger-CbLdcy-W.js.map} +1 -1
- package/dist/_chunks/{poison-scan-vGV7Re0B.js → poison-scan-C92liMAr.js} +2 -4
- package/dist/_chunks/poison-scan-C92liMAr.js.map +1 -0
- package/dist/_chunks/{scanner-DmqdxzbW.js → scanner-CQt12vE2.js} +2 -2
- package/dist/_chunks/{scanner-DmqdxzbW.js.map → scanner-CQt12vE2.js.map} +1 -1
- package/dist/_chunks/{sizeof-BM1409x2.js → sizeof-QPE5nd3u.js} +2 -2
- package/dist/_chunks/{sizeof-BM1409x2.js.map → sizeof-QPE5nd3u.js.map} +1 -1
- package/dist/_chunks/{walkers-Czu2jXFq.js → walkers-DAT4avhZ.js} +3 -3
- package/dist/_chunks/{walkers-Czu2jXFq.js.map → walkers-DAT4avhZ.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/analyze/classify.d.ts +2 -0
- package/dist/analyze/classify.d.ts.map +1 -1
- package/dist/analyze/crawl-entry.js +3 -2
- package/dist/analyze/crawl-entry.js.map +1 -1
- package/dist/analyze/graph-command.js +2 -2
- package/dist/analyze/poison-scan.d.ts.map +1 -1
- package/dist/cache/index.js +2 -2
- package/dist/cache/stores/memory.js +1 -1
- package/dist/cdn/workers-cache-purge.js +1 -1
- package/dist/cli.js +3 -3
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/form.d.ts +17 -64
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +23 -51
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +25 -25
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +25 -63
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/router-lifecycle.d.ts +17 -11
- package/dist/client/router-lifecycle.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +0 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +14 -45
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/client-dep-entries.d.ts +44 -0
- package/dist/plugins/client-dep-entries.d.ts.map +1 -0
- package/dist/plugins/content.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +6 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/server/action-client.d.ts +9 -5
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts +27 -8
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +22 -2
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/client-error-message.d.ts +12 -0
- package/dist/server/client-error-message.d.ts.map +1 -0
- package/dist/server/form-state-embed.d.ts +32 -0
- package/dist/server/form-state-embed.d.ts.map +1 -0
- package/dist/server/index.d.ts +0 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +7 -40
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +10 -6
- package/dist/server/internal.js.map +1 -1
- package/dist/server/logger.d.ts +1 -0
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +20 -6
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +27 -2
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-dispatcher.d.ts +6 -5
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +8 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/docs/api/31-api-client.mdx +9 -3
- package/docs/learn/08-forms-and-actions.mdx +21 -28
- package/package.json +1 -1
- package/src/analyze/classify.ts +1 -1
- package/src/analyze/poison-scan.ts +1 -4
- package/src/client/browser-entry/action-dispatch.ts +74 -18
- package/src/client/browser-entry/hydrate.ts +18 -0
- package/src/client/browser-entry/router-init.ts +4 -18
- package/src/client/form.tsx +33 -98
- package/src/client/index.ts +2 -2
- package/src/client/navigation-api.ts +47 -173
- package/src/client/navigation-transition.ts +2 -2
- package/src/client/router-lifecycle.ts +40 -20
- package/src/client/router-pipeline.ts +3 -9
- package/src/client/router-types.ts +14 -49
- package/src/client/router.ts +38 -58
- package/src/index.ts +21 -2
- package/src/plugins/client-dep-entries.ts +78 -0
- package/src/plugins/content.ts +26 -1
- package/src/plugins/entries.ts +7 -0
- package/src/plugins/shims.ts +0 -2
- package/src/rsc-runtime/rsc.ts +3 -0
- package/src/rsc-runtime/vendor-types.d.ts +14 -0
- package/src/server/action-client.ts +18 -20
- package/src/server/action-handler.ts +133 -95
- package/src/server/als-registry.ts +23 -9
- package/src/server/client-error-message.ts +18 -0
- package/src/server/form-state-embed.ts +63 -0
- package/src/server/index.ts +0 -4
- package/src/server/logger.ts +6 -1
- package/src/server/pipeline.ts +27 -8
- package/src/server/request-context.ts +39 -2
- package/src/server/route-element-builder.ts +12 -1
- package/src/server/rsc-entry/action-dispatcher.ts +37 -34
- package/src/server/rsc-entry/error-renderer.ts +2 -1
- package/src/server/rsc-entry/rsc-stream.ts +20 -11
- package/src/server/rsc-entry/ssr-renderer.ts +14 -1
- package/src/server/ssr-bridge-types.ts +9 -0
- package/src/server/ssr-entry.ts +1 -0
- package/src/server/ssr-render.ts +5 -0
- package/dist/_chunks/actions-Rjk4htmA.js.map +0 -1
- package/dist/_chunks/als-registry-DaxkVjt5.js.map +0 -1
- package/dist/_chunks/chains-BoO51joc.js.map +0 -1
- package/dist/_chunks/poison-scan-vGV7Re0B.js.map +0 -1
- package/dist/server/form-flash.d.ts +0 -78
- package/dist/server/form-flash.d.ts.map +0 -1
- package/src/server/form-flash.ts +0 -89
|
@@ -161,7 +161,8 @@ export interface ActionBuilderWithSchema<TCtx, TInput> {
|
|
|
161
161
|
/**
|
|
162
162
|
* The final action function. Callable three ways:
|
|
163
163
|
* - Direct: action(input) → Promise<ActionResult<TData>>
|
|
164
|
-
* - React useActionState: action(prevState,
|
|
164
|
+
* - React useActionState: action(prevState, payload) → Promise<ActionResult<TData>>,
|
|
165
|
+
* where payload is the submitted FormData or the value dispatch was called with
|
|
165
166
|
* - React <form action={fn}>: action(formData) → void (return value ignored by React)
|
|
166
167
|
*
|
|
167
168
|
* The third overload exists purely for type compatibility with React's
|
|
@@ -193,8 +194,11 @@ export type ActionFn<TData = unknown, TInput = unknown> = {
|
|
|
193
194
|
(
|
|
194
195
|
...args: undefined extends TInput ? [input?: TInput] : [input: TInput]
|
|
195
196
|
): Promise<ActionResult<TData>>;
|
|
196
|
-
/**
|
|
197
|
-
|
|
197
|
+
/**
|
|
198
|
+
* React useActionState: action(prevState, payload). The payload is the
|
|
199
|
+
* submitted FormData, or whatever the hook's dispatch was called with.
|
|
200
|
+
*/
|
|
201
|
+
(prevState: ActionResult<TData> | null, payload: TInput | FormData): Promise<ActionResult<TData>>;
|
|
198
202
|
};
|
|
199
203
|
|
|
200
204
|
// ─── Implementation ──────────────────────────────────────────────────────
|
|
@@ -281,8 +285,8 @@ function extractStandardSchemaErrors(issues: ReadonlyArray<StandardSchemaIssue>)
|
|
|
281
285
|
* Wrap unexpected errors into a safe server error result.
|
|
282
286
|
* ActionError → typed result. Other errors → INTERNAL_ERROR (no leak).
|
|
283
287
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
288
|
+
* `createActionClient` actions only. A raw `'use server'` function that
|
|
289
|
+
* throws is rejected on the client instead (action-handler.ts).
|
|
286
290
|
*/
|
|
287
291
|
export function handleActionError(error: unknown): ActionResult<never> {
|
|
288
292
|
if (error instanceof ActionError) {
|
|
@@ -339,21 +343,15 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
339
343
|
// Run middleware
|
|
340
344
|
const ctx = await runActionMiddleware(config.middleware);
|
|
341
345
|
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// no additional args — so the bound FormData arrives as args[0].
|
|
352
|
-
rawInput = schema ? parseFormData(args[0]) : args[0];
|
|
353
|
-
} else {
|
|
354
|
-
// Direct call: action(input)
|
|
355
|
-
rawInput = args[0];
|
|
356
|
-
}
|
|
346
|
+
// The input is the last argument in every call shape: `(input)`,
|
|
347
|
+
// `(formData)`, `(prevState, payload)` from useActionState's
|
|
348
|
+
// dispatch, `(initialState, formData)` on the no-JS path (Fizz binds
|
|
349
|
+
// the initial state into the form, and decodeAction binds the
|
|
350
|
+
// FormData after it), and `(...bound, prevState, payload)` for an
|
|
351
|
+
// action with bound arguments. Nothing before it is input:
|
|
352
|
+
// `prevState` comes from the client, so it is never read.
|
|
353
|
+
const last = args.at(-1);
|
|
354
|
+
const rawInput: unknown = schema && last instanceof FormData ? parseFormData(last) : last;
|
|
357
355
|
|
|
358
356
|
// Resolve the sensitive-field stripping predicate once per invocation.
|
|
359
357
|
// Precedence: per-action (config.stripSensitiveFields) > global
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 1. Detect action request (POST with `x-rsc-action` header or form action fields)
|
|
9
9
|
* 2. CSRF validation
|
|
10
10
|
* 3. Load and execute the server action
|
|
11
|
-
* 4. Return RSC stream (with-JS) or
|
|
11
|
+
* 4. Return RSC stream (with-JS), or on the no-JS path a redirect, a page
|
|
12
|
+
* rerender carrying React's form state, or the error page
|
|
12
13
|
*
|
|
13
14
|
* See design/08-forms-and-actions.md
|
|
14
15
|
*/
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
loadServerAction,
|
|
18
19
|
decodeReply,
|
|
19
20
|
decodeAction,
|
|
21
|
+
decodeFormState,
|
|
20
22
|
renderToReadableStream,
|
|
21
23
|
} from '../rsc-runtime/rsc.ts';
|
|
22
24
|
|
|
@@ -26,16 +28,19 @@ import { executeAction, type RevalidateRenderer } from './actions.ts';
|
|
|
26
28
|
import { runWithRequestContext, setMutableCookieContext } from './request-context.ts';
|
|
27
29
|
import { runOutsideReactCacheScope, runWithReactCacheScope } from './react-cache-scope.ts';
|
|
28
30
|
import { getSetCookieHeaders, getCookiesForSsr } from './cookie-context.ts';
|
|
29
|
-
import { ActionError
|
|
31
|
+
import { ActionError } from './action-client.ts';
|
|
30
32
|
import { enforceBodyLimits, enforceFieldLimit, type BodyLimitsConfig } from './body-limits.ts';
|
|
31
|
-
import { parseFormData } from './form-data.ts';
|
|
32
33
|
import {
|
|
33
34
|
stripSensitiveFields,
|
|
34
35
|
resolveSensitivePredicate,
|
|
35
36
|
getGlobalSensitiveFieldsConfig,
|
|
37
|
+
type ResolvedSensitivePredicate,
|
|
36
38
|
type SensitiveFieldsOption,
|
|
37
39
|
} from './sensitive-fields.ts';
|
|
38
|
-
import
|
|
40
|
+
import { randomUUID } from 'node:crypto';
|
|
41
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
42
|
+
import { encodeErrorDigest } from '../shared/error-digest.ts';
|
|
43
|
+
import { clientErrorMessage } from './client-error-message.ts';
|
|
39
44
|
import { checkVersionSkew, applyReloadHeaders } from './version-skew.ts';
|
|
40
45
|
import { logActionError, swallow } from './logger.ts';
|
|
41
46
|
import { fireOnRequestError } from './pipeline-helpers.ts';
|
|
@@ -95,7 +100,18 @@ export function isActionRequest(req: Request): boolean {
|
|
|
95
100
|
// ─── Handler ──────────────────────────────────────────────────────────────
|
|
96
101
|
|
|
97
102
|
/**
|
|
98
|
-
*
|
|
103
|
+
* How a no-JS action that did not redirect is answered. The dispatcher
|
|
104
|
+
* (`rsc-entry/action-dispatcher.ts`) turns it into the response:
|
|
105
|
+
*
|
|
106
|
+
* - `rerender`: the action returned. The page is rendered again as a GET,
|
|
107
|
+
* with `formState` — React's `decodeFormState` of the result, present
|
|
108
|
+
* only when the submitted form used `useActionState` — handed to SSR
|
|
109
|
+
* and hydration, where React gives it to the hook that submitted.
|
|
110
|
+
* - `error`: the action threw. The page is rendered again with the error
|
|
111
|
+
* in place of the page component, so the route's nearest error page
|
|
112
|
+
* answers, with the
|
|
113
|
+
* status the pipeline gives any unhandled error. The JS path rejects
|
|
114
|
+
* the action for the same throw, so both reach an error boundary.
|
|
99
115
|
*
|
|
100
116
|
* Carries two cookie-related snapshots taken before the action's ALS scope
|
|
101
117
|
* exits, so the rerender pipeline (which establishes its own fresh cookie
|
|
@@ -115,22 +131,36 @@ export function isActionRequest(req: Request): boolean {
|
|
|
115
131
|
* previous `cookieHeader: string` shape carried — see
|
|
116
132
|
* ONGOING_SECURITY.md H-3 (TIM-868) and TIM-837.
|
|
117
133
|
*/
|
|
118
|
-
export
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
export type FormActionOutcome =
|
|
135
|
+
| {
|
|
136
|
+
kind: 'rerender';
|
|
137
|
+
formState: ReactFormState | undefined;
|
|
138
|
+
setCookieHeaders: string[];
|
|
139
|
+
cookies: Map<string, string>;
|
|
140
|
+
}
|
|
141
|
+
| {
|
|
142
|
+
kind: 'error';
|
|
143
|
+
error: unknown;
|
|
144
|
+
errorId: string;
|
|
145
|
+
setCookieHeaders: string[];
|
|
146
|
+
cookies: Map<string, string>;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** What `handleFormAction` settles with, before the cookie snapshot. */
|
|
150
|
+
type FormActionSettled =
|
|
151
|
+
| { kind: 'rerender'; formState: ReactFormState | undefined }
|
|
152
|
+
| { kind: 'error'; error: unknown; errorId: string };
|
|
123
153
|
|
|
124
154
|
/**
|
|
125
155
|
* Handle a server action request.
|
|
126
156
|
*
|
|
127
|
-
* Returns a Response, a
|
|
157
|
+
* Returns a Response, a FormActionOutcome (no-JS action that did not redirect),
|
|
128
158
|
* or null if this isn't actually an action request (e.g., a regular form POST to an API route).
|
|
129
159
|
*/
|
|
130
160
|
export async function handleActionRequest(
|
|
131
161
|
req: Request,
|
|
132
162
|
config: ActionDispatchConfig
|
|
133
|
-
): Promise<Response |
|
|
163
|
+
): Promise<Response | FormActionOutcome | null> {
|
|
134
164
|
// Version skew detection — reject actions from stale clients (TIM-446).
|
|
135
165
|
// On mismatch, return a structured RSC error response that the client
|
|
136
166
|
// handles by showing a brief "App updated" message and reloading.
|
|
@@ -183,38 +213,35 @@ export async function handleActionRequest(
|
|
|
183
213
|
setMutableCookieContext(true);
|
|
184
214
|
const actionId = req.headers.get('x-rsc-action');
|
|
185
215
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// No-JS path: form POST with React's hidden action fields
|
|
192
|
-
result = await handleFormAction(req, config);
|
|
193
|
-
}
|
|
216
|
+
// With-JS path: client sent action ID in header, args in body.
|
|
217
|
+
// No-JS path: form POST with React's hidden action fields.
|
|
218
|
+
const result = actionId
|
|
219
|
+
? await handleRscAction(req, actionId, config)
|
|
220
|
+
: await handleFormAction(req, config);
|
|
194
221
|
|
|
195
222
|
// Apply cookie jar to action responses.
|
|
196
223
|
//
|
|
197
|
-
// For Response
|
|
198
|
-
//
|
|
199
|
-
//
|
|
224
|
+
// For a Response we append Set-Cookie directly. For a no-JS outcome we
|
|
225
|
+
// snapshot the headers here, before this ALS scope exits, so the
|
|
226
|
+
// dispatcher can apply them to the response it builds (the rerender
|
|
200
227
|
// runs in its own request-context scope with a fresh cookie jar).
|
|
201
228
|
// See LOCAL-740 — without this snapshot, cookies set inside a no-JS
|
|
202
|
-
// form action
|
|
229
|
+
// form action are silently dropped.
|
|
230
|
+
if (result === null) return null;
|
|
203
231
|
if (result instanceof Response) {
|
|
204
232
|
for (const value of getSetCookieHeaders()) {
|
|
205
233
|
result.headers.append('Set-Cookie', value);
|
|
206
234
|
}
|
|
207
|
-
|
|
208
|
-
result.setCookieHeaders = getSetCookieHeaders();
|
|
209
|
-
// Snapshot the post-action RYW cookie state as a Map so the rerender
|
|
210
|
-
// dispatcher can hand it to the rerender request context directly,
|
|
211
|
-
// with no string round-trip. See TIM-837
|
|
212
|
-
// and ONGOING_SECURITY.md H-3 (TIM-868). `getCookiesForSsr` already
|
|
213
|
-
// returns a defensive copy, so the rerender scope cannot mutate the
|
|
214
|
-
// snapshot through this reference.
|
|
215
|
-
result.cookies = getCookiesForSsr();
|
|
235
|
+
return result;
|
|
216
236
|
}
|
|
217
|
-
|
|
237
|
+
const setCookieHeaders = getSetCookieHeaders();
|
|
238
|
+
// Snapshot the post-action RYW cookie state as a Map so the rerender
|
|
239
|
+
// dispatcher can hand it to the rerender request context directly,
|
|
240
|
+
// with no string round-trip. See TIM-837
|
|
241
|
+
// and ONGOING_SECURITY.md H-3 (TIM-868). `getCookiesForSsr` already
|
|
242
|
+
// returns a defensive copy, so the rerender scope cannot mutate the
|
|
243
|
+
// snapshot through this reference.
|
|
244
|
+
return { ...result, setCookieHeaders, cookies: getCookiesForSsr() };
|
|
218
245
|
})
|
|
219
246
|
);
|
|
220
247
|
}
|
|
@@ -226,8 +253,8 @@ export async function handleActionRequest(
|
|
|
226
253
|
* `'action'`. An `ActionError` is not reported: it is an outcome the
|
|
227
254
|
* action chose to return, not an unhandled error.
|
|
228
255
|
*/
|
|
229
|
-
async function reportActionError(req: Request, error: unknown): Promise<void> {
|
|
230
|
-
logActionError({ method: req.method, path: new URL(req.url).pathname, error });
|
|
256
|
+
async function reportActionError(req: Request, error: unknown, errorId?: string): Promise<void> {
|
|
257
|
+
logActionError({ method: req.method, path: new URL(req.url).pathname, error, errorId });
|
|
231
258
|
if (error instanceof ActionError) return;
|
|
232
259
|
await fireOnRequestError(error, req, 'action');
|
|
233
260
|
}
|
|
@@ -275,17 +302,32 @@ async function rejectActionRequest(
|
|
|
275
302
|
}
|
|
276
303
|
|
|
277
304
|
/**
|
|
278
|
-
* The
|
|
279
|
-
*
|
|
280
|
-
*
|
|
305
|
+
* The with-JS answer to an action that threw: the action rejects on the
|
|
306
|
+
* client and the nearest error boundary renders, as React does for any
|
|
307
|
+
* server function that throws. The no-JS path answers the same throw with
|
|
308
|
+
* the error page, so both reach an error boundary and neither turns the
|
|
309
|
+
* throw into state the action never returned. `createActionClient`
|
|
310
|
+
* actions never get here: they catch into a `serverError` result.
|
|
311
|
+
*
|
|
312
|
+
* The error crosses as a rejected Flight root carrying the digest a render
|
|
313
|
+
* error carries (shared/error-digest.ts): its message in dev, a fixed one in
|
|
314
|
+
* production (design/13-security.md §"Errors don't leak"), and the
|
|
315
|
+
* correlation ID the server logged it with.
|
|
281
316
|
*/
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
};
|
|
317
|
+
async function rejectedActionResponse(req: Request, error: unknown): Promise<Response> {
|
|
318
|
+
const errorId = randomUUID();
|
|
319
|
+
await reportActionError(req, error, errorId);
|
|
320
|
+
const rejection = Promise.reject(error);
|
|
321
|
+
// Flight subscribes when it renders the root, after this tick; the no-op
|
|
322
|
+
// handler keeps the rejection from being reported as unhandled first.
|
|
323
|
+
rejection.catch(() => {});
|
|
324
|
+
const rscStream = renderToReadableStream(rejection, {
|
|
325
|
+
onError: () => encodeErrorDigest({ message: clientErrorMessage(error), errorId }),
|
|
326
|
+
});
|
|
327
|
+
return new Response(rscStream, {
|
|
328
|
+
status: 500,
|
|
329
|
+
headers: { 'Content-Type': RSC_CONTENT_TYPE },
|
|
330
|
+
});
|
|
289
331
|
}
|
|
290
332
|
|
|
291
333
|
/**
|
|
@@ -355,10 +397,8 @@ async function handleRscAction(
|
|
|
355
397
|
const call = await decodeRscActionCall(req, actionId, config);
|
|
356
398
|
if (call instanceof Response) return call;
|
|
357
399
|
|
|
358
|
-
// Execute the action with revalidation tracking.
|
|
359
|
-
//
|
|
360
|
-
// createActionClient) still return structured error responses instead
|
|
361
|
-
// of leaking stack traces as 500s.
|
|
400
|
+
// Execute the action with revalidation tracking. A throw is answered
|
|
401
|
+
// with a rejected action (below), never a stack trace.
|
|
362
402
|
let result;
|
|
363
403
|
try {
|
|
364
404
|
const requestUrl = new URL(req.url);
|
|
@@ -371,16 +411,7 @@ async function handleRscAction(
|
|
|
371
411
|
requestSearch: requestUrl.search,
|
|
372
412
|
});
|
|
373
413
|
} catch (error) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
// Return structured error response — ActionError gets its code/data,
|
|
377
|
-
// unexpected errors get sanitized { code: 'INTERNAL_ERROR' }
|
|
378
|
-
const errorResult = handleActionError(error);
|
|
379
|
-
const rscStream = renderToReadableStream(errorResult);
|
|
380
|
-
return new Response(rscStream, {
|
|
381
|
-
status: 200,
|
|
382
|
-
headers: { 'Content-Type': RSC_CONTENT_TYPE },
|
|
383
|
-
});
|
|
414
|
+
return rejectedActionResponse(req, error);
|
|
384
415
|
}
|
|
385
416
|
// Outside the try: the action succeeded, and a failure to report its
|
|
386
417
|
// revalidation must not turn it into an action error.
|
|
@@ -571,13 +602,14 @@ function concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {
|
|
|
571
602
|
* Handle a no-JS form action (progressive enhancement fallback).
|
|
572
603
|
*
|
|
573
604
|
* React embeds `$ACTION_REF` / `$ACTION_KEY` hidden fields in the form.
|
|
574
|
-
* We use `decodeAction` to resolve the action function from the form data
|
|
575
|
-
* execute it
|
|
605
|
+
* We use `decodeAction` to resolve the action function from the form data
|
|
606
|
+
* and execute it. A redirect is answered here; anything else settles as a
|
|
607
|
+
* `FormActionSettled` for the dispatcher to render.
|
|
576
608
|
*/
|
|
577
609
|
async function handleFormAction(
|
|
578
610
|
req: Request,
|
|
579
611
|
config: ActionDispatchConfig
|
|
580
|
-
): Promise<Response |
|
|
612
|
+
): Promise<Response | FormActionSettled | null> {
|
|
581
613
|
// Clone before consuming — if this turns out not to be a server action form,
|
|
582
614
|
// we return null and the original request body must remain readable for
|
|
583
615
|
// downstream route handlers. Clone is cheap (shares the body buffer until read).
|
|
@@ -595,16 +627,6 @@ async function handleFormAction(
|
|
|
595
627
|
return new Response(null, { status: fieldResult.status });
|
|
596
628
|
}
|
|
597
629
|
|
|
598
|
-
// Capture submitted values for re-render on validation failure.
|
|
599
|
-
// Parse before decodeAction consumes the FormData, then strip sensitive
|
|
600
|
-
// fields (passwords, tokens, CVV, etc.) so they are never rendered back
|
|
601
|
-
// into the HTML as `defaultValue` attributes. See TIM-816.
|
|
602
|
-
const sensitivePredicate = resolveSensitivePredicate(
|
|
603
|
-
config.sensitiveFields,
|
|
604
|
-
getGlobalSensitiveFieldsConfig()
|
|
605
|
-
);
|
|
606
|
-
const submittedValues = stripSensitiveFields(parseFormData(formData), sensitivePredicate);
|
|
607
|
-
|
|
608
630
|
// decodeAction resolves the action function from the form data's hidden fields.
|
|
609
631
|
// Returns null when no $ACTION_REF_/$ACTION_ID_ fields are present — meaning
|
|
610
632
|
// this is a regular form POST, not a React server action.
|
|
@@ -632,8 +654,9 @@ async function handleFormAction(
|
|
|
632
654
|
renderer: config.revalidateRenderer,
|
|
633
655
|
});
|
|
634
656
|
} catch (error) {
|
|
635
|
-
|
|
636
|
-
|
|
657
|
+
const errorId = randomUUID();
|
|
658
|
+
await reportActionError(req, error, errorId);
|
|
659
|
+
return { kind: 'error', error, errorId };
|
|
637
660
|
}
|
|
638
661
|
// Outside the try: the action succeeded, and a failure to report its
|
|
639
662
|
// revalidation must not turn it into an action error.
|
|
@@ -647,25 +670,40 @@ async function handleFormAction(
|
|
|
647
670
|
});
|
|
648
671
|
}
|
|
649
672
|
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
// even if the action already built it. `createActionClient` strips internally,
|
|
659
|
-
// but raw `'use server'` functions that manually return `{ submittedValues }`
|
|
660
|
-
// are not covered by the inner strip. See TIM-816.
|
|
661
|
-
if (actionResult && actionResult.submittedValues) {
|
|
662
|
-
actionResult.submittedValues = stripSensitiveFields(
|
|
663
|
-
actionResult.submittedValues,
|
|
664
|
-
sensitivePredicate
|
|
665
|
-
);
|
|
666
|
-
}
|
|
673
|
+
// Defense-in-depth: strip sensitive fields from `submittedValues` before
|
|
674
|
+
// the result is rendered into the page. `createActionClient` strips
|
|
675
|
+
// internally, but a raw `'use server'` function that returns
|
|
676
|
+
// `{ submittedValues }` itself is not covered by that. See TIM-816.
|
|
677
|
+
const actionResult = stripResultSubmittedValues(
|
|
678
|
+
result.actionResult,
|
|
679
|
+
resolveSensitivePredicate(config.sensitiveFields, getGlobalSensitiveFieldsConfig())
|
|
680
|
+
);
|
|
667
681
|
|
|
668
|
-
//
|
|
669
|
-
// the
|
|
670
|
-
|
|
682
|
+
// React keys the result to the hook that submitted: `$ACTION_KEY` names
|
|
683
|
+
// the useActionState instance, and Fizz hands the result to it as its
|
|
684
|
+
// initial state only when that key and the action match. A form with no
|
|
685
|
+
// useActionState has no key, so this is undefined and the page renders
|
|
686
|
+
// with no result, as the JS path discards the return value of a plain
|
|
687
|
+
// `<form action>`. Such forms should `redirect()` (post-redirect-get).
|
|
688
|
+
const formState = (await decodeFormState(actionResult, formData)) ?? undefined;
|
|
689
|
+
return { kind: 'rerender', formState };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* `result` with sensitive fields removed from its `submittedValues`, when it
|
|
694
|
+
* has any. A copy: the action's own object is not written to.
|
|
695
|
+
*/
|
|
696
|
+
function stripResultSubmittedValues(
|
|
697
|
+
result: unknown,
|
|
698
|
+
isSensitive: ResolvedSensitivePredicate
|
|
699
|
+
): unknown {
|
|
700
|
+
if (typeof result !== 'object' || result === null || !('submittedValues' in result)) {
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
const { submittedValues } = result;
|
|
704
|
+
if (typeof submittedValues !== 'object' || submittedValues === null) return result;
|
|
705
|
+
return {
|
|
706
|
+
...result,
|
|
707
|
+
submittedValues: stripSensitiveFields(submittedValues, isSensitive),
|
|
708
|
+
};
|
|
671
709
|
}
|
|
@@ -35,6 +35,7 @@ import '#server-only-guard';
|
|
|
35
35
|
import type { CoercedParams } from '../shared/param-value.ts';
|
|
36
36
|
import type { SlotParamsRecord } from '../shared/slot-params.ts';
|
|
37
37
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
38
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
38
39
|
/**
|
|
39
40
|
* Return a process-wide singleton `AsyncLocalStorage` keyed by `symbol`.
|
|
40
41
|
*
|
|
@@ -65,6 +66,12 @@ export const requestContextAls = getOrCreateAls<RequestContextStore>(
|
|
|
65
66
|
Symbol.for('timber:request-context-als')
|
|
66
67
|
);
|
|
67
68
|
|
|
69
|
+
/** A no-JS action's thrown error, and the correlation ID it was logged with. */
|
|
70
|
+
export interface ActionErrorForRender {
|
|
71
|
+
error: unknown;
|
|
72
|
+
errorId: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
68
75
|
export interface RequestContextStore {
|
|
69
76
|
/**
|
|
70
77
|
* The request this context was opened for, or `null` in the build-time
|
|
@@ -134,6 +141,22 @@ export interface RequestContextStore {
|
|
|
134
141
|
flushed: boolean;
|
|
135
142
|
/** Whether the current context allows cookie mutation. */
|
|
136
143
|
mutableContext: boolean;
|
|
144
|
+
/**
|
|
145
|
+
* The form state a no-JS action produced (`decodeFormState`), set only on
|
|
146
|
+
* the page render that answers it. Read once, by the SSR renderer, which
|
|
147
|
+
* hands it to Fizz and embeds it for `hydrateRoot`; no user-facing API
|
|
148
|
+
* reads it, because a server component cannot see an action's result on
|
|
149
|
+
* the JS path either (design/08 §"No-JS Result Round-Trip").
|
|
150
|
+
*/
|
|
151
|
+
formState?: ReactFormState;
|
|
152
|
+
/**
|
|
153
|
+
* The error a no-JS action threw, set only on the page render that answers
|
|
154
|
+
* it. The route renders it where its page would be, so the nearest error
|
|
155
|
+
* page answers, as the nearest error boundary does with JS. Read by the
|
|
156
|
+
* route element builder and the Flight `onError`; no user-facing API reads
|
|
157
|
+
* it (design/08 §"The Basic Wire-Up").
|
|
158
|
+
*/
|
|
159
|
+
actionError?: ActionErrorForRender;
|
|
137
160
|
/**
|
|
138
161
|
* Set by AccessGate or PageDenyBoundary when a DenySignal is caught
|
|
139
162
|
* server-side (inside the React tree, before React Flight sees it).
|
|
@@ -236,15 +259,6 @@ export const revalidationAls = getOrCreateAls<RevalidationState>(
|
|
|
236
259
|
Symbol.for('timber:revalidation-als')
|
|
237
260
|
);
|
|
238
261
|
|
|
239
|
-
// ─── Form Flash ───────────────────────────────────────────────────────────
|
|
240
|
-
// Used by: form-flash.ts (getFormFlash())
|
|
241
|
-
// Design doc: design/08-forms-and-actions.md §"No-JS Result Round-Trip"
|
|
242
|
-
|
|
243
|
-
/** @internal — import via form-flash.ts public API */
|
|
244
|
-
export const formFlashAls = getOrCreateAls<import('./form-flash.ts').FormFlashData>(
|
|
245
|
-
Symbol.for('timber:form-flash-als')
|
|
246
|
-
);
|
|
247
|
-
|
|
248
262
|
// ─── Early Hints Sender ──────────────────────────────────────────────────
|
|
249
263
|
// Used by: early-hints-sender.ts (sendEarlyHints103())
|
|
250
264
|
// Design doc: design/02-rendering-pipeline.md §"Early Hints (103)"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message an unexpected server error shows the client: its own message
|
|
3
|
+
* in dev, a fixed one in production (design/13-security.md §"Errors don't
|
|
4
|
+
* leak"). One definition for every place an error crosses to the client —
|
|
5
|
+
* a Flight error row's digest, a rejected action, an error page's error.
|
|
6
|
+
*
|
|
7
|
+
* Uses isDevMode(), not isDebug(): the text reaches the browser, and
|
|
8
|
+
* TIMBER_DEBUG must never make it leak.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { isDevMode } from './debug.ts';
|
|
12
|
+
|
|
13
|
+
export const PRODUCTION_ERROR_MESSAGE = 'An unexpected error occurred.';
|
|
14
|
+
|
|
15
|
+
export function clientErrorMessage(error: unknown): string {
|
|
16
|
+
if (!isDevMode()) return PRODUCTION_ERROR_MESSAGE;
|
|
17
|
+
return error instanceof Error ? error.message : String(error);
|
|
18
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The no-JS action form state a page render hands React (TIM-1570).
|
|
3
|
+
*
|
|
4
|
+
* Fizz renders the `useActionState` hook that submitted with the action's
|
|
5
|
+
* result, and the browser must hydrate with the same value, or the hook
|
|
6
|
+
* resets. The browser's copy is embedded as JSON, as Next.js embeds it
|
|
7
|
+
* (`self.__timber_form_state`, read by client/browser-entry/hydrate.ts).
|
|
8
|
+
*
|
|
9
|
+
* Both renders must get the same value, or the hook's state changes during
|
|
10
|
+
* hydration. JSON is lossy (a `Date` becomes a string, a `Map` `{}`, an
|
|
11
|
+
* `undefined` field — `{ data: undefined }` from every void action — is
|
|
12
|
+
* dropped), so Fizz gets the JSON round-trip of the state, not the original:
|
|
13
|
+
* exactly what the browser decodes. On the no-JS path a result is what JSON
|
|
14
|
+
* carries. A state JSON cannot encode at all (a `BigInt`, a cycle) must not
|
|
15
|
+
* turn the page into a 500 after the action's mutation committed, so it is
|
|
16
|
+
* dropped from both renders: the page renders with no result, as for a form
|
|
17
|
+
* without `useActionState`.
|
|
18
|
+
*
|
|
19
|
+
* With client JS disabled nothing is embedded and nothing hydrates, so Fizz
|
|
20
|
+
* gets the original.
|
|
21
|
+
*
|
|
22
|
+
* See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
26
|
+
import { htmlEscapeJsonString } from './flight-scripts.ts';
|
|
27
|
+
import { swallow } from './logger.ts';
|
|
28
|
+
import { nonceAttr } from './render-utils.ts';
|
|
29
|
+
|
|
30
|
+
export interface FormStateForRender {
|
|
31
|
+
/** For Fizz — the same value the embedded script hands `hydrateRoot`. */
|
|
32
|
+
formState: ReactFormState | undefined;
|
|
33
|
+
/** The `<script>` that embeds it for hydration, or `''`. */
|
|
34
|
+
script: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formStateForRender(
|
|
38
|
+
formState: ReactFormState | null,
|
|
39
|
+
clientJsDisabled: boolean,
|
|
40
|
+
nonce: string | undefined
|
|
41
|
+
): FormStateForRender {
|
|
42
|
+
if (!formState) return { formState: undefined, script: '' };
|
|
43
|
+
// No client: nothing hydrates, so nothing is embedded.
|
|
44
|
+
if (clientJsDisabled) return { formState, script: '' };
|
|
45
|
+
let json: string;
|
|
46
|
+
try {
|
|
47
|
+
json = JSON.stringify(formState);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
swallow(
|
|
50
|
+
error,
|
|
51
|
+
"a no-JS action's result could not be encoded as JSON for hydration, so the page " +
|
|
52
|
+
'renders without it; return plain data (no BigInt or cycles) from actions',
|
|
53
|
+
{ level: 'warn' }
|
|
54
|
+
);
|
|
55
|
+
return { formState: undefined, script: '' };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
// `JSON.parse` returns the decoded tuple untyped; it is the same
|
|
59
|
+
// opaque `ReactFormState` React built, as the browser will decode it.
|
|
60
|
+
formState: JSON.parse(json),
|
|
61
|
+
script: `<script${nonceAttr(nonce)}>self.__timber_form_state=${htmlEscapeJsonString(json)}</script>`,
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -49,10 +49,6 @@ export type {
|
|
|
49
49
|
// FormData Preprocessing
|
|
50
50
|
export { coerce, parseFormData } from './form-data.ts';
|
|
51
51
|
|
|
52
|
-
// Form Flash (no-JS error round-trip)
|
|
53
|
-
export { getFormFlash } from './form-flash.ts';
|
|
54
|
-
export type { FormFlashData } from './form-flash.ts';
|
|
55
|
-
|
|
56
52
|
// Revalidation — user-facing revalidation APIs
|
|
57
53
|
export { revalidatePath, revalidateTag } from './actions.ts';
|
|
58
54
|
|
package/src/server/logger.ts
CHANGED
|
@@ -168,7 +168,12 @@ export function logProxyError(data: { error: unknown }): void {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/** Log unhandled error in server action. Level: error. */
|
|
171
|
-
export function logActionError(data: {
|
|
171
|
+
export function logActionError(data: {
|
|
172
|
+
method: string;
|
|
173
|
+
path: string;
|
|
174
|
+
error: unknown;
|
|
175
|
+
errorId?: string;
|
|
176
|
+
}): void {
|
|
172
177
|
if (isControlFlowSignal(data.error)) return;
|
|
173
178
|
_logger.error('unhandled server action error', withTraceContext(data));
|
|
174
179
|
}
|