@timber-js/app 0.2.0-alpha.212 → 0.2.0-alpha.214
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-CCdnVtWm.js → actions-CUh3cClk.js} +43 -8
- package/dist/_chunks/{actions-CCdnVtWm.js.map → actions-CUh3cClk.js.map} +1 -1
- package/dist/_chunks/{canonicalize-CgHoscYO.js → canonicalize-BAWkWiKK.js} +28 -2
- package/dist/_chunks/{canonicalize-CgHoscYO.js.map → canonicalize-BAWkWiKK.js.map} +1 -1
- package/dist/_chunks/{chains-BfoPFraI.js → chains-CjK1Eu6a.js} +2 -2
- package/dist/_chunks/{chains-BfoPFraI.js.map → chains-CjK1Eu6a.js.map} +1 -1
- package/dist/_chunks/{classify-BT66U83D.js → classify-GAdt6aiA.js} +2 -2
- package/dist/_chunks/{classify-BT66U83D.js.map → classify-GAdt6aiA.js.map} +1 -1
- package/dist/_chunks/{cli-check-BfQ54-UJ.js → cli-check-DOzjlycg.js} +3 -3
- package/dist/_chunks/{cli-check-BfQ54-UJ.js.map → cli-check-DOzjlycg.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-czh2dsLs.js → cli-schema-sync-B9wDaQvW.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-czh2dsLs.js.map → cli-schema-sync-B9wDaQvW.js.map} +1 -1
- package/dist/_chunks/{client-dep-entries-CQwpb8dI.js → client-dep-entries-DyDqXOF9.js} +2 -2
- package/dist/_chunks/{client-dep-entries-CQwpb8dI.js.map → client-dep-entries-DyDqXOF9.js.map} +1 -1
- package/dist/_chunks/{convention-lint-BEVW4EID.js → convention-lint-B3QEGJX7.js} +2 -2
- package/dist/_chunks/{convention-lint-BEVW4EID.js.map → convention-lint-B3QEGJX7.js.map} +1 -1
- package/dist/_chunks/{dev-server-FKxptbnI.js → dev-server-_9L4KWC-.js} +3 -3
- package/dist/_chunks/{dev-server-FKxptbnI.js.map → dev-server-_9L4KWC-.js.map} +1 -1
- package/dist/_chunks/{error-boundary-DsNScGRM.js → error-boundary-xxxLtXt6.js} +38 -5
- package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-xxxLtXt6.js.map} +1 -1
- package/dist/_chunks/{live-graph-C_4v-fHv.js → live-graph-Cd3YHvrH.js} +4 -4
- package/dist/_chunks/{live-graph-C_4v-fHv.js.map → live-graph-Cd3YHvrH.js.map} +1 -1
- package/dist/_chunks/{poison-scan-C92liMAr.js → poison-scan-BnJjBOkn.js} +2 -2
- package/dist/_chunks/{poison-scan-C92liMAr.js.map → poison-scan-BnJjBOkn.js.map} +1 -1
- package/dist/_chunks/{scanner-CQt12vE2.js → scanner-CKAT5gRx.js} +2 -2
- package/dist/_chunks/{scanner-CQt12vE2.js.map → scanner-CKAT5gRx.js.map} +1 -1
- package/dist/_chunks/{walkers-DAT4avhZ.js → walkers-DwCXEyRu.js} +3 -3
- package/dist/_chunks/{walkers-DAT4avhZ.js.map → walkers-DwCXEyRu.js.map} +1 -1
- package/dist/adapters/nitro-preview.d.ts.map +1 -1
- package/dist/adapters/nitro.js +11 -2
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/analyze/crawl-entry.js +3 -3
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cli.js +3 -3
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/action-queue.d.ts +1 -0
- package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
- package/dist/client/browser-entry/form-state.d.ts +22 -0
- package/dist/client/browser-entry/form-state.d.ts.map +1 -0
- package/dist/client/browser-entry/hydrate.d.ts +9 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +2 -0
- package/dist/client/browser-entry/index.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/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +90 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +26 -10
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-transition.d.ts +11 -2
- package/dist/client/navigation-transition.d.ts.map +1 -1
- package/dist/client/router-effects.d.ts +70 -8
- 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 +12 -1
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/use-form-field.d.ts +39 -0
- package/dist/client/use-form-field.d.ts.map +1 -0
- package/dist/config-types.d.ts +2 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/action-client.d.ts +11 -2
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/canonicalize.d.ts +22 -0
- package/dist/server/canonicalize.d.ts.map +1 -1
- package/dist/server/flight-scripts.d.ts +9 -0
- package/dist/server/flight-scripts.d.ts.map +1 -1
- package/dist/server/form-data.d.ts +13 -4
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/form-state-flight.d.ts +32 -0
- package/dist/server/form-state-flight.d.ts.map +1 -0
- package/dist/server/index.js +37 -23
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +54 -3
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +31 -0
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +2 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +6 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +7 -5
- 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-form-state.d.ts +30 -0
- package/dist/server/ssr-form-state.d.ts.map +1 -0
- package/dist/shared/form-state-flight.d.ts +36 -0
- package/dist/shared/form-state-flight.d.ts.map +1 -0
- package/docs/api/31-api-client.mdx +22 -0
- package/docs/api/34-api-config.mdx +1 -1
- package/docs/learn/08-forms-and-actions.mdx +109 -22
- package/package.json +1 -1
- package/src/adapters/nitro-preview.ts +10 -1
- package/src/client/browser-entry/action-dispatch.ts +34 -10
- package/src/client/browser-entry/action-queue.ts +1 -1
- package/src/client/browser-entry/form-state.ts +48 -0
- package/src/client/browser-entry/hydrate.ts +9 -18
- package/src/client/browser-entry/index.ts +25 -7
- package/src/client/browser-entry/router-init.ts +3 -2
- package/src/client/index.ts +1 -0
- package/src/client/navigation-transition.ts +13 -2
- package/src/client/router-effects.ts +99 -10
- package/src/client/router-pipeline.ts +7 -3
- package/src/client/router-types.ts +12 -1
- package/src/client/router.ts +35 -6
- package/src/client/use-form-field.ts +132 -0
- package/src/config-types.ts +2 -1
- package/src/server/action-client.ts +68 -53
- package/src/server/canonicalize.ts +29 -0
- package/src/server/flight-scripts.ts +13 -0
- package/src/server/form-data.ts +62 -10
- package/src/server/form-state-flight.ts +67 -0
- package/src/server/pipeline-helpers.ts +61 -0
- package/src/server/pipeline.ts +13 -1
- package/src/server/rsc-entry/action-dispatcher.ts +3 -0
- package/src/server/rsc-entry/index.ts +1 -0
- package/src/server/rsc-entry/render-route.ts +16 -0
- package/src/server/rsc-entry/ssr-renderer.ts +12 -14
- package/src/server/ssr-bridge-types.ts +7 -6
- package/src/server/ssr-entry.ts +16 -3
- package/src/server/ssr-form-state.ts +58 -0
- package/src/shared/form-state-flight.ts +74 -0
- package/dist/server/form-state-embed.d.ts +0 -32
- package/dist/server/form-state-embed.d.ts.map +0 -1
- package/src/server/form-state-embed.ts +0 -63
|
@@ -118,7 +118,11 @@ export type ActionResult<TData = unknown> =
|
|
|
118
118
|
data?: never;
|
|
119
119
|
validationErrors?: never;
|
|
120
120
|
serverError: { code: string; data?: Record<string, unknown> };
|
|
121
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The submitted form, when the input was FormData — for repopulating
|
|
123
|
+
* form fields, which React resets after the action (TIM-1573).
|
|
124
|
+
*/
|
|
125
|
+
submittedValues?: Record<string, unknown>;
|
|
122
126
|
};
|
|
123
127
|
|
|
124
128
|
/** Context passed to the action body. */
|
|
@@ -288,7 +292,9 @@ function extractStandardSchemaErrors(issues: ReadonlyArray<StandardSchemaIssue>)
|
|
|
288
292
|
* `createActionClient` actions only. A raw `'use server'` function that
|
|
289
293
|
* throws is rejected on the client instead (action-handler.ts).
|
|
290
294
|
*/
|
|
291
|
-
export function handleActionError(error: unknown):
|
|
295
|
+
export function handleActionError(error: unknown): {
|
|
296
|
+
serverError: { code: string; data?: Record<string, unknown> };
|
|
297
|
+
} {
|
|
292
298
|
if (error instanceof ActionError) {
|
|
293
299
|
return {
|
|
294
300
|
serverError: {
|
|
@@ -339,43 +345,49 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
339
345
|
fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>
|
|
340
346
|
): ActionFn<TData, TInput> {
|
|
341
347
|
async function actionHandler(...args: unknown[]): Promise<ActionResult<TData>> {
|
|
348
|
+
// The input is the last argument in every call shape: `(input)`,
|
|
349
|
+
// `(formData)`, `(prevState, payload)` from useActionState's
|
|
350
|
+
// dispatch, `(initialState, formData)` on the no-JS path (Fizz binds
|
|
351
|
+
// the initial state into the form, and decodeAction binds the
|
|
352
|
+
// FormData after it), and `(...bound, prevState, payload)` for an
|
|
353
|
+
// action with bound arguments. Nothing before it is input:
|
|
354
|
+
// `prevState` comes from the client, so it is never read.
|
|
355
|
+
const last = args.at(-1);
|
|
356
|
+
const form = last instanceof FormData ? parseFormData(last) : undefined;
|
|
357
|
+
|
|
358
|
+
// Resolve the sensitive-field stripping predicate once per invocation.
|
|
359
|
+
// Precedence: per-action (config.stripSensitiveFields) > global
|
|
360
|
+
// (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
|
|
361
|
+
// See TIM-816.
|
|
362
|
+
const sensitivePredicate = resolveSensitivePredicate(
|
|
363
|
+
config.stripSensitiveFields,
|
|
364
|
+
getGlobalSensitiveFieldsConfig()
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// A "safe-to-echo" copy of the input. Files are stripped (can't
|
|
368
|
+
// serialize, shouldn't echo back) and sensitive fields (passwords,
|
|
369
|
+
// tokens, CVV, etc.) are removed before they would land in the RSC
|
|
370
|
+
// payload → client form `defaultValue` → DOM.
|
|
371
|
+
const echo = (value: unknown): Record<string, unknown> | undefined => {
|
|
372
|
+
const withoutFiles = stripFiles(value);
|
|
373
|
+
if (withoutFiles === undefined) return undefined;
|
|
374
|
+
return stripSensitiveFields(withoutFiles, sensitivePredicate);
|
|
375
|
+
};
|
|
376
|
+
|
|
342
377
|
try {
|
|
343
378
|
// Run middleware
|
|
344
379
|
const ctx = await runActionMiddleware(config.middleware);
|
|
345
380
|
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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;
|
|
355
|
-
|
|
356
|
-
// Resolve the sensitive-field stripping predicate once per invocation.
|
|
357
|
-
// Precedence: per-action (config.stripSensitiveFields) > global
|
|
358
|
-
// (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
|
|
359
|
-
// See TIM-816.
|
|
360
|
-
const sensitivePredicate = resolveSensitivePredicate(
|
|
361
|
-
config.stripSensitiveFields,
|
|
362
|
-
getGlobalSensitiveFieldsConfig()
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
// Capture a "safe-to-echo" snapshot of the raw input once. Files are
|
|
366
|
-
// stripped (can't serialize, shouldn't echo back) and sensitive fields
|
|
367
|
-
// (passwords, tokens, CVV, etc.) are removed before they would land
|
|
368
|
-
// in the RSC payload → client form `defaultValue` → DOM.
|
|
369
|
-
const buildSubmittedValues = (): Record<string, unknown> | undefined => {
|
|
370
|
-
const withoutFiles = stripFiles(rawInput);
|
|
371
|
-
if (withoutFiles === undefined) return undefined;
|
|
372
|
-
return stripSensitiveFields(withoutFiles, sensitivePredicate);
|
|
373
|
-
};
|
|
381
|
+
// Without a schema the action receives the FormData itself. Checks
|
|
382
|
+
// and echoes read the parsed form: a FormData has no own entries.
|
|
383
|
+
const rawInput: unknown = schema && form ? form : last;
|
|
384
|
+
const submitted: unknown = form ?? rawInput;
|
|
385
|
+
const buildSubmittedValues = () => echo(submitted);
|
|
374
386
|
|
|
375
387
|
// Validate file sizes before schema validation.
|
|
376
|
-
if (config.fileSizeLimit !== undefined &&
|
|
388
|
+
if (config.fileSizeLimit !== undefined && submitted && typeof submitted === 'object') {
|
|
377
389
|
const fileSizeErrors = validateFileSizes(
|
|
378
|
-
|
|
390
|
+
submitted as Record<string, unknown>,
|
|
379
391
|
config.fileSizeLimit
|
|
380
392
|
);
|
|
381
393
|
if (fileSizeErrors) {
|
|
@@ -435,7 +447,12 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
435
447
|
if (isRedirectSignal(error) || isDenySignal(error)) {
|
|
436
448
|
throw error;
|
|
437
449
|
}
|
|
438
|
-
|
|
450
|
+
// A `serverError` echoes the form like a validation failure does.
|
|
451
|
+
// React resets the form after the action, and a form whose defaults
|
|
452
|
+
// fell back to page data would lose what the user typed (TIM-1573).
|
|
453
|
+
const result = handleActionError(error);
|
|
454
|
+
const submittedValues = form && echo(form);
|
|
455
|
+
return submittedValues ? { ...result, submittedValues } : result;
|
|
439
456
|
}
|
|
440
457
|
}
|
|
441
458
|
|
|
@@ -508,6 +525,8 @@ function logValidationFailure(errors: ValidationErrors): void {
|
|
|
508
525
|
/**
|
|
509
526
|
* Validate that all File objects in the input are within the size limit.
|
|
510
527
|
* Returns validation errors keyed by field name, or null if all files are ok.
|
|
528
|
+
* Keys are dot paths (`rows.0.file`), the form a field name and a Standard
|
|
529
|
+
* Schema error use, so `getFieldError` finds a file in a list.
|
|
511
530
|
*/
|
|
512
531
|
function validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {
|
|
513
532
|
const limitKb = Math.round(limit / 1024);
|
|
@@ -526,7 +545,7 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
|
|
|
526
545
|
} else if (Array.isArray(value)) {
|
|
527
546
|
for (let i = 0; i < value.length; i++) {
|
|
528
547
|
const item = value[i];
|
|
529
|
-
const itemPath = `${path}
|
|
548
|
+
const itemPath = `${path}.${i}`;
|
|
530
549
|
if (item instanceof File && item.size > limit) {
|
|
531
550
|
(errors[itemPath] ??= []).push(
|
|
532
551
|
`File "${item.name}" (${formatSize(item.size)}) exceeds the ${limitLabel} limit`
|
|
@@ -546,29 +565,25 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
|
|
|
546
565
|
}
|
|
547
566
|
|
|
548
567
|
/**
|
|
549
|
-
* Strip File objects from a value, returning a
|
|
550
|
-
*
|
|
568
|
+
* Strip File objects from a value, returning a copy safe for serialization.
|
|
569
|
+
* File objects can't be serialized and shouldn't be echoed back.
|
|
570
|
+
*
|
|
571
|
+
* Mirrors `parseFormData`'s output: objects and arrays at any depth, arrays
|
|
572
|
+
* of arrays included. A File in an array becomes `undefined` rather than
|
|
573
|
+
* being removed, so `rows.2` still names the third element (TIM-1573).
|
|
551
574
|
*/
|
|
552
575
|
function stripFiles(value: unknown): Record<string, unknown> | undefined {
|
|
553
|
-
if (value === null || value
|
|
554
|
-
|
|
576
|
+
if (value === null || typeof value !== 'object' || value instanceof File) return undefined;
|
|
577
|
+
return stripFilesFrom(value) as Record<string, unknown>;
|
|
578
|
+
}
|
|
555
579
|
|
|
580
|
+
function stripFilesFrom(value: unknown): unknown {
|
|
581
|
+
if (value instanceof File) return undefined;
|
|
582
|
+
if (Array.isArray(value)) return value.map(stripFilesFrom);
|
|
583
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
556
584
|
const result: Record<string, unknown> = {};
|
|
557
|
-
for (const [k, v] of Object.entries(value
|
|
558
|
-
if (v instanceof File)
|
|
559
|
-
if (Array.isArray(v)) {
|
|
560
|
-
result[k] = v
|
|
561
|
-
.filter((item) => !(item instanceof File))
|
|
562
|
-
.map((item) =>
|
|
563
|
-
typeof item === 'object' && item !== null && !(item instanceof File)
|
|
564
|
-
? (stripFiles(item) ?? {})
|
|
565
|
-
: item
|
|
566
|
-
);
|
|
567
|
-
} else if (typeof v === 'object' && v !== null && !(v instanceof File)) {
|
|
568
|
-
result[k] = stripFiles(v) ?? {};
|
|
569
|
-
} else {
|
|
570
|
-
result[k] = v;
|
|
571
|
-
}
|
|
585
|
+
for (const [k, v] of Object.entries(value)) {
|
|
586
|
+
if (!(v instanceof File)) result[k] = stripFilesFrom(v);
|
|
572
587
|
}
|
|
573
588
|
return result;
|
|
574
589
|
}
|
|
@@ -91,3 +91,32 @@ export function canonicalize(rawPathname: string, stripTrailingSlash = true): Ca
|
|
|
91
91
|
|
|
92
92
|
return { ok: true, pathname };
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The path a safe request for `rawPathname` should be redirected to — or
|
|
97
|
+
* `rawPathname` itself when it is already canonical.
|
|
98
|
+
*
|
|
99
|
+
* Applies the structural steps of `canonicalize()` — collapse `//` (step 3)
|
|
100
|
+
* and strip the trailing slash (step 5) — to the **encoded** path, without
|
|
101
|
+
* decoding. That is what the browser will send back, so the redirect is
|
|
102
|
+
* idempotent: `/%61dmin` differs from its canonical form `/admin` only by
|
|
103
|
+
* percent-encoding, and redirecting it to `/admin` would compare against the
|
|
104
|
+
* decoded form forever. Decoding cannot introduce a `/` (`%2f` is rejected),
|
|
105
|
+
* so the structure is the same before and after decoding.
|
|
106
|
+
*
|
|
107
|
+
* Dot segments are not handled here: the request URL is parsed by the WHATWG
|
|
108
|
+
* URL parser, which has already resolved `.`, `..` and their encoded forms.
|
|
109
|
+
*
|
|
110
|
+
* Only meaningful for a path `canonicalize()` accepted. The collapse also
|
|
111
|
+
* means the result can never start with `//`, so it is never a
|
|
112
|
+
* protocol-relative redirect target.
|
|
113
|
+
*
|
|
114
|
+
* See design/07-routing.md §"Non-Canonical URLs Redirect"
|
|
115
|
+
*/
|
|
116
|
+
export function canonicalRequestPath(rawPathname: string, stripTrailingSlash = true): string {
|
|
117
|
+
let pathname = rawPathname.replace(/\/\/+/g, '/');
|
|
118
|
+
if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {
|
|
119
|
+
pathname = pathname.slice(0, -1);
|
|
120
|
+
}
|
|
121
|
+
return pathname;
|
|
122
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { nonceAttr } from './render-utils.ts';
|
|
19
|
+
import { formStateToBase64 } from '../shared/form-state-flight.ts';
|
|
19
20
|
|
|
20
21
|
// ─── JSON Escaping ────────────────────────────────────────────────────────
|
|
21
22
|
|
|
@@ -65,3 +66,15 @@ export function flightChunkScript(data: string, nonce?: string): string {
|
|
|
65
66
|
const escaped = htmlEscapeJsonString(JSON.stringify([1, data]));
|
|
66
67
|
return `<script${nonceAttr(nonce)}>(${FLIGHT_VAR}=${FLIGHT_VAR}||[]).push(${escaped})</script>`;
|
|
67
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generate the script that embeds a no-JS action's form state for
|
|
72
|
+
* hydration: its Flight bytes (server/form-state-flight.ts), as base64. Only
|
|
73
|
+
* a page answering a no-JS action has one. Base64 carries Flight's binary
|
|
74
|
+
* rows, which a text chunk would corrupt, and has no character that can end
|
|
75
|
+
* the script element or the string. See design/08-forms-and-actions.md
|
|
76
|
+
* §"No-JS Result Round-Trip".
|
|
77
|
+
*/
|
|
78
|
+
export function formStateScript(bytes: Uint8Array, nonce?: string): string {
|
|
79
|
+
return `<script${nonceAttr(nonce)}>self.__timber_form_state="${formStateToBase64(bytes)}"</script>`;
|
|
80
|
+
}
|
package/src/server/form-data.ts
CHANGED
|
@@ -18,9 +18,15 @@
|
|
|
18
18
|
* Handles:
|
|
19
19
|
* - **Duplicate keys → arrays**: `tags=js&tags=ts` → `{ tags: ["js", "ts"] }`
|
|
20
20
|
* - **Nested dot-paths**: `user.name=Alice` → `{ user: { name: "Alice" } }`
|
|
21
|
-
* - **
|
|
21
|
+
* - **Indexed lists → arrays**: `rows.0.name=A&rows.1.name=B` →
|
|
22
|
+
* `{ rows: [{ name: "A" }, { name: "B" }] }`, when the indexes are exactly
|
|
23
|
+
* `0..n-1`. A list with a gap stays an object keyed by index.
|
|
24
|
+
* - **Empty strings stay `""`**: a blank text input is `""`, so
|
|
25
|
+
* `z.string().min(1, 'Required')` reports its own message. Use
|
|
26
|
+
* `coerce.text` to make a blank optional field `undefined`
|
|
22
27
|
* - **Empty Files → undefined**: File inputs with no selection become `undefined`
|
|
23
28
|
* - **Strips `$ACTION_*` fields**: React's internal hidden fields are excluded
|
|
29
|
+
* - **Drops `__proto__` paths and names deeper than 32 segments**
|
|
24
30
|
*/
|
|
25
31
|
export function parseFormData(formData: FormData): Record<string, unknown> {
|
|
26
32
|
const flat: Record<string, unknown> = {};
|
|
@@ -97,6 +103,11 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
|
|
|
97
103
|
// `result['__proto__']` resolves to the prototype object itself.
|
|
98
104
|
const parts = key.split('.');
|
|
99
105
|
if (parts.some((p) => DANGEROUS_KEYS.has(p))) continue;
|
|
106
|
+
// Bound the nesting depth. Every walk over the parsed value recurses
|
|
107
|
+
// once per level (list conversion here, then file and sensitive-field
|
|
108
|
+
// stripping and schema validation), and a 10 KB key of 5,000 segments
|
|
109
|
+
// fits well inside the body limits. No form nests this deep.
|
|
110
|
+
if (parts.length > MAX_PATH_DEPTH) continue;
|
|
100
111
|
|
|
101
112
|
if (parts.length === 1) {
|
|
102
113
|
result[parts[0]] = value;
|
|
@@ -106,12 +117,13 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
|
|
|
106
117
|
let current: Record<string, unknown> = result;
|
|
107
118
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
108
119
|
const part = parts[i];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
120
|
+
// Step only into an object this walk built. Anything else under the
|
|
121
|
+
// name — a string, a File, or an array from a duplicate key
|
|
122
|
+
// (`rows=x&rows=y`) — is replaced: the dot-path takes precedence.
|
|
123
|
+
// Stepping into an array would let `rows.4294967294` set its length
|
|
124
|
+
// to 2^32 - 1, and every later walk (file and sensitive-field
|
|
125
|
+
// stripping) maps over that length (TIM-1573).
|
|
126
|
+
if (!isPlainObject(current[part])) {
|
|
115
127
|
current[part] = {};
|
|
116
128
|
}
|
|
117
129
|
current = current[part] as Record<string, unknown>;
|
|
@@ -120,9 +132,39 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
|
|
|
120
132
|
current[parts[parts.length - 1]] = value;
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
// The top level is always an object: it is the form, not a list.
|
|
136
|
+
for (const [key, value] of Object.entries(result)) {
|
|
137
|
+
if (isPlainObject(value)) result[key] = indexedListsToArrays(value);
|
|
138
|
+
}
|
|
123
139
|
return result;
|
|
124
140
|
}
|
|
125
141
|
|
|
142
|
+
/**
|
|
143
|
+
* `node`, with every nested object whose keys are exactly `"0".."n-1"`
|
|
144
|
+
* turned into an array, depth first. Only a dense, canonical index set
|
|
145
|
+
* converts: `{ "0", "2" }` (a gap), `{ "01" }` and `{ "0", "name" }` stay
|
|
146
|
+
* objects. So a forged `rows.99999` stays one key instead of allocating a
|
|
147
|
+
* sparse array, and the array's length is bounded by the field count limit.
|
|
148
|
+
*
|
|
149
|
+
* Object.keys lists integer-like keys first, in ascending order, so a dense
|
|
150
|
+
* set reads as `0, 1, … n-1` and `Object.values` is already in index order.
|
|
151
|
+
*/
|
|
152
|
+
function indexedListsToArrays(node: Record<string, unknown>): Record<string, unknown> | unknown[] {
|
|
153
|
+
for (const [key, value] of Object.entries(node)) {
|
|
154
|
+
if (isPlainObject(value)) node[key] = indexedListsToArrays(value);
|
|
155
|
+
}
|
|
156
|
+
const keys = Object.keys(node);
|
|
157
|
+
const dense = keys.length > 0 && keys.every((key, i) => key === String(i));
|
|
158
|
+
return dense ? Object.values(node) : node;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** An object the dot-path walk built — not an array, File or other instance. */
|
|
162
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
163
|
+
return (
|
|
164
|
+
typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
126
168
|
// `__proto__` is the only key in this parser that escapes the dot-path
|
|
127
169
|
// walk's `typeof !== 'object'` reset: accessing `obj['__proto__']` returns
|
|
128
170
|
// `Object.prototype` (itself an object), so the walk steps into the global
|
|
@@ -132,13 +174,22 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
|
|
|
132
174
|
// keys and do not mutate anything global.
|
|
133
175
|
const DANGEROUS_KEYS = new Set(['__proto__']);
|
|
134
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The most segments a dot-path field name may have. A deeper name is dropped,
|
|
179
|
+
* like a `__proto__` path, so no walk over the parsed value can exhaust the
|
|
180
|
+
* stack (TIM-1573).
|
|
181
|
+
*/
|
|
182
|
+
const MAX_PATH_DEPTH = 32;
|
|
183
|
+
|
|
135
184
|
// ─── Coercion Helpers ────────────────────────────────────────────────────
|
|
136
185
|
|
|
137
186
|
/**
|
|
138
187
|
* Schema-agnostic coercion primitives for common FormData patterns.
|
|
139
188
|
*
|
|
140
189
|
* These are plain transform functions — they compose with any schema library's
|
|
141
|
-
* `transform`/`preprocess` pipeline
|
|
190
|
+
* `transform`/`preprocess` pipeline. In Zod, use `z.preprocess`: it runs on an
|
|
191
|
+
* absent key (an unchecked checkbox), where `z.unknown().transform()` fails
|
|
192
|
+
* with "expected nonoptional" before the transform runs.
|
|
142
193
|
*
|
|
143
194
|
* ```ts
|
|
144
195
|
* // Zod
|
|
@@ -153,8 +204,9 @@ export const coerce = {
|
|
|
153
204
|
* Use with `.optional()` schemas where an empty input means "not provided".
|
|
154
205
|
*
|
|
155
206
|
* ```ts
|
|
156
|
-
* // Zod
|
|
157
|
-
*
|
|
207
|
+
* // Zod — preprocess, not `z.unknown().transform(…)`: Zod 4 rejects an
|
|
208
|
+
* // absent key before a transform runs ("expected nonoptional")
|
|
209
|
+
* z.preprocess(coerce.text, z.string().optional())
|
|
158
210
|
* // Valibot
|
|
159
211
|
* v.pipe(v.unknown(), v.transform(coerce.text), v.optional(v.string()))
|
|
160
212
|
* ```
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The no-JS action form state a page render hands React (TIM-1570), encoded
|
|
3
|
+
* with Flight (TIM-1572).
|
|
4
|
+
*
|
|
5
|
+
* Fizz renders the `useActionState` hook that submitted with the action's
|
|
6
|
+
* result, and the browser must hydrate with the same value, or the hook
|
|
7
|
+
* resets. Both get it from the Flight bytes encoded here: SSR decodes them
|
|
8
|
+
* for Fizz (`NavContext.formState`) and, once they decode, embeds them for
|
|
9
|
+
* `hydrateRoot` (server/ssr-form-state.ts; decoded by
|
|
10
|
+
* client/browser-entry/form-state.ts). Flight is what carries a
|
|
11
|
+
* `useActionState` result with JS, so a result has the same types with and
|
|
12
|
+
* without JS. See shared/form-state-flight.ts for the decode.
|
|
13
|
+
*
|
|
14
|
+
* A state Flight cannot encode must not turn the page into a 500 after the
|
|
15
|
+
* action's mutation committed, so it is dropped from both renders: the page
|
|
16
|
+
* renders with no result, as for a form without `useActionState`. That is a
|
|
17
|
+
* value Flight cannot serialize (a plain function, a class instance), which
|
|
18
|
+
* fails the action with JS too, or a nested promise that rejects or does not
|
|
19
|
+
* settle in time, which with JS reaches the hook as that rejected or pending
|
|
20
|
+
* promise. The no-JS page cannot carry a pending promise, and does not embed
|
|
21
|
+
* an error row.
|
|
22
|
+
*
|
|
23
|
+
* See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
27
|
+
import { renderToReadableStream } from '../rsc-runtime/rsc.ts';
|
|
28
|
+
import { swallow } from './logger.ts';
|
|
29
|
+
import { readAllBytesWithDeadline } from './stream-utils.ts';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Serialize the form state with Flight, or return `null` when it cannot be,
|
|
33
|
+
* with a warning. The whole payload is read under one `timeoutMs` deadline,
|
|
34
|
+
* and the render stops when `signal` (the request's) aborts.
|
|
35
|
+
*/
|
|
36
|
+
export async function encodeFormState(
|
|
37
|
+
formState: ReactFormState,
|
|
38
|
+
timeoutMs: number,
|
|
39
|
+
signal?: AbortSignal
|
|
40
|
+
): Promise<Uint8Array | null> {
|
|
41
|
+
let failure: unknown;
|
|
42
|
+
try {
|
|
43
|
+
// Flight reports a value it cannot serialize (anywhere in the tree,
|
|
44
|
+
// including a rejected nested promise) here, and writes an error row in
|
|
45
|
+
// its place; a result with an error row in it is not the action's result.
|
|
46
|
+
const flightErrors: unknown[] = [];
|
|
47
|
+
const stream = renderToReadableStream(formState, {
|
|
48
|
+
signal,
|
|
49
|
+
onError(error: unknown) {
|
|
50
|
+
flightErrors.push(error);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const bytes = await readAllBytesWithDeadline(stream, timeoutMs, 'no-JS form state encode');
|
|
54
|
+
if (flightErrors.length === 0) return bytes;
|
|
55
|
+
failure = flightErrors[0];
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// The deadline, reported as itself: cancelling the read after it makes
|
|
58
|
+
// Flight report an abort to onError too, which says nothing of the cause.
|
|
59
|
+
failure = error;
|
|
60
|
+
}
|
|
61
|
+
swallow(
|
|
62
|
+
failure,
|
|
63
|
+
"a no-JS action's result could not be serialized with Flight, so the page renders without it",
|
|
64
|
+
{ level: 'warn' }
|
|
65
|
+
);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
@@ -28,6 +28,9 @@ import { isApiRouteChain, isCsrfExemptRoute, type ManifestSegmentNode } from './
|
|
|
28
28
|
import type { ProxyConfig, RouteMatcher } from './pipeline.ts';
|
|
29
29
|
import { csrfRejectionResponse, validateCsrf, type CsrfConfig } from './csrf.ts';
|
|
30
30
|
import type { PhaseName } from './pipeline-outcome.ts';
|
|
31
|
+
import { canonicalRequestPath } from './canonicalize.ts';
|
|
32
|
+
import { isRscRequest } from '../shared/rsc-media-type.ts';
|
|
33
|
+
import { appVisibleSearch } from '../shared/rsc-cache-key.ts';
|
|
31
34
|
|
|
32
35
|
// ─── Prototype-Pollution-Safe Sanitizer ────────────────────────────────────
|
|
33
36
|
|
|
@@ -66,6 +69,64 @@ export function csrfGate(
|
|
|
66
69
|
return exempt ? null : csrfRejectionResponse(req, result);
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
// ─── Canonical Redirect ────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The redirect for a safe request whose URL is not canonical, or null to
|
|
76
|
+
* serve it.
|
|
77
|
+
*
|
|
78
|
+
* A GET for `/admin/` used to render with a 200 at that URL. The server
|
|
79
|
+
* rendered with the canonical `/admin` while the browser hydrated with the
|
|
80
|
+
* raw `/admin/`, so anything branching on `usePathname()` hydrated
|
|
81
|
+
* mismatched (TIM-1571). Redirecting makes the address bar — the client's
|
|
82
|
+
* copy of the URL — the canonical one, and leaves one URL per page for
|
|
83
|
+
* caches and crawlers.
|
|
84
|
+
*
|
|
85
|
+
* Only GET and HEAD are redirected. Every other method — an action's POST,
|
|
86
|
+
* a CORS preflight — is served at the canonical path, as before: a
|
|
87
|
+
* redirected POST is re-sent by the browser, which an action must not
|
|
88
|
+
* depend on.
|
|
89
|
+
*
|
|
90
|
+
* Runs inside proxy.ts's `next()`, not before it, so proxy.ts still runs
|
|
91
|
+
* on every request and its headers land on the redirect. A cross-origin
|
|
92
|
+
* `GET /api/items/` needs proxy.ts's CORS headers on the 308 itself:
|
|
93
|
+
* browsers apply the CORS check to a redirect response.
|
|
94
|
+
*
|
|
95
|
+
* A document request gets a relative 308 (never built from `Host`). A page
|
|
96
|
+
* navigation gets the router's soft redirect, decided exactly as for a
|
|
97
|
+
* `redirect()` outcome: an RSC request that does not match a route.ts
|
|
98
|
+
* handler (`buildRedirectResponse`). The framework's `_rsc` cache key is
|
|
99
|
+
* dropped from the soft target; the router appends a fresh one when it
|
|
100
|
+
* fetches the canonical URL.
|
|
101
|
+
*
|
|
102
|
+
* See design/07-routing.md §"Non-Canonical URLs Redirect".
|
|
103
|
+
*/
|
|
104
|
+
export function canonicalRedirect(
|
|
105
|
+
req: Request,
|
|
106
|
+
canonicalPath: string,
|
|
107
|
+
stripTrailingSlash: boolean,
|
|
108
|
+
matchRoute: RouteMatcher
|
|
109
|
+
): Response | null {
|
|
110
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return null;
|
|
111
|
+
const url = new URL(req.url);
|
|
112
|
+
const target = canonicalRequestPath(url.pathname, stripTrailingSlash);
|
|
113
|
+
if (target === url.pathname) return null;
|
|
114
|
+
const navigation = isRscRequest(req) && !isApiRoutePath(canonicalPath, matchRoute);
|
|
115
|
+
const search = navigation ? appVisibleSearch(url).search : url.search;
|
|
116
|
+
return buildRedirectResponse(new RedirectSignal(target + search, 308), new Headers(), navigation);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether `path` matches a route.ts handler. A matcher that throws: no. */
|
|
120
|
+
function isApiRoutePath(path: string, matchRoute: RouteMatcher): boolean {
|
|
121
|
+
try {
|
|
122
|
+
const match = matchRoute(path);
|
|
123
|
+
return match !== null && isApiRouteChain(match.segments);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
swallow(err, 'canonical redirect: route match for route.ts lookup threw');
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
69
130
|
// ─── Proxy Resolver ────────────────────────────────────────────────────────
|
|
70
131
|
|
|
71
132
|
/**
|
package/src/server/pipeline.ts
CHANGED
|
@@ -32,7 +32,7 @@ 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 { csrfGate, makeProxyResolver } from './pipeline-helpers.ts';
|
|
35
|
+
import { canonicalRedirect, csrfGate, makeProxyResolver } from './pipeline-helpers.ts';
|
|
36
36
|
import type { CsrfConfig } from './csrf.ts';
|
|
37
37
|
import { handleRequest, runProxyPhase } from './pipeline-phases.ts';
|
|
38
38
|
import { outcomeToResponse } from './pipeline-outcome.ts';
|
|
@@ -402,6 +402,18 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
402
402
|
// TIM-1213: action dispatch is now INSIDE the pipeline so
|
|
403
403
|
// proxy.ts runs on every request, including action POSTs.
|
|
404
404
|
const innerHandler = async (): Promise<Response> => {
|
|
405
|
+
// A safe request for a non-canonical URL (`/x/`, `/a//b`) is
|
|
406
|
+
// redirected to the canonical one instead of served, so the
|
|
407
|
+
// client hydrates with the pathname the server renders with
|
|
408
|
+
// (TIM-1571). Inside proxy.ts's next(), so proxy.ts still runs
|
|
409
|
+
// on every request and its headers (CORS) reach the redirect.
|
|
410
|
+
const redirect = canonicalRedirect(
|
|
411
|
+
req,
|
|
412
|
+
canonicalPath,
|
|
413
|
+
stripTrailingSlash,
|
|
414
|
+
config.matchRoute
|
|
415
|
+
);
|
|
416
|
+
if (redirect) return redirect;
|
|
405
417
|
if (config.dispatchAction) {
|
|
406
418
|
const actionResult = await config.dispatchAction(
|
|
407
419
|
canonicalReq,
|
|
@@ -123,11 +123,14 @@ export function buildActionDispatcher(
|
|
|
123
123
|
// render's request-context cookies, so the page reads what the
|
|
124
124
|
// action wrote, not what the request carried (TIM-868).
|
|
125
125
|
// - Method GET because it is a page render.
|
|
126
|
+
// - The POST's abort signal, so a client that disconnects stops the
|
|
127
|
+
// render (and the form state encode) as it would any page's.
|
|
126
128
|
const rerenderHeaders = new Headers(req.headers);
|
|
127
129
|
rerenderHeaders.delete('cookie');
|
|
128
130
|
const rerenderReq = new Request(req.url, {
|
|
129
131
|
method: 'GET',
|
|
130
132
|
headers: rerenderHeaders,
|
|
133
|
+
signal: req.signal,
|
|
131
134
|
});
|
|
132
135
|
const { cookies } = actionResponse;
|
|
133
136
|
const response = await reenter(
|
|
@@ -322,6 +322,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
322
322
|
clientSegmentCache,
|
|
323
323
|
renderDenyFallback,
|
|
324
324
|
buildManifest: typedBuildManifest,
|
|
325
|
+
renderTimeoutMs: (config as Record<string, unknown>).renderTimeoutMs as number,
|
|
325
326
|
globalError: manifest.globalError,
|
|
326
327
|
},
|
|
327
328
|
interception
|
|
@@ -38,6 +38,8 @@ import type { DenyFallbackRenderer } from './deny-fallback.ts';
|
|
|
38
38
|
import { isRscRequest } from '../../shared/rsc-media-type.ts';
|
|
39
39
|
import { buildRscPayloadResponse } from './rsc-payload.ts';
|
|
40
40
|
import { renderRscStream } from './rsc-stream.ts';
|
|
41
|
+
import { encodeFormState } from '../form-state-flight.ts';
|
|
42
|
+
import { getFormStateForSsr } from '../request-context.ts';
|
|
41
43
|
import { renderSsrResponse } from './ssr-renderer.ts';
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -51,6 +53,8 @@ export interface RenderRouteDeps {
|
|
|
51
53
|
clientSegmentCache: boolean;
|
|
52
54
|
renderDenyFallback: DenyFallbackRenderer;
|
|
53
55
|
buildManifest: BuildManifest;
|
|
56
|
+
/** The resolved `renderTimeoutMs`: bounds encoding a no-JS form state. */
|
|
57
|
+
renderTimeoutMs: number;
|
|
54
58
|
globalError?: { load: () => Promise<unknown>; filePath: string };
|
|
55
59
|
}
|
|
56
60
|
|
|
@@ -210,6 +214,17 @@ export async function renderRoute(
|
|
|
210
214
|
);
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
// The form state of the no-JS action this page answers, if any, as the
|
|
218
|
+
// Flight bytes both SSR and the browser decode (design/08 §"No-JS Result
|
|
219
|
+
// Round-Trip"). Encoded while the RSC render above is already running,
|
|
220
|
+
// under the render timeout and the request's abort signal. A promise in
|
|
221
|
+
// the result holds the response until it settles: the page must carry the
|
|
222
|
+
// whole state, as Fizz would wait for it anyway.
|
|
223
|
+
const pendingFormState = getFormStateForSsr();
|
|
224
|
+
const formState = pendingFormState
|
|
225
|
+
? await encodeFormState(pendingFormState, deps.renderTimeoutMs, req.signal)
|
|
226
|
+
: null;
|
|
227
|
+
|
|
213
228
|
// Pipe through SSR for HTML rendering with streaming Suspense support.
|
|
214
229
|
return renderSsrResponse({
|
|
215
230
|
req,
|
|
@@ -225,5 +240,6 @@ export async function renderRoute(
|
|
|
225
240
|
deferSuspenseFor,
|
|
226
241
|
globalError,
|
|
227
242
|
slotSkipInfo,
|
|
243
|
+
formState,
|
|
228
244
|
});
|
|
229
245
|
}
|