@timber-js/app 0.2.0-alpha.206 → 0.2.0-alpha.208
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/{cli-check-BqOylm-3.js → cli-check-G8o30ldg.js} +2 -2
- package/dist/_chunks/{cli-check-BqOylm-3.js.map → cli-check-G8o30ldg.js.map} +1 -1
- package/dist/_chunks/{convention-lint-BnO5TyHD.js → convention-lint-5KzoMg_A.js} +35 -1
- package/dist/_chunks/convention-lint-5KzoMg_A.js.map +1 -0
- package/dist/_chunks/{error-boundary-BndF-3Td.js → error-boundary-BrHmG-2X.js} +2 -2
- package/dist/_chunks/{error-boundary-BndF-3Td.js.map → error-boundary-BrHmG-2X.js.map} +1 -1
- package/dist/_chunks/schema-bridge-C83xa9lT.js.map +1 -1
- package/dist/_chunks/{status-page-marker-DwQBrLBz.js → status-page-marker-CNbpUOPB.js} +88 -9
- package/dist/_chunks/status-page-marker-CNbpUOPB.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/history.d.ts +8 -0
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/internal.js +30 -7
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-commit.d.ts +4 -0
- package/dist/client/navigation-commit.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +2 -2
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +44 -4
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +103 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/react-resolve.d.ts +49 -0
- package/dist/plugins/react-resolve.d.ts.map +1 -0
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +2 -2
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/server/action-client.d.ts +2 -2
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +1 -1
- package/package.json +3 -3
- package/src/client/browser-entry/action-dispatch.ts +8 -2
- package/src/client/history.ts +17 -0
- package/src/client/navigation-commit.ts +6 -0
- package/src/client/router-pipeline.ts +31 -4
- package/src/client/router.ts +22 -2
- package/src/client/segment-cache.ts +110 -11
- package/src/index.ts +2 -0
- package/src/plugins/react-resolve.ts +149 -0
- package/src/plugins/routing.ts +1 -1
- package/src/routing/convention-lint.ts +60 -0
- package/src/schema-bridge.ts +4 -2
- package/src/server/action-client.ts +4 -2
- package/dist/_chunks/convention-lint-BnO5TyHD.js.map +0 -1
- package/dist/_chunks/status-page-marker-DwQBrLBz.js.map +0 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-react-resolve — Deduplicate React across timber and user project.
|
|
3
|
+
*
|
|
4
|
+
* Without this plugin, pnpm's strict isolation can install two physical
|
|
5
|
+
* copies of React when the user's pinned version differs from timber's
|
|
6
|
+
* peer dep. Timber's 47 bare `from 'react'` imports resolve to one copy
|
|
7
|
+
* while react-dom uses the other — causing "Invalid hook call" hydration
|
|
8
|
+
* crashes with no indication of the real cause.
|
|
9
|
+
*
|
|
10
|
+
* The fix: inject `resolve.dedupe` entries so Vite always resolves React
|
|
11
|
+
* packages from the project root. Unlike `resolve.alias`, `dedupe`
|
|
12
|
+
* preserves Vite's per-environment export conditions — the RSC
|
|
13
|
+
* environment still gets the `react-server` build, SSR gets the SSR
|
|
14
|
+
* build, and the browser gets the browser build. The deduplication
|
|
15
|
+
* happens at the package level, not the file level.
|
|
16
|
+
*
|
|
17
|
+
* A `configResolved` check detects version mismatches between timber's
|
|
18
|
+
* resolved React and the project's React, and logs an actionable error.
|
|
19
|
+
*
|
|
20
|
+
* Design doc: 18-build-system.md §"Module Resolution"
|
|
21
|
+
* Task: TIM-1520
|
|
22
|
+
*/
|
|
23
|
+
import type { Plugin } from 'vite';
|
|
24
|
+
/**
|
|
25
|
+
* React packages to deduplicate. Vite's `resolve.dedupe` extracts the
|
|
26
|
+
* base package name (via `deepImportRE`) before checking, so subpath
|
|
27
|
+
* specifiers like `react/jsx-runtime` are covered by the base `react`
|
|
28
|
+
* entry — listing them separately has no effect.
|
|
29
|
+
*/
|
|
30
|
+
export declare const REACT_DEDUPE_PACKAGES: readonly ['react', 'react-dom'];
|
|
31
|
+
export interface ReactVersionMismatch {
|
|
32
|
+
specifier: string;
|
|
33
|
+
timberVersion: string;
|
|
34
|
+
projectVersion: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Check whether timber's React and the user project's React resolve to
|
|
38
|
+
* the same version. Returns mismatches if any.
|
|
39
|
+
*/
|
|
40
|
+
export declare function detectReactMismatch(projectRoot: string): ReactVersionMismatch[];
|
|
41
|
+
/**
|
|
42
|
+
* Format React version mismatches as an actionable warning.
|
|
43
|
+
*/
|
|
44
|
+
export declare function formatReactMismatch(mismatches: ReactVersionMismatch[]): string;
|
|
45
|
+
/**
|
|
46
|
+
* Create the timber-react-resolve Vite plugin.
|
|
47
|
+
*/
|
|
48
|
+
export declare function timberReactResolve(): Plugin;
|
|
49
|
+
//# sourceMappingURL=react-resolve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react-resolve.d.ts","sourceRoot":"","sources":["../../src/plugins/react-resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAKH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,YAAI,OAAO,EAAE,WAAW,CAAU,CAAC;AAsBrE,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,oBAAoB,EAAE,CAc/E;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,oBAAoB,EAAE,GAAG,MAAM,CA4B9E;AAED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CA8B3C"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"convention-lint.d.ts","sourceRoot":"","sources":["../../src/routing/convention-lint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,KAAK,EAAa,SAAS,EAAe,MAAM,YAAY,CAAC;AAYpE,MAAM,WAAW,iBAAiB;IAChC,kDAAkD;IAClD,EAAE,EAAE,MAAM,CAAC;IACX,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,qFAAqF;IACrF,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAID;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,iBAAiB,EAAE,
|
|
1
|
+
{"version":3,"file":"convention-lint.d.ts","sourceRoot":"","sources":["../../src/routing/convention-lint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,KAAK,EAAa,SAAS,EAAe,MAAM,YAAY,CAAC;AAYpE,MAAM,WAAW,iBAAiB;IAChC,kDAAkD;IAClD,EAAE,EAAE,MAAM,CAAC;IACX,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,qFAAqF;IACrF,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAID;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAmCpF;AA0ZD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAe1E;AAUD,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,iBAAiB,EAAE,EAC7B,OAAO,GAAE,aAAkB,GAC1B,MAAM,CAkCR"}
|
package/dist/schema-bridge.d.ts
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
import type { Codec } from './codec.ts';
|
|
27
27
|
/** Minimal Standard Schema interface for auto-detection. */
|
|
28
28
|
export interface StandardSchemaV1<Output = unknown> {
|
|
29
|
-
'~standard': {
|
|
30
|
-
validate(value: unknown)
|
|
29
|
+
readonly '~standard': {
|
|
30
|
+
readonly validate: (value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
export type StandardSchemaResult<Output> = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-bridge.d.ts","sourceRoot":"","sources":["../src/schema-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAUxC,4DAA4D;AAC5D,MAAM,WAAW,gBAAgB,CAAC,MAAM,GAAG,OAAO;IAChD,WAAW,EAAE;
|
|
1
|
+
{"version":3,"file":"schema-bridge.d.ts","sourceRoot":"","sources":["../src/schema-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAUxC,4DAA4D;AAC5D,MAAM,WAAW,gBAAgB,CAAC,MAAM,GAAG,OAAO;IAChD,QAAQ,CAAC,WAAW,EAAE;QACpB,QAAQ,CAAC,QAAQ,EAAE,CACjB,KAAK,EAAE,OAAO,KACX,oBAAoB,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,oBAAoB,CAAC,MAAM,CAAC,CAAC,CAAC;KAC3E,CAAC;CACH;AAED,MAAM,MAAM,oBAAoB,CAAC,MAAM,IACnC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,SAAS,CAAA;CAAE,GACrC;IAAE,KAAK,CAAC,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,aAAa,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC;AAMtE;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,EACjC,MAAM,EAAE,gBAAgB,CAAC,MAAM,CAAC,EAChC,KAAK,EAAE,OAAO,GACb,oBAAoB,CAAC,MAAM,CAAC,CAQ9B;AAMD,oDAAoD;AACpD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAO1E;AAED,mEAAmE;AACnE,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,CAO/D;AAMD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAgB3F;AAMD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,OAAO,EACd,IAAI,GAAE,OAAO,GAAG,QAAQ,GAAG,QAAmB,GAC7C,KAAK,CAAC,OAAO,CAAC,CAYhB;AA0GD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAWnE;AAMD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAWzE;AAMD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG;IAC/D,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,CAAC,CAAC;IAC/C,SAAS,CAAC,KAAK,EAAE,CAAC,GAAG,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC;CAC/C,CAgBA"}
|
|
@@ -32,8 +32,8 @@ export declare class ActionError<TCode extends string = string> extends Error {
|
|
|
32
32
|
* requiring exact structural matches on issues/path shapes.
|
|
33
33
|
*/
|
|
34
34
|
interface StandardSchemaV1<Output = unknown> {
|
|
35
|
-
'~standard': {
|
|
36
|
-
validate(value: unknown)
|
|
35
|
+
readonly '~standard': {
|
|
36
|
+
readonly validate: (value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
type StandardSchemaResult<Output> = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action-client.d.ts","sourceRoot":"","sources":["../../src/server/action-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH;;;;;;;GAOG;AACH,qBAAa,WAAW,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,KAAK;IACnE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAEnD,YAAY,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAKtD;CACF;AAID;;;;;;;GAOG;AACH,UAAU,gBAAgB,CAAC,MAAM,GAAG,OAAO;IACzC,WAAW,EAAE;
|
|
1
|
+
{"version":3,"file":"action-client.d.ts","sourceRoot":"","sources":["../../src/server/action-client.ts"],"names":[],"mappings":"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"}
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/shared/redirect-type.ts","../../src/server/sensitive-fields.ts","../../src/server/action-client.ts","../../src/server/form-flash.ts"],"sourcesContent":["/**\n * Next.js redirect type discriminator.\n *\n * Provided for API compatibility with libraries that import `RedirectType`\n * from `next/navigation`. In timber, `redirect()` always uses `replace`\n * semantics (no history entry for the redirect itself).\n *\n * Lives in shared/ (isomorphic) so both the server primitives and the\n * client-only next/navigation shim export the same definition without the\n * client shim pulling in server code.\n */\nexport const RedirectType = {\n push: 'push',\n replace: 'replace',\n} as const;\n\nexport type RedirectTypeValue = (typeof RedirectType)[keyof typeof RedirectType];\n","/**\n * Sensitive field stripping — removes password/token/CVV-style fields\n * from form values before they are echoed back to the client as\n * `submittedValues` for form repopulation.\n *\n * Applied to both action paths:\n * - With-JS action path: `createActionClient()` in `action-client.ts`\n * - No-JS form POST path: `handleFormAction()` in `action-handler.ts`\n *\n * Why: on a validation failure, timber echoes submitted form values back so\n * the user doesn't have to re-type everything. Without filtering, plaintext\n * passwords / credit-card numbers / TOTP codes would travel through the RSC\n * stream (with-JS) or land in the HTML as `defaultValue` attributes (no-JS)\n * — ending up in browser history, proxy logs, disk caches, and the\n * back-forward cache.\n *\n * Safe by default: the built-in deny-list is applied unconditionally unless\n * the user explicitly opts out via `forms.stripSensitiveFields: false` in\n * `timber.config.ts` or per-action via `createActionClient({ stripSensitiveFields: false })`.\n *\n * See design/08-forms-and-actions.md §\"Validation errors\"\n * See design/13-security.md §\"Sensitive field stripping\"\n * See TIM-816\n */\n\nimport { isDebug } from './debug.ts';\n\n// ─── Public types ────────────────────────────────────────────────────────\n\n/**\n * How to strip sensitive fields from `submittedValues`.\n *\n * - `true` / `undefined` — use the built-in deny-list (default, safe).\n * - `false` — do not strip anything (dev convenience; never do this in prod).\n * - `string[]` — additional field names to strip, merged with the built-in list.\n * - `(name) => boolean` — custom predicate, fully replaces the built-in list.\n * Return `true` to strip, `false` to keep. The `name` argument is the raw\n * (un-normalized) field name as it appeared in the submitted form.\n */\nexport type SensitiveFieldsOption = boolean | readonly string[] | ((name: string) => boolean);\n\n// ─── Built-in deny-list ──────────────────────────────────────────────────\n\n/**\n * Substring patterns matched against the normalized field name.\n * Normalization = lowercase + strip `_` and `-`.\n *\n * Any field whose normalized name *contains* one of these strings is\n * considered sensitive. Entries like `currentPassword`, `passwordConfirmation`,\n * and `user.password` all match via the `password` substring.\n */\nconst BUILTIN_SUBSTRING_PATTERNS: readonly string[] = [\n 'password',\n 'passwd',\n 'pwd',\n 'secret',\n 'apikey',\n 'accesstoken',\n 'refreshtoken',\n 'cvv',\n 'cvc',\n 'cardnumber',\n 'cardcvc',\n 'ssn',\n 'socialsecuritynumber',\n 'otp',\n 'totp',\n 'mfacode',\n 'twofactorcode',\n 'privatekey',\n];\n\n/**\n * Exact matches against the normalized field name. These are field names that\n * are too short or too common to substring-match safely. e.g. `token` alone\n * would match `csrfToken`, which is not sensitive — so `token` is exact-only,\n * while legitimate token fields are covered by `accesstoken` / `refreshtoken`.\n */\nconst BUILTIN_EXACT_PATTERNS: readonly string[] = ['token'];\n\n/**\n * Normalize a field name for deny-list comparison.\n * Lowercases the string and strips `_` and `-` so camelCase, snake_case, and\n * kebab-case variants all compare equal (`api_key` / `apiKey` / `api-key` →\n * `apikey`).\n */\nfunction normalize(name: string): string {\n let out = '';\n for (let i = 0; i < name.length; i++) {\n const ch = name.charCodeAt(i);\n if (ch === 0x5f /* _ */ || ch === 0x2d /* - */) continue;\n // A-Z → a-z\n if (ch >= 0x41 && ch <= 0x5a) {\n out += String.fromCharCode(ch + 32);\n } else {\n out += name[i];\n }\n }\n return out;\n}\n\n/**\n * Check whether a name matches the built-in deny-list (with optional extras).\n * Extras are merged into the substring pattern list after normalization.\n */\nfunction isBuiltinSensitive(name: string, extras?: readonly string[]): boolean {\n const normalized = normalize(name);\n if (BUILTIN_EXACT_PATTERNS.includes(normalized)) return true;\n for (const pattern of BUILTIN_SUBSTRING_PATTERNS) {\n if (normalized.includes(pattern)) return true;\n }\n if (extras && extras.length > 0) {\n for (const extra of extras) {\n const normExtra = normalize(extra);\n if (normExtra.length === 0) continue;\n if (normalized.includes(normExtra)) return true;\n }\n }\n return false;\n}\n\n// ─── Predicate resolution ────────────────────────────────────────────────\n\n/**\n * A resolved predicate: `null` means \"don't strip anything\" (the option was\n * explicitly `false`). Otherwise a function from raw field name → boolean.\n */\nexport type ResolvedSensitivePredicate = ((name: string) => boolean) | null;\n\n/**\n * Resolve a `SensitiveFieldsOption` into a concrete predicate.\n * Precedence: per-action > global > built-in default.\n *\n * - Per-action `undefined` → fall back to global.\n * - Global `undefined` → use built-in list.\n * - Either level set to `false` → disable stripping entirely (returns `null`).\n * - `true` → built-in list.\n * - `string[]` → built-in ∪ extras.\n * - function → custom, replaces the built-in list entirely.\n */\nexport function resolveSensitivePredicate(\n perAction: SensitiveFieldsOption | undefined,\n global: SensitiveFieldsOption | undefined\n): ResolvedSensitivePredicate {\n const chosen = perAction !== undefined ? perAction : global;\n\n if (chosen === false) return null;\n if (chosen === undefined || chosen === true) {\n return (name) => isBuiltinSensitive(name);\n }\n if (typeof chosen === 'function') {\n return chosen;\n }\n // Array of extra names merged with the built-in list.\n const extras = chosen;\n return (name) => isBuiltinSensitive(name, extras);\n}\n\n// ─── Module-level global config ──────────────────────────────────────────\n\nlet globalConfig: SensitiveFieldsOption | undefined;\n\n/**\n * Set the global `forms.stripSensitiveFields` config from `timber.config.ts`.\n * Called once at startup from `rsc-entry`.\n */\nexport function setGlobalSensitiveFieldsConfig(option: SensitiveFieldsOption | undefined): void {\n globalConfig = option;\n}\n\n/** Read the global `forms.stripSensitiveFields` config. */\nexport function getGlobalSensitiveFieldsConfig(): SensitiveFieldsOption | undefined {\n return globalConfig;\n}\n\n// ─── Stripping ───────────────────────────────────────────────────────────\n\n// One warning per field name per process — prevents log spam when a form is\n// submitted many times in dev mode.\nconst warnedFields = new Set<string>();\n\nfunction warnStripped(name: string): void {\n if (!isDebug()) return;\n if (warnedFields.has(name)) return;\n warnedFields.add(name);\n console.warn(\n `[timber] stripped sensitive field \"${name}\" from submittedValues. ` +\n `Override via forms.stripSensitiveFields in timber.config.ts.`\n );\n}\n\n/**\n * Walk an object (recursively) and return a copy with every key matching\n * `predicate` removed. Nested objects like `{ user: { password: '...' } }`\n * are handled — `user.password` is stripped while other `user.*` fields remain.\n *\n * - Arrays are walked element-wise (object entries inside arrays are cleaned).\n * - Non-plain values (strings, numbers, Files, Dates, etc.) are returned as-is.\n * - When a stripped key is encountered, it is omitted from the result entirely\n * — we do NOT set it to an empty string, because that would overwrite a\n * valid `defaultValue` the form author might have set.\n */\nexport function stripSensitiveFields<T>(value: T, predicate: ResolvedSensitivePredicate): T {\n // Null predicate = stripping disabled entirely.\n if (predicate === null) return value;\n if (value === null || value === undefined) return value;\n if (typeof value !== 'object') return value;\n if (value instanceof File || value instanceof Date) return value;\n\n if (Array.isArray(value)) {\n return value.map((item) => stripSensitiveFields(item, predicate)) as unknown as T;\n }\n\n const result: Record<string, unknown> = {};\n for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {\n if (predicate(key)) {\n warnStripped(key);\n continue;\n }\n result[key] = stripSensitiveFields(nested, predicate);\n }\n return result as unknown as T;\n}\n\n// ─── Test helpers ────────────────────────────────────────────────────────\n\n/** Reset the \"warned once\" cache. Exposed for tests. */\nexport function __resetSensitiveFieldsWarnings(): void {\n warnedFields.clear();\n}\n","/**\n * createActionClient — typed middleware and schema validation for server actions.\n *\n * Inspired by next-safe-action. Provides a builder API:\n * createActionClient({ middleware }) → .schema(z.object(...)) → .action(fn)\n *\n * The resulting action function satisfies both:\n * 1. Direct call: action(input) → Promise<ActionResult>\n * 2. React useActionState: (prevState, formData) => Promise<ActionResult>\n *\n * See design/08-forms-and-actions.md §\"Middleware and Server Actions\"\n */\n\n// ─── ActionError ─────────────────────────────────────────────────────────\n\n/**\n * Typed error class for server actions. Carries a string code and optional data.\n * When thrown from middleware or the action body, the action short-circuits and\n * the client receives `result.serverError`.\n *\n * In production, unexpected errors (non-ActionError) return `{ code: 'INTERNAL_ERROR' }`\n * with no message. In dev, `data.message` is included.\n */\nexport class ActionError<TCode extends string = string> extends Error {\n readonly code: TCode;\n readonly data: Record<string, unknown> | undefined;\n\n constructor(code: TCode, data?: Record<string, unknown>) {\n super(`ActionError: ${code}`);\n this.name = 'ActionError';\n this.code = code;\n this.data = data;\n }\n}\n\n// ─── Standard Schema ──────────────────────────────────────────────────────\n\n/**\n * Standard Schema v1 interface (subset).\n * Zod ≥3.24, Valibot ≥1.0, and ArkType all implement this.\n * See https://github.com/standard-schema/standard-schema\n *\n * We use permissive types here to accept all compliant libraries without\n * requiring exact structural matches on issues/path shapes.\n */\ninterface StandardSchemaV1<Output = unknown> {\n '~standard': {\n validate(value: unknown): StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;\n };\n}\n\ntype StandardSchemaResult<Output> =\n | { value: Output; issues?: undefined }\n | { value?: undefined; issues: ReadonlyArray<StandardSchemaIssue> };\n\ninterface StandardSchemaIssue {\n message: string;\n path?: ReadonlyArray<PropertyKey | { key: PropertyKey }>;\n}\n\n/** Check if a schema implements the Standard Schema protocol. */\nfunction isStandardSchema(schema: unknown): schema is StandardSchemaV1 {\n return (\n typeof schema === 'object' &&\n schema !== null &&\n '~standard' in schema &&\n typeof (schema as StandardSchemaV1)['~standard'].validate === 'function'\n );\n}\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\n/**\n * Minimal schema interface — compatible with Zod, Valibot, ArkType, etc.\n *\n * Accepts either:\n * - Standard Schema (preferred): any object with `~standard.validate()`\n * - Legacy parse interface: objects with `.parse()` / `.safeParse()`\n *\n * At runtime, Standard Schema is detected via `~standard` property and\n * takes priority over the legacy interface.\n */\nexport type ActionSchema<T = unknown> = StandardSchemaV1<T> | LegacyActionSchema<T>;\n\n/** Legacy schema interface with .parse() / .safeParse(). */\ninterface LegacyActionSchema<T = unknown> {\n 'parse'(data: unknown): T;\n 'safeParse'?(data: unknown): { success: true; data: T } | { success: false; error: SchemaError };\n // Exclude Standard Schema objects from matching this interface\n '~standard'?: never;\n}\n\n/** Schema validation error shape (for legacy .safeParse()/.parse() interface). */\nexport interface SchemaError {\n issues?: Array<{ path?: Array<string | number>; message: string }>;\n flatten?(): { fieldErrors: Record<string, string[]> };\n}\n\n/** Flattened validation errors keyed by field name. */\nexport type ValidationErrors = Record<string, string[]>;\n\n/** Middleware function: returns context to merge into the action body's ctx. */\nexport type ActionMiddleware<TCtx = Record<string, unknown>> = () => Promise<TCtx> | TCtx;\n\n/** The result type returned to the client. */\nexport type ActionResult<TData = unknown> =\n | { data: TData; validationErrors?: never; serverError?: never; submittedValues?: never }\n | {\n data?: never;\n validationErrors: ValidationErrors;\n serverError?: never;\n /** Raw input values on validation failure — for repopulating form fields. */\n submittedValues?: Record<string, unknown>;\n }\n | {\n data?: never;\n validationErrors?: never;\n serverError: { code: string; data?: Record<string, unknown> };\n submittedValues?: never;\n };\n\n/** Context passed to the action body. */\nexport interface ActionContext<TCtx, TInput> {\n ctx: TCtx;\n input: TInput;\n}\n\n// ─── Builder ─────────────────────────────────────────────────────────────\n\ninterface ActionClientConfig<TCtx> {\n middleware?: ActionMiddleware<TCtx> | ActionMiddleware<Record<string, unknown>>[];\n /** Max file size in bytes. Files exceeding this are rejected with validation errors. */\n fileSizeLimit?: number;\n /**\n * Override the sensitive-field deny-list for this action client.\n * See `SensitiveFieldsOption` in `./sensitive-fields.ts`. Per-action config\n * takes precedence over the global `forms.stripSensitiveFields` option in\n * `timber.config.ts`. See design/08-forms-and-actions.md and TIM-816.\n */\n stripSensitiveFields?: SensitiveFieldsOption;\n}\n\n/** Intermediate builder returned by createActionClient(). */\nexport interface ActionBuilder<TCtx> {\n /** Declare the input schema. Validation errors are returned typed. */\n schema<TInput>(schema: ActionSchema<TInput>): ActionBuilderWithSchema<TCtx, TInput>;\n /** Define the action body without input validation. */\n action<TData>(\n fn: (ctx: ActionContext<TCtx, undefined>) => Promise<TData>\n ): ActionFn<TData, undefined>;\n}\n\n/** Builder after .schema() has been called. */\nexport interface ActionBuilderWithSchema<TCtx, TInput> {\n /** Define the action body with validated input. */\n action<TData>(fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>): ActionFn<TData, TInput>;\n}\n\n/**\n * The final action function. Callable three ways:\n * - Direct: action(input) → Promise<ActionResult<TData>>\n * - React useActionState: action(prevState, formData) → Promise<ActionResult<TData>>\n * - React <form action={fn}>: action(formData) → void (return value ignored by React)\n *\n * The third overload exists purely for type compatibility with React's\n * `<form action>` prop, which expects `(formData: FormData) => void`.\n * At runtime the function still returns Promise<ActionResult>, but React\n * discards it. This lets validated actions be passed directly to forms\n * without casts.\n */\n/**\n * Map schema output keys to `string | undefined` for form-facing APIs.\n * HTML form values are always strings, and fields can be absent.\n * Gives autocomplete for field names without lying about value types.\n */\nexport type InputHint<T> =\n T extends Record<string, unknown> ? { [K in keyof T]: string | undefined } : T;\n\n/**\n * ActionFn — the callable returned by `createActionClient().action()`.\n *\n * Generic order: `<TData, TInput>` — TData first for backward compatibility.\n * Previously ActionFn had a single `<TData>` generic, so existing code like\n * `ActionFn<MyResult>` must still work with TData in the first position.\n * See TIM-797.\n */\nexport type ActionFn<TData = unknown, TInput = unknown> = {\n /** <form action={fn}> compatibility — React discards the return value. */\n (formData: FormData): void;\n /** Direct call: action(input) — optional when TInput is undefined/unknown (no-schema actions). */\n (\n ...args: undefined extends TInput ? [input?: TInput] : [input: TInput]\n ): Promise<ActionResult<TData>>;\n /** React useActionState: action(prevState, formData) */\n (prevState: ActionResult<TData> | null, formData: FormData): Promise<ActionResult<TData>>;\n};\n\n// ─── Implementation ──────────────────────────────────────────────────────\n\n/**\n * Run middleware array or single function. Returns merged context.\n */\nasync function runActionMiddleware<TCtx>(\n middleware: ActionMiddleware<TCtx> | ActionMiddleware<Record<string, unknown>>[] | undefined\n): Promise<TCtx> {\n if (!middleware) {\n return {} as TCtx;\n }\n\n if (Array.isArray(middleware)) {\n let merged = {} as Record<string, unknown>;\n for (const mw of middleware) {\n const result = await mw();\n merged = { ...merged, ...result };\n }\n return merged as TCtx;\n }\n\n return await middleware();\n}\n\n// Re-export parseFormData for use throughout the framework\nimport { parseFormData } from './form-data.ts';\nimport { formatSize } from '../utils/format.ts';\nimport { isDebug, isDevMode } from './debug.ts';\nimport { isDenySignal, isRedirectSignal } from './primitives.ts';\nimport {\n stripSensitiveFields,\n resolveSensitivePredicate,\n getGlobalSensitiveFieldsConfig,\n type SensitiveFieldsOption,\n} from './sensitive-fields.ts';\n\n/**\n * Extract validation errors from a schema error.\n * Supports Zod's flatten() and generic issues array.\n */\nfunction extractValidationErrors(error: SchemaError): ValidationErrors {\n // Zod-style flatten\n if (typeof error.flatten === 'function') {\n return error.flatten().fieldErrors;\n }\n\n // Generic issues array\n if (error.issues) {\n const errors: ValidationErrors = {};\n for (const issue of error.issues) {\n const path = issue.path?.join('.') ?? '_root';\n if (!errors[path]) errors[path] = [];\n errors[path].push(issue.message);\n }\n return errors;\n }\n\n return { _root: ['Validation failed'] };\n}\n\n/**\n * Extract validation errors from Standard Schema issues.\n */\nfunction extractStandardSchemaErrors(issues: ReadonlyArray<StandardSchemaIssue>): ValidationErrors {\n const errors: ValidationErrors = {};\n for (const issue of issues) {\n const path =\n issue.path\n ?.map((p) => {\n // Standard Schema path items can be { key: ... } objects or bare PropertyKey values\n if (typeof p === 'object' && p !== null && 'key' in p) return String(p.key);\n return String(p);\n })\n .join('.') ?? '_root';\n if (!errors[path]) errors[path] = [];\n errors[path].push(issue.message);\n }\n return Object.keys(errors).length > 0 ? errors : { _root: ['Validation failed'] };\n}\n\n/**\n * Wrap unexpected errors into a safe server error result.\n * ActionError → typed result. Other errors → INTERNAL_ERROR (no leak).\n *\n * Exported for use by action-handler.ts to catch errors from raw 'use server'\n * functions that don't use createActionClient.\n */\nexport function handleActionError(error: unknown): ActionResult<never> {\n if (error instanceof ActionError) {\n return {\n serverError: {\n code: error.code,\n ...(error.data ? { data: error.data } : {}),\n },\n };\n }\n\n // In dev, include the message for debugging.\n // Uses isDevMode() — NOT isDebug() — because this data is sent to the\n // browser. TIMBER_DEBUG must never cause error messages to leak to clients.\n // See design/13-security.md principle 4: \"Errors don't leak.\"\n const devMode = isDevMode();\n return {\n serverError: {\n code: 'INTERNAL_ERROR',\n ...(devMode && error instanceof Error ? { data: { message: error.message } } : {}),\n },\n };\n}\n\n/**\n * Create a typed action client with middleware and schema validation.\n *\n * @example\n * ```ts\n * const action = createActionClient({\n * middleware: async () => {\n * const user = await getUser()\n * if (!user) throw new ActionError('UNAUTHORIZED')\n * return { user }\n * },\n * })\n *\n * export const createTodo = action\n * .schema(z.object({ title: z.string().min(1) }))\n * .action(async ({ input, ctx }) => {\n * await db.todos.create({ ...input, userId: ctx.user.id })\n * })\n * ```\n */\nexport function createActionClient<TCtx = Record<string, never>>(\n config: ActionClientConfig<TCtx> = {}\n): ActionBuilder<TCtx> {\n function buildAction<TInput, TData>(\n schema: ActionSchema<TInput> | undefined,\n fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>\n ): ActionFn<TData, TInput> {\n async function actionHandler(...args: unknown[]): Promise<ActionResult<TData>> {\n try {\n // Run middleware\n const ctx = await runActionMiddleware(config.middleware);\n\n // Determine input — either FormData (from useActionState) or direct arg\n let rawInput: unknown;\n if (args.length === 2 && args[1] instanceof FormData) {\n // Called as (prevState, formData) by React useActionState (with-JS path)\n rawInput = schema ? parseFormData(args[1]) : args[1];\n } else if (args.length === 1 && args[0] instanceof FormData) {\n // No-JS path: React's decodeAction binds FormData as the sole argument.\n // The form POSTs without JavaScript, decodeAction resolves the server\n // reference and binds the FormData, then executeAction calls fn() with\n // no additional args — so the bound FormData arrives as args[0].\n rawInput = schema ? parseFormData(args[0]) : args[0];\n } else {\n // Direct call: action(input)\n rawInput = args[0];\n }\n\n // Resolve the sensitive-field stripping predicate once per invocation.\n // Precedence: per-action (config.stripSensitiveFields) > global\n // (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.\n // See TIM-816.\n const sensitivePredicate = resolveSensitivePredicate(\n config.stripSensitiveFields,\n getGlobalSensitiveFieldsConfig()\n );\n\n // Capture a \"safe-to-echo\" snapshot of the raw input once. Files are\n // stripped (can't serialize, shouldn't echo back) and sensitive fields\n // (passwords, tokens, CVV, etc.) are removed before they would land\n // in the RSC payload → client form `defaultValue` → DOM.\n const buildSubmittedValues = (): Record<string, unknown> | undefined => {\n const withoutFiles = stripFiles(rawInput);\n if (withoutFiles === undefined) return undefined;\n return stripSensitiveFields(withoutFiles, sensitivePredicate);\n };\n\n // Validate file sizes before schema validation.\n if (config.fileSizeLimit !== undefined && rawInput && typeof rawInput === 'object') {\n const fileSizeErrors = validateFileSizes(\n rawInput as Record<string, unknown>,\n config.fileSizeLimit\n );\n if (fileSizeErrors) {\n return { validationErrors: fileSizeErrors, submittedValues: buildSubmittedValues() };\n }\n }\n\n // Capture submitted values for repopulation on validation failure.\n const submittedValues = schema ? buildSubmittedValues() : undefined;\n\n // Validate with schema if provided\n let input: TInput;\n if (schema) {\n if (isStandardSchema(schema)) {\n // Standard Schema protocol (Zod ≥3.24, Valibot ≥1.0, ArkType)\n const result = schema['~standard'].validate(rawInput);\n if (result instanceof Promise) {\n throw new Error(\n '[timber] createActionClient: schema returned a Promise — only sync schemas are supported.'\n );\n }\n if (result.issues) {\n const validationErrors = extractStandardSchemaErrors(result.issues);\n logValidationFailure(validationErrors);\n return { validationErrors, submittedValues };\n }\n input = result.value;\n } else if (typeof schema.safeParse === 'function') {\n const result = schema.safeParse(rawInput);\n if (!result.success) {\n const validationErrors = extractValidationErrors(result.error);\n logValidationFailure(validationErrors);\n return { validationErrors, submittedValues };\n }\n input = result.data;\n } else {\n try {\n input = schema.parse(rawInput);\n } catch (parseError) {\n const validationErrors = extractValidationErrors(parseError as SchemaError);\n logValidationFailure(validationErrors);\n return { validationErrors, submittedValues };\n }\n }\n } else {\n input = rawInput as TInput;\n }\n\n // Execute the action body\n const data = await fn({ ctx, input });\n return { data };\n } catch (error) {\n // Re-throw redirect/deny signals — these are control flow, not errors.\n // They must propagate to executeAction() which converts them to proper\n // HTTP responses (302 redirect, 4xx deny). Catching them here would\n // wrap them as INTERNAL_ERROR and break redirect()/redirectExternal()/deny().\n if (isRedirectSignal(error) || isDenySignal(error)) {\n throw error;\n }\n return handleActionError(error);\n }\n }\n\n return actionHandler as ActionFn<TData, TInput>;\n }\n\n return {\n schema<TInput>(schema: ActionSchema<TInput>) {\n return {\n action<TData>(\n fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>\n ): ActionFn<TData, TInput> {\n return buildAction(schema, fn);\n },\n };\n },\n action<TData>(\n fn: (ctx: ActionContext<TCtx, undefined>) => Promise<TData>\n ): ActionFn<TData, undefined> {\n return buildAction(undefined, fn as (ctx: ActionContext<TCtx, unknown>) => Promise<TData>);\n },\n };\n}\n\n// ─── validated() ────────────────────────────────────────────────────────\n\n/**\n * Convenience wrapper for the common case: validate input, run handler.\n * No middleware needed.\n *\n * @example\n * ```ts\n * 'use server'\n * import { validated } from '@timber-js/app/server'\n * import { z } from 'zod'\n *\n * export const createTodo = validated(\n * z.object({ title: z.string().min(1) }),\n * async (input) => {\n * await db.todos.create(input)\n * }\n * )\n * ```\n */\nexport function validated<TInput, TData>(\n schema: ActionSchema<TInput>,\n handler: (input: TInput) => Promise<TData>\n): ActionFn<TData, TInput> {\n return createActionClient()\n .schema(schema)\n .action(async ({ input }) => handler(input));\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\n/**\n * Log validation failures in dev mode so developers can see what went wrong.\n * In production, validation errors are only returned to the client.\n */\nfunction logValidationFailure(errors: ValidationErrors): void {\n const isDev = isDebug();\n if (!isDev) return;\n\n const fields = Object.entries(errors)\n .map(([field, messages]) => ` ${field}: ${messages.join(', ')}`)\n .join('\\n');\n console.warn(`[timber] action schema validation failed:\\n${fields}`);\n}\n\n/**\n * Validate that all File objects in the input are within the size limit.\n * Returns validation errors keyed by field name, or null if all files are ok.\n */\nfunction validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {\n const limitKb = Math.round(limit / 1024);\n const limitLabel =\n limit >= 1024 * 1024 ? `${Math.round(limit / (1024 * 1024))}MB` : `${limitKb}KB`;\n\n const errors: ValidationErrors = {};\n\n function walk(obj: Record<string, unknown>, prefix: string): void {\n for (const [key, value] of Object.entries(obj)) {\n const path = prefix ? `${prefix}.${key}` : key;\n if (value instanceof File && value.size > limit) {\n errors[path] = [\n `File \"${value.name}\" (${formatSize(value.size)}) exceeds the ${limitLabel} limit`,\n ];\n } else if (Array.isArray(value)) {\n for (let i = 0; i < value.length; i++) {\n const item = value[i];\n const itemPath = `${path}[${i}]`;\n if (item instanceof File && item.size > limit) {\n (errors[itemPath] ??= []).push(\n `File \"${item.name}\" (${formatSize(item.size)}) exceeds the ${limitLabel} limit`\n );\n } else if (typeof item === 'object' && item !== null && !(item instanceof File)) {\n walk(item as Record<string, unknown>, itemPath);\n }\n }\n } else if (typeof value === 'object' && value !== null && !(value instanceof File)) {\n walk(value as Record<string, unknown>, path);\n }\n }\n }\n\n walk(input, '');\n return Object.keys(errors).length > 0 ? errors : null;\n}\n\n/**\n * Strip File objects from a value, returning a plain object safe for\n * serialization. File objects can't be serialized and shouldn't be echoed back.\n */\nfunction stripFiles(value: unknown): Record<string, unknown> | undefined {\n if (value === null || value === undefined) return undefined;\n if (typeof value !== 'object') return undefined;\n\n const result: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n if (v instanceof File) continue;\n if (Array.isArray(v)) {\n result[k] = v\n .filter((item) => !(item instanceof File))\n .map((item) =>\n typeof item === 'object' && item !== null && !(item instanceof File)\n ? (stripFiles(item) ?? {})\n : item\n );\n } else if (typeof v === 'object' && v !== null && !(v instanceof File)) {\n result[k] = stripFiles(v) ?? {};\n } else {\n result[k] = v;\n }\n }\n return result;\n}\n","/**\n * Form Flash — ALS-based store for no-JS form action results.\n *\n * When a no-JS form action completes, the server re-renders the page with\n * the action result injected via AsyncLocalStorage instead of redirecting\n * (which would discard the result). Server components read the flash and\n * pass it to client form components as the initial `useActionState` value.\n *\n * This follows the Remix/Rails pattern — the form component becomes the\n * single source of truth for both with-JS (React state) and no-JS (flash).\n *\n * The flash data is server-side only — never serialized to cookies or headers.\n *\n * See design/08-forms-and-actions.md §\"No-JS Error Round-Trip\"\n */\n\nimport type { ValidationErrors } from './action-client.ts';\nimport { formFlashAls } from './als-registry.ts';\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\n/**\n * Flash data injected into the re-render after a no-JS form submission.\n *\n * This is the action result from the server action, stored in ALS so server\n * components can read it and pass it to client form components as the initial\n * state for `useActionState`. This makes the form component a single source\n * of truth for both with-JS and no-JS paths.\n *\n * The shape matches `ActionResult<unknown>` — it's one of:\n * - `{ data: ... }` — success\n * - `{ validationErrors, submittedValues }` — validation failure\n * - `{ serverError }` — server error\n */\nexport interface FormFlashData {\n /** Success data from the action. */\n data?: unknown;\n /** Validation errors keyed by field name. `_root` for form-level errors. */\n validationErrors?: ValidationErrors;\n /** Raw submitted values for repopulating form fields. File objects are excluded. */\n submittedValues?: Record<string, unknown>;\n /** Server error if the action threw an ActionError. */\n serverError?: { code: string; data?: Record<string, unknown> };\n}\n\n// ─── Public API ──────────────────────────────────────────────────────────\n\n/**\n * Read the form flash data for the current request.\n *\n * Returns `null` if no flash data is present (i.e., this is a normal page\n * render, not a re-render after a no-JS form submission).\n *\n * Pass the flash as the initial state to `useActionState` so the form\n * component has a single source of truth for both with-JS and no-JS paths:\n *\n * ```tsx\n * // app/contact/page.tsx (server component)\n * import { getFormFlash } from '@timber-js/app/server'\n *\n * export default function ContactPage() {\n * const flash = getFormFlash()\n * return <ContactForm flash={flash} />\n * }\n *\n * // app/contact/form.tsx (client component)\n * export function ContactForm({ flash }) {\n * const [result, action, isPending] = useActionState(submitContact, flash)\n * // result is the single source of truth — flash seeds it on no-JS\n * }\n * ```\n */\nexport function getFormFlash(): FormFlashData | null {\n return formFlashAls.getStore() ?? null;\n}\n\n// ─── Framework-Internal ──────────────────────────────────────────────────\n\n/**\n * Run a callback with form flash data in scope.\n *\n * Used by the action handler to re-render the page with validation errors\n * available via `getFormFlash()`. Not part of the public API.\n *\n * @internal\n */\nexport function runWithFormFlash<T>(data: FormFlashData, fn: () => T): T {\n return formFlashAls.run(data, fn);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/shared/redirect-type.ts","../../src/server/sensitive-fields.ts","../../src/server/action-client.ts","../../src/server/form-flash.ts"],"sourcesContent":["/**\n * Next.js redirect type discriminator.\n *\n * Provided for API compatibility with libraries that import `RedirectType`\n * from `next/navigation`. In timber, `redirect()` always uses `replace`\n * semantics (no history entry for the redirect itself).\n *\n * Lives in shared/ (isomorphic) so both the server primitives and the\n * client-only next/navigation shim export the same definition without the\n * client shim pulling in server code.\n */\nexport const RedirectType = {\n push: 'push',\n replace: 'replace',\n} as const;\n\nexport type RedirectTypeValue = (typeof RedirectType)[keyof typeof RedirectType];\n","/**\n * Sensitive field stripping — removes password/token/CVV-style fields\n * from form values before they are echoed back to the client as\n * `submittedValues` for form repopulation.\n *\n * Applied to both action paths:\n * - With-JS action path: `createActionClient()` in `action-client.ts`\n * - No-JS form POST path: `handleFormAction()` in `action-handler.ts`\n *\n * Why: on a validation failure, timber echoes submitted form values back so\n * the user doesn't have to re-type everything. Without filtering, plaintext\n * passwords / credit-card numbers / TOTP codes would travel through the RSC\n * stream (with-JS) or land in the HTML as `defaultValue` attributes (no-JS)\n * — ending up in browser history, proxy logs, disk caches, and the\n * back-forward cache.\n *\n * Safe by default: the built-in deny-list is applied unconditionally unless\n * the user explicitly opts out via `forms.stripSensitiveFields: false` in\n * `timber.config.ts` or per-action via `createActionClient({ stripSensitiveFields: false })`.\n *\n * See design/08-forms-and-actions.md §\"Validation errors\"\n * See design/13-security.md §\"Sensitive field stripping\"\n * See TIM-816\n */\n\nimport { isDebug } from './debug.ts';\n\n// ─── Public types ────────────────────────────────────────────────────────\n\n/**\n * How to strip sensitive fields from `submittedValues`.\n *\n * - `true` / `undefined` — use the built-in deny-list (default, safe).\n * - `false` — do not strip anything (dev convenience; never do this in prod).\n * - `string[]` — additional field names to strip, merged with the built-in list.\n * - `(name) => boolean` — custom predicate, fully replaces the built-in list.\n * Return `true` to strip, `false` to keep. The `name` argument is the raw\n * (un-normalized) field name as it appeared in the submitted form.\n */\nexport type SensitiveFieldsOption = boolean | readonly string[] | ((name: string) => boolean);\n\n// ─── Built-in deny-list ──────────────────────────────────────────────────\n\n/**\n * Substring patterns matched against the normalized field name.\n * Normalization = lowercase + strip `_` and `-`.\n *\n * Any field whose normalized name *contains* one of these strings is\n * considered sensitive. Entries like `currentPassword`, `passwordConfirmation`,\n * and `user.password` all match via the `password` substring.\n */\nconst BUILTIN_SUBSTRING_PATTERNS: readonly string[] = [\n 'password',\n 'passwd',\n 'pwd',\n 'secret',\n 'apikey',\n 'accesstoken',\n 'refreshtoken',\n 'cvv',\n 'cvc',\n 'cardnumber',\n 'cardcvc',\n 'ssn',\n 'socialsecuritynumber',\n 'otp',\n 'totp',\n 'mfacode',\n 'twofactorcode',\n 'privatekey',\n];\n\n/**\n * Exact matches against the normalized field name. These are field names that\n * are too short or too common to substring-match safely. e.g. `token` alone\n * would match `csrfToken`, which is not sensitive — so `token` is exact-only,\n * while legitimate token fields are covered by `accesstoken` / `refreshtoken`.\n */\nconst BUILTIN_EXACT_PATTERNS: readonly string[] = ['token'];\n\n/**\n * Normalize a field name for deny-list comparison.\n * Lowercases the string and strips `_` and `-` so camelCase, snake_case, and\n * kebab-case variants all compare equal (`api_key` / `apiKey` / `api-key` →\n * `apikey`).\n */\nfunction normalize(name: string): string {\n let out = '';\n for (let i = 0; i < name.length; i++) {\n const ch = name.charCodeAt(i);\n if (ch === 0x5f /* _ */ || ch === 0x2d /* - */) continue;\n // A-Z → a-z\n if (ch >= 0x41 && ch <= 0x5a) {\n out += String.fromCharCode(ch + 32);\n } else {\n out += name[i];\n }\n }\n return out;\n}\n\n/**\n * Check whether a name matches the built-in deny-list (with optional extras).\n * Extras are merged into the substring pattern list after normalization.\n */\nfunction isBuiltinSensitive(name: string, extras?: readonly string[]): boolean {\n const normalized = normalize(name);\n if (BUILTIN_EXACT_PATTERNS.includes(normalized)) return true;\n for (const pattern of BUILTIN_SUBSTRING_PATTERNS) {\n if (normalized.includes(pattern)) return true;\n }\n if (extras && extras.length > 0) {\n for (const extra of extras) {\n const normExtra = normalize(extra);\n if (normExtra.length === 0) continue;\n if (normalized.includes(normExtra)) return true;\n }\n }\n return false;\n}\n\n// ─── Predicate resolution ────────────────────────────────────────────────\n\n/**\n * A resolved predicate: `null` means \"don't strip anything\" (the option was\n * explicitly `false`). Otherwise a function from raw field name → boolean.\n */\nexport type ResolvedSensitivePredicate = ((name: string) => boolean) | null;\n\n/**\n * Resolve a `SensitiveFieldsOption` into a concrete predicate.\n * Precedence: per-action > global > built-in default.\n *\n * - Per-action `undefined` → fall back to global.\n * - Global `undefined` → use built-in list.\n * - Either level set to `false` → disable stripping entirely (returns `null`).\n * - `true` → built-in list.\n * - `string[]` → built-in ∪ extras.\n * - function → custom, replaces the built-in list entirely.\n */\nexport function resolveSensitivePredicate(\n perAction: SensitiveFieldsOption | undefined,\n global: SensitiveFieldsOption | undefined\n): ResolvedSensitivePredicate {\n const chosen = perAction !== undefined ? perAction : global;\n\n if (chosen === false) return null;\n if (chosen === undefined || chosen === true) {\n return (name) => isBuiltinSensitive(name);\n }\n if (typeof chosen === 'function') {\n return chosen;\n }\n // Array of extra names merged with the built-in list.\n const extras = chosen;\n return (name) => isBuiltinSensitive(name, extras);\n}\n\n// ─── Module-level global config ──────────────────────────────────────────\n\nlet globalConfig: SensitiveFieldsOption | undefined;\n\n/**\n * Set the global `forms.stripSensitiveFields` config from `timber.config.ts`.\n * Called once at startup from `rsc-entry`.\n */\nexport function setGlobalSensitiveFieldsConfig(option: SensitiveFieldsOption | undefined): void {\n globalConfig = option;\n}\n\n/** Read the global `forms.stripSensitiveFields` config. */\nexport function getGlobalSensitiveFieldsConfig(): SensitiveFieldsOption | undefined {\n return globalConfig;\n}\n\n// ─── Stripping ───────────────────────────────────────────────────────────\n\n// One warning per field name per process — prevents log spam when a form is\n// submitted many times in dev mode.\nconst warnedFields = new Set<string>();\n\nfunction warnStripped(name: string): void {\n if (!isDebug()) return;\n if (warnedFields.has(name)) return;\n warnedFields.add(name);\n console.warn(\n `[timber] stripped sensitive field \"${name}\" from submittedValues. ` +\n `Override via forms.stripSensitiveFields in timber.config.ts.`\n );\n}\n\n/**\n * Walk an object (recursively) and return a copy with every key matching\n * `predicate` removed. Nested objects like `{ user: { password: '...' } }`\n * are handled — `user.password` is stripped while other `user.*` fields remain.\n *\n * - Arrays are walked element-wise (object entries inside arrays are cleaned).\n * - Non-plain values (strings, numbers, Files, Dates, etc.) are returned as-is.\n * - When a stripped key is encountered, it is omitted from the result entirely\n * — we do NOT set it to an empty string, because that would overwrite a\n * valid `defaultValue` the form author might have set.\n */\nexport function stripSensitiveFields<T>(value: T, predicate: ResolvedSensitivePredicate): T {\n // Null predicate = stripping disabled entirely.\n if (predicate === null) return value;\n if (value === null || value === undefined) return value;\n if (typeof value !== 'object') return value;\n if (value instanceof File || value instanceof Date) return value;\n\n if (Array.isArray(value)) {\n return value.map((item) => stripSensitiveFields(item, predicate)) as unknown as T;\n }\n\n const result: Record<string, unknown> = {};\n for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {\n if (predicate(key)) {\n warnStripped(key);\n continue;\n }\n result[key] = stripSensitiveFields(nested, predicate);\n }\n return result as unknown as T;\n}\n\n// ─── Test helpers ────────────────────────────────────────────────────────\n\n/** Reset the \"warned once\" cache. Exposed for tests. */\nexport function __resetSensitiveFieldsWarnings(): void {\n warnedFields.clear();\n}\n","/**\n * createActionClient — typed middleware and schema validation for server actions.\n *\n * Inspired by next-safe-action. Provides a builder API:\n * createActionClient({ middleware }) → .schema(z.object(...)) → .action(fn)\n *\n * The resulting action function satisfies both:\n * 1. Direct call: action(input) → Promise<ActionResult>\n * 2. React useActionState: (prevState, formData) => Promise<ActionResult>\n *\n * See design/08-forms-and-actions.md §\"Middleware and Server Actions\"\n */\n\n// ─── ActionError ─────────────────────────────────────────────────────────\n\n/**\n * Typed error class for server actions. Carries a string code and optional data.\n * When thrown from middleware or the action body, the action short-circuits and\n * the client receives `result.serverError`.\n *\n * In production, unexpected errors (non-ActionError) return `{ code: 'INTERNAL_ERROR' }`\n * with no message. In dev, `data.message` is included.\n */\nexport class ActionError<TCode extends string = string> extends Error {\n readonly code: TCode;\n readonly data: Record<string, unknown> | undefined;\n\n constructor(code: TCode, data?: Record<string, unknown>) {\n super(`ActionError: ${code}`);\n this.name = 'ActionError';\n this.code = code;\n this.data = data;\n }\n}\n\n// ─── Standard Schema ──────────────────────────────────────────────────────\n\n/**\n * Standard Schema v1 interface (subset).\n * Zod ≥3.24, Valibot ≥1.0, and ArkType all implement this.\n * See https://github.com/standard-schema/standard-schema\n *\n * We use permissive types here to accept all compliant libraries without\n * requiring exact structural matches on issues/path shapes.\n */\ninterface StandardSchemaV1<Output = unknown> {\n readonly '~standard': {\n readonly validate: (\n value: unknown\n ) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;\n };\n}\n\ntype StandardSchemaResult<Output> =\n | { value: Output; issues?: undefined }\n | { value?: undefined; issues: ReadonlyArray<StandardSchemaIssue> };\n\ninterface StandardSchemaIssue {\n message: string;\n path?: ReadonlyArray<PropertyKey | { key: PropertyKey }>;\n}\n\n/** Check if a schema implements the Standard Schema protocol. */\nfunction isStandardSchema(schema: unknown): schema is StandardSchemaV1 {\n return (\n typeof schema === 'object' &&\n schema !== null &&\n '~standard' in schema &&\n typeof (schema as StandardSchemaV1)['~standard'].validate === 'function'\n );\n}\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\n/**\n * Minimal schema interface — compatible with Zod, Valibot, ArkType, etc.\n *\n * Accepts either:\n * - Standard Schema (preferred): any object with `~standard.validate()`\n * - Legacy parse interface: objects with `.parse()` / `.safeParse()`\n *\n * At runtime, Standard Schema is detected via `~standard` property and\n * takes priority over the legacy interface.\n */\nexport type ActionSchema<T = unknown> = StandardSchemaV1<T> | LegacyActionSchema<T>;\n\n/** Legacy schema interface with .parse() / .safeParse(). */\ninterface LegacyActionSchema<T = unknown> {\n 'parse'(data: unknown): T;\n 'safeParse'?(data: unknown): { success: true; data: T } | { success: false; error: SchemaError };\n // Exclude Standard Schema objects from matching this interface\n '~standard'?: never;\n}\n\n/** Schema validation error shape (for legacy .safeParse()/.parse() interface). */\nexport interface SchemaError {\n issues?: Array<{ path?: Array<string | number>; message: string }>;\n flatten?(): { fieldErrors: Record<string, string[]> };\n}\n\n/** Flattened validation errors keyed by field name. */\nexport type ValidationErrors = Record<string, string[]>;\n\n/** Middleware function: returns context to merge into the action body's ctx. */\nexport type ActionMiddleware<TCtx = Record<string, unknown>> = () => Promise<TCtx> | TCtx;\n\n/** The result type returned to the client. */\nexport type ActionResult<TData = unknown> =\n | { data: TData; validationErrors?: never; serverError?: never; submittedValues?: never }\n | {\n data?: never;\n validationErrors: ValidationErrors;\n serverError?: never;\n /** Raw input values on validation failure — for repopulating form fields. */\n submittedValues?: Record<string, unknown>;\n }\n | {\n data?: never;\n validationErrors?: never;\n serverError: { code: string; data?: Record<string, unknown> };\n submittedValues?: never;\n };\n\n/** Context passed to the action body. */\nexport interface ActionContext<TCtx, TInput> {\n ctx: TCtx;\n input: TInput;\n}\n\n// ─── Builder ─────────────────────────────────────────────────────────────\n\ninterface ActionClientConfig<TCtx> {\n middleware?: ActionMiddleware<TCtx> | ActionMiddleware<Record<string, unknown>>[];\n /** Max file size in bytes. Files exceeding this are rejected with validation errors. */\n fileSizeLimit?: number;\n /**\n * Override the sensitive-field deny-list for this action client.\n * See `SensitiveFieldsOption` in `./sensitive-fields.ts`. Per-action config\n * takes precedence over the global `forms.stripSensitiveFields` option in\n * `timber.config.ts`. See design/08-forms-and-actions.md and TIM-816.\n */\n stripSensitiveFields?: SensitiveFieldsOption;\n}\n\n/** Intermediate builder returned by createActionClient(). */\nexport interface ActionBuilder<TCtx> {\n /** Declare the input schema. Validation errors are returned typed. */\n schema<TInput>(schema: ActionSchema<TInput>): ActionBuilderWithSchema<TCtx, TInput>;\n /** Define the action body without input validation. */\n action<TData>(\n fn: (ctx: ActionContext<TCtx, undefined>) => Promise<TData>\n ): ActionFn<TData, undefined>;\n}\n\n/** Builder after .schema() has been called. */\nexport interface ActionBuilderWithSchema<TCtx, TInput> {\n /** Define the action body with validated input. */\n action<TData>(fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>): ActionFn<TData, TInput>;\n}\n\n/**\n * The final action function. Callable three ways:\n * - Direct: action(input) → Promise<ActionResult<TData>>\n * - React useActionState: action(prevState, formData) → Promise<ActionResult<TData>>\n * - React <form action={fn}>: action(formData) → void (return value ignored by React)\n *\n * The third overload exists purely for type compatibility with React's\n * `<form action>` prop, which expects `(formData: FormData) => void`.\n * At runtime the function still returns Promise<ActionResult>, but React\n * discards it. This lets validated actions be passed directly to forms\n * without casts.\n */\n/**\n * Map schema output keys to `string | undefined` for form-facing APIs.\n * HTML form values are always strings, and fields can be absent.\n * Gives autocomplete for field names without lying about value types.\n */\nexport type InputHint<T> =\n T extends Record<string, unknown> ? { [K in keyof T]: string | undefined } : T;\n\n/**\n * ActionFn — the callable returned by `createActionClient().action()`.\n *\n * Generic order: `<TData, TInput>` — TData first for backward compatibility.\n * Previously ActionFn had a single `<TData>` generic, so existing code like\n * `ActionFn<MyResult>` must still work with TData in the first position.\n * See TIM-797.\n */\nexport type ActionFn<TData = unknown, TInput = unknown> = {\n /** <form action={fn}> compatibility — React discards the return value. */\n (formData: FormData): void;\n /** Direct call: action(input) — optional when TInput is undefined/unknown (no-schema actions). */\n (\n ...args: undefined extends TInput ? [input?: TInput] : [input: TInput]\n ): Promise<ActionResult<TData>>;\n /** React useActionState: action(prevState, formData) */\n (prevState: ActionResult<TData> | null, formData: FormData): Promise<ActionResult<TData>>;\n};\n\n// ─── Implementation ──────────────────────────────────────────────────────\n\n/**\n * Run middleware array or single function. Returns merged context.\n */\nasync function runActionMiddleware<TCtx>(\n middleware: ActionMiddleware<TCtx> | ActionMiddleware<Record<string, unknown>>[] | undefined\n): Promise<TCtx> {\n if (!middleware) {\n return {} as TCtx;\n }\n\n if (Array.isArray(middleware)) {\n let merged = {} as Record<string, unknown>;\n for (const mw of middleware) {\n const result = await mw();\n merged = { ...merged, ...result };\n }\n return merged as TCtx;\n }\n\n return await middleware();\n}\n\n// Re-export parseFormData for use throughout the framework\nimport { parseFormData } from './form-data.ts';\nimport { formatSize } from '../utils/format.ts';\nimport { isDebug, isDevMode } from './debug.ts';\nimport { isDenySignal, isRedirectSignal } from './primitives.ts';\nimport {\n stripSensitiveFields,\n resolveSensitivePredicate,\n getGlobalSensitiveFieldsConfig,\n type SensitiveFieldsOption,\n} from './sensitive-fields.ts';\n\n/**\n * Extract validation errors from a schema error.\n * Supports Zod's flatten() and generic issues array.\n */\nfunction extractValidationErrors(error: SchemaError): ValidationErrors {\n // Zod-style flatten\n if (typeof error.flatten === 'function') {\n return error.flatten().fieldErrors;\n }\n\n // Generic issues array\n if (error.issues) {\n const errors: ValidationErrors = {};\n for (const issue of error.issues) {\n const path = issue.path?.join('.') ?? '_root';\n if (!errors[path]) errors[path] = [];\n errors[path].push(issue.message);\n }\n return errors;\n }\n\n return { _root: ['Validation failed'] };\n}\n\n/**\n * Extract validation errors from Standard Schema issues.\n */\nfunction extractStandardSchemaErrors(issues: ReadonlyArray<StandardSchemaIssue>): ValidationErrors {\n const errors: ValidationErrors = {};\n for (const issue of issues) {\n const path =\n issue.path\n ?.map((p) => {\n // Standard Schema path items can be { key: ... } objects or bare PropertyKey values\n if (typeof p === 'object' && p !== null && 'key' in p) return String(p.key);\n return String(p);\n })\n .join('.') ?? '_root';\n if (!errors[path]) errors[path] = [];\n errors[path].push(issue.message);\n }\n return Object.keys(errors).length > 0 ? errors : { _root: ['Validation failed'] };\n}\n\n/**\n * Wrap unexpected errors into a safe server error result.\n * ActionError → typed result. Other errors → INTERNAL_ERROR (no leak).\n *\n * Exported for use by action-handler.ts to catch errors from raw 'use server'\n * functions that don't use createActionClient.\n */\nexport function handleActionError(error: unknown): ActionResult<never> {\n if (error instanceof ActionError) {\n return {\n serverError: {\n code: error.code,\n ...(error.data ? { data: error.data } : {}),\n },\n };\n }\n\n // In dev, include the message for debugging.\n // Uses isDevMode() — NOT isDebug() — because this data is sent to the\n // browser. TIMBER_DEBUG must never cause error messages to leak to clients.\n // See design/13-security.md principle 4: \"Errors don't leak.\"\n const devMode = isDevMode();\n return {\n serverError: {\n code: 'INTERNAL_ERROR',\n ...(devMode && error instanceof Error ? { data: { message: error.message } } : {}),\n },\n };\n}\n\n/**\n * Create a typed action client with middleware and schema validation.\n *\n * @example\n * ```ts\n * const action = createActionClient({\n * middleware: async () => {\n * const user = await getUser()\n * if (!user) throw new ActionError('UNAUTHORIZED')\n * return { user }\n * },\n * })\n *\n * export const createTodo = action\n * .schema(z.object({ title: z.string().min(1) }))\n * .action(async ({ input, ctx }) => {\n * await db.todos.create({ ...input, userId: ctx.user.id })\n * })\n * ```\n */\nexport function createActionClient<TCtx = Record<string, never>>(\n config: ActionClientConfig<TCtx> = {}\n): ActionBuilder<TCtx> {\n function buildAction<TInput, TData>(\n schema: ActionSchema<TInput> | undefined,\n fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>\n ): ActionFn<TData, TInput> {\n async function actionHandler(...args: unknown[]): Promise<ActionResult<TData>> {\n try {\n // Run middleware\n const ctx = await runActionMiddleware(config.middleware);\n\n // Determine input — either FormData (from useActionState) or direct arg\n let rawInput: unknown;\n if (args.length === 2 && args[1] instanceof FormData) {\n // Called as (prevState, formData) by React useActionState (with-JS path)\n rawInput = schema ? parseFormData(args[1]) : args[1];\n } else if (args.length === 1 && args[0] instanceof FormData) {\n // No-JS path: React's decodeAction binds FormData as the sole argument.\n // The form POSTs without JavaScript, decodeAction resolves the server\n // reference and binds the FormData, then executeAction calls fn() with\n // no additional args — so the bound FormData arrives as args[0].\n rawInput = schema ? parseFormData(args[0]) : args[0];\n } else {\n // Direct call: action(input)\n rawInput = args[0];\n }\n\n // Resolve the sensitive-field stripping predicate once per invocation.\n // Precedence: per-action (config.stripSensitiveFields) > global\n // (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.\n // See TIM-816.\n const sensitivePredicate = resolveSensitivePredicate(\n config.stripSensitiveFields,\n getGlobalSensitiveFieldsConfig()\n );\n\n // Capture a \"safe-to-echo\" snapshot of the raw input once. Files are\n // stripped (can't serialize, shouldn't echo back) and sensitive fields\n // (passwords, tokens, CVV, etc.) are removed before they would land\n // in the RSC payload → client form `defaultValue` → DOM.\n const buildSubmittedValues = (): Record<string, unknown> | undefined => {\n const withoutFiles = stripFiles(rawInput);\n if (withoutFiles === undefined) return undefined;\n return stripSensitiveFields(withoutFiles, sensitivePredicate);\n };\n\n // Validate file sizes before schema validation.\n if (config.fileSizeLimit !== undefined && rawInput && typeof rawInput === 'object') {\n const fileSizeErrors = validateFileSizes(\n rawInput as Record<string, unknown>,\n config.fileSizeLimit\n );\n if (fileSizeErrors) {\n return { validationErrors: fileSizeErrors, submittedValues: buildSubmittedValues() };\n }\n }\n\n // Capture submitted values for repopulation on validation failure.\n const submittedValues = schema ? buildSubmittedValues() : undefined;\n\n // Validate with schema if provided\n let input: TInput;\n if (schema) {\n if (isStandardSchema(schema)) {\n // Standard Schema protocol (Zod ≥3.24, Valibot ≥1.0, ArkType)\n const result = schema['~standard'].validate(rawInput);\n if (result instanceof Promise) {\n throw new Error(\n '[timber] createActionClient: schema returned a Promise — only sync schemas are supported.'\n );\n }\n if (result.issues) {\n const validationErrors = extractStandardSchemaErrors(result.issues);\n logValidationFailure(validationErrors);\n return { validationErrors, submittedValues };\n }\n input = result.value;\n } else if (typeof schema.safeParse === 'function') {\n const result = schema.safeParse(rawInput);\n if (!result.success) {\n const validationErrors = extractValidationErrors(result.error);\n logValidationFailure(validationErrors);\n return { validationErrors, submittedValues };\n }\n input = result.data;\n } else {\n try {\n input = schema.parse(rawInput);\n } catch (parseError) {\n const validationErrors = extractValidationErrors(parseError as SchemaError);\n logValidationFailure(validationErrors);\n return { validationErrors, submittedValues };\n }\n }\n } else {\n input = rawInput as TInput;\n }\n\n // Execute the action body\n const data = await fn({ ctx, input });\n return { data };\n } catch (error) {\n // Re-throw redirect/deny signals — these are control flow, not errors.\n // They must propagate to executeAction() which converts them to proper\n // HTTP responses (302 redirect, 4xx deny). Catching them here would\n // wrap them as INTERNAL_ERROR and break redirect()/redirectExternal()/deny().\n if (isRedirectSignal(error) || isDenySignal(error)) {\n throw error;\n }\n return handleActionError(error);\n }\n }\n\n return actionHandler as ActionFn<TData, TInput>;\n }\n\n return {\n schema<TInput>(schema: ActionSchema<TInput>) {\n return {\n action<TData>(\n fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>\n ): ActionFn<TData, TInput> {\n return buildAction(schema, fn);\n },\n };\n },\n action<TData>(\n fn: (ctx: ActionContext<TCtx, undefined>) => Promise<TData>\n ): ActionFn<TData, undefined> {\n return buildAction(undefined, fn as (ctx: ActionContext<TCtx, unknown>) => Promise<TData>);\n },\n };\n}\n\n// ─── validated() ────────────────────────────────────────────────────────\n\n/**\n * Convenience wrapper for the common case: validate input, run handler.\n * No middleware needed.\n *\n * @example\n * ```ts\n * 'use server'\n * import { validated } from '@timber-js/app/server'\n * import { z } from 'zod'\n *\n * export const createTodo = validated(\n * z.object({ title: z.string().min(1) }),\n * async (input) => {\n * await db.todos.create(input)\n * }\n * )\n * ```\n */\nexport function validated<TInput, TData>(\n schema: ActionSchema<TInput>,\n handler: (input: TInput) => Promise<TData>\n): ActionFn<TData, TInput> {\n return createActionClient()\n .schema(schema)\n .action(async ({ input }) => handler(input));\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\n/**\n * Log validation failures in dev mode so developers can see what went wrong.\n * In production, validation errors are only returned to the client.\n */\nfunction logValidationFailure(errors: ValidationErrors): void {\n const isDev = isDebug();\n if (!isDev) return;\n\n const fields = Object.entries(errors)\n .map(([field, messages]) => ` ${field}: ${messages.join(', ')}`)\n .join('\\n');\n console.warn(`[timber] action schema validation failed:\\n${fields}`);\n}\n\n/**\n * Validate that all File objects in the input are within the size limit.\n * Returns validation errors keyed by field name, or null if all files are ok.\n */\nfunction validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {\n const limitKb = Math.round(limit / 1024);\n const limitLabel =\n limit >= 1024 * 1024 ? `${Math.round(limit / (1024 * 1024))}MB` : `${limitKb}KB`;\n\n const errors: ValidationErrors = {};\n\n function walk(obj: Record<string, unknown>, prefix: string): void {\n for (const [key, value] of Object.entries(obj)) {\n const path = prefix ? `${prefix}.${key}` : key;\n if (value instanceof File && value.size > limit) {\n errors[path] = [\n `File \"${value.name}\" (${formatSize(value.size)}) exceeds the ${limitLabel} limit`,\n ];\n } else if (Array.isArray(value)) {\n for (let i = 0; i < value.length; i++) {\n const item = value[i];\n const itemPath = `${path}[${i}]`;\n if (item instanceof File && item.size > limit) {\n (errors[itemPath] ??= []).push(\n `File \"${item.name}\" (${formatSize(item.size)}) exceeds the ${limitLabel} limit`\n );\n } else if (typeof item === 'object' && item !== null && !(item instanceof File)) {\n walk(item as Record<string, unknown>, itemPath);\n }\n }\n } else if (typeof value === 'object' && value !== null && !(value instanceof File)) {\n walk(value as Record<string, unknown>, path);\n }\n }\n }\n\n walk(input, '');\n return Object.keys(errors).length > 0 ? errors : null;\n}\n\n/**\n * Strip File objects from a value, returning a plain object safe for\n * serialization. File objects can't be serialized and shouldn't be echoed back.\n */\nfunction stripFiles(value: unknown): Record<string, unknown> | undefined {\n if (value === null || value === undefined) return undefined;\n if (typeof value !== 'object') return undefined;\n\n const result: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n if (v instanceof File) continue;\n if (Array.isArray(v)) {\n result[k] = v\n .filter((item) => !(item instanceof File))\n .map((item) =>\n typeof item === 'object' && item !== null && !(item instanceof File)\n ? (stripFiles(item) ?? {})\n : item\n );\n } else if (typeof v === 'object' && v !== null && !(v instanceof File)) {\n result[k] = stripFiles(v) ?? {};\n } else {\n result[k] = v;\n }\n }\n return result;\n}\n","/**\n * Form Flash — ALS-based store for no-JS form action results.\n *\n * When a no-JS form action completes, the server re-renders the page with\n * the action result injected via AsyncLocalStorage instead of redirecting\n * (which would discard the result). Server components read the flash and\n * pass it to client form components as the initial `useActionState` value.\n *\n * This follows the Remix/Rails pattern — the form component becomes the\n * single source of truth for both with-JS (React state) and no-JS (flash).\n *\n * The flash data is server-side only — never serialized to cookies or headers.\n *\n * See design/08-forms-and-actions.md §\"No-JS Error Round-Trip\"\n */\n\nimport type { ValidationErrors } from './action-client.ts';\nimport { formFlashAls } from './als-registry.ts';\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\n/**\n * Flash data injected into the re-render after a no-JS form submission.\n *\n * This is the action result from the server action, stored in ALS so server\n * components can read it and pass it to client form components as the initial\n * state for `useActionState`. This makes the form component a single source\n * of truth for both with-JS and no-JS paths.\n *\n * The shape matches `ActionResult<unknown>` — it's one of:\n * - `{ data: ... }` — success\n * - `{ validationErrors, submittedValues }` — validation failure\n * - `{ serverError }` — server error\n */\nexport interface FormFlashData {\n /** Success data from the action. */\n data?: unknown;\n /** Validation errors keyed by field name. `_root` for form-level errors. */\n validationErrors?: ValidationErrors;\n /** Raw submitted values for repopulating form fields. File objects are excluded. */\n submittedValues?: Record<string, unknown>;\n /** Server error if the action threw an ActionError. */\n serverError?: { code: string; data?: Record<string, unknown> };\n}\n\n// ─── Public API ──────────────────────────────────────────────────────────\n\n/**\n * Read the form flash data for the current request.\n *\n * Returns `null` if no flash data is present (i.e., this is a normal page\n * render, not a re-render after a no-JS form submission).\n *\n * Pass the flash as the initial state to `useActionState` so the form\n * component has a single source of truth for both with-JS and no-JS paths:\n *\n * ```tsx\n * // app/contact/page.tsx (server component)\n * import { getFormFlash } from '@timber-js/app/server'\n *\n * export default function ContactPage() {\n * const flash = getFormFlash()\n * return <ContactForm flash={flash} />\n * }\n *\n * // app/contact/form.tsx (client component)\n * export function ContactForm({ flash }) {\n * const [result, action, isPending] = useActionState(submitContact, flash)\n * // result is the single source of truth — flash seeds it on no-JS\n * }\n * ```\n */\nexport function getFormFlash(): FormFlashData | null {\n return formFlashAls.getStore() ?? null;\n}\n\n// ─── Framework-Internal ──────────────────────────────────────────────────\n\n/**\n * Run a callback with form flash data in scope.\n *\n * Used by the action handler to re-render the page with validation errors\n * available via `getFormFlash()`. Not part of the public API.\n *\n * @internal\n */\nexport function runWithFormFlash<T>(data: FormFlashData, fn: () => T): T {\n return formFlashAls.run(data, fn);\n}\n"],"mappings":"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"}
|
package/dist/server/internal.js
CHANGED
|
@@ -13,7 +13,7 @@ import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPay
|
|
|
13
13
|
import { n as createSingleflight } from "../_chunks/singleflight-2lUWfcAk.js";
|
|
14
14
|
import { n as toBracketKey } from "../_chunks/resolve-schema-CBR6Lm4i.js";
|
|
15
15
|
import { t as normalizeParamValue } from "../_chunks/param-value-C8TNYchQ.js";
|
|
16
|
-
import { s as TIMBER_ERROR_HEADER, t as StatusPageMarker } from "../_chunks/status-page-marker-
|
|
16
|
+
import { s as TIMBER_ERROR_HEADER, t as StatusPageMarker } from "../_chunks/status-page-marker-CNbpUOPB.js";
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
18
|
import { readFile } from "node:fs/promises";
|
|
19
19
|
import React, { createElement, useContext } from "react";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.208",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -165,8 +165,8 @@
|
|
|
165
165
|
"@vitejs/plugin-react": "^6.1.0",
|
|
166
166
|
"@vitejs/plugin-rsc": ">=0.5.35",
|
|
167
167
|
"nuqs": "^2.0.0",
|
|
168
|
-
"react": "19.3.0",
|
|
169
|
-
"react-dom": "19.3.0",
|
|
168
|
+
"react": "^19.3.0",
|
|
169
|
+
"react-dom": "^19.3.0",
|
|
170
170
|
"satteri": "^0.9.5",
|
|
171
171
|
"vite": "8.3.0",
|
|
172
172
|
"vite-plugin-satteri": "^0.2.15",
|
|
@@ -169,7 +169,10 @@ export function setupServerActions(): void {
|
|
|
169
169
|
.then(() => router.applyActionResult(epoch, wrapper._tree))
|
|
170
170
|
.then(
|
|
171
171
|
(applied) => {
|
|
172
|
-
if (!applied)
|
|
172
|
+
if (!applied) {
|
|
173
|
+
router.evictStaleCaches();
|
|
174
|
+
queue.markNeedsRefresh();
|
|
175
|
+
}
|
|
173
176
|
},
|
|
174
177
|
(e: unknown) => {
|
|
175
178
|
console.error('[timber] applyActionResult failed after server action', e);
|
|
@@ -194,7 +197,10 @@ export function setupServerActions(): void {
|
|
|
194
197
|
hold(
|
|
195
198
|
router.applyActionResult(epoch).then(
|
|
196
199
|
(applied) => {
|
|
197
|
-
if (!applied)
|
|
200
|
+
if (!applied) {
|
|
201
|
+
router.evictStaleCaches();
|
|
202
|
+
queue.markNeedsRefresh();
|
|
203
|
+
}
|
|
198
204
|
},
|
|
199
205
|
(e: unknown) => {
|
|
200
206
|
console.debug('[timber] action refresh failed', e instanceof Error ? e.message : e);
|
package/src/client/history.ts
CHANGED
|
@@ -91,6 +91,23 @@ export class HistoryStack {
|
|
|
91
91
|
return this.entries.delete(url);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Delete all entries whose pathname matches `pathname` (TIM-1465).
|
|
96
|
+
* Used by `invalidatePath` when the invalidation target has no search
|
|
97
|
+
* string — `/products` should evict `/products?page=1` too, because
|
|
98
|
+
* `revalidatePath('/products')` invalidates the route regardless of
|
|
99
|
+
* query.
|
|
100
|
+
*/
|
|
101
|
+
deleteByPathname(pathname: string): void {
|
|
102
|
+
for (const url of this.entries.keys()) {
|
|
103
|
+
const qIndex = url.indexOf('?');
|
|
104
|
+
const urlPathname = qIndex === -1 ? url : url.slice(0, qIndex);
|
|
105
|
+
if (urlPathname === pathname) {
|
|
106
|
+
this.entries.delete(url);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
/**
|
|
95
112
|
* Evict all cached payloads (TIM-1476). Called after a server action
|
|
96
113
|
* that revalidated data — history entries are equally stale since they
|
|
@@ -154,6 +154,10 @@ export interface NavigationCommitter {
|
|
|
154
154
|
prepareNavigation(url: string, opts: NavigationCommitInput): PreparedNavigation;
|
|
155
155
|
currentStateTree(): StateTree | undefined;
|
|
156
156
|
updateSegmentCache(segmentInfo: SegmentInfo[] | null | undefined): void;
|
|
157
|
+
/** Whether segment reuse is currently suppressed (TIM-1466). */
|
|
158
|
+
isSegmentReuseSuppressed(): boolean;
|
|
159
|
+
/** Lift segment reuse suppression after fresh data has been fetched (TIM-1466). */
|
|
160
|
+
restoreSegmentReuse(): void;
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
/**
|
|
@@ -205,6 +209,8 @@ export function createNavigationCommitter(deps: {
|
|
|
205
209
|
|
|
206
210
|
return {
|
|
207
211
|
updateSegmentCache,
|
|
212
|
+
isSegmentReuseSuppressed: () => segmentCache.isReuseSuppressed(),
|
|
213
|
+
restoreSegmentReuse: () => segmentCache.restoreReuse(),
|
|
208
214
|
|
|
209
215
|
/**
|
|
210
216
|
* The X-Timber-State-Tree to send with the next RSC request.
|
|
@@ -60,7 +60,7 @@ export interface NavigationFetchOptions {
|
|
|
60
60
|
|
|
61
61
|
export interface NavigationPipelineDeps extends Pick<
|
|
62
62
|
NavigationCommitter,
|
|
63
|
-
'prepareNavigation' | 'currentStateTree'
|
|
63
|
+
'prepareNavigation' | 'currentStateTree' | 'isSegmentReuseSuppressed' | 'restoreSegmentReuse'
|
|
64
64
|
> {
|
|
65
65
|
deps: RouterDeps;
|
|
66
66
|
prefetchCache: PrefetchCache;
|
|
@@ -174,6 +174,8 @@ export function createNavigationPipeline({
|
|
|
174
174
|
prefetchCache,
|
|
175
175
|
currentStateTree,
|
|
176
176
|
prepareNavigation,
|
|
177
|
+
isSegmentReuseSuppressed,
|
|
178
|
+
restoreSegmentReuse,
|
|
177
179
|
markHandedOff,
|
|
178
180
|
forgetOlderHandoffs,
|
|
179
181
|
}: NavigationPipelineDeps): NavigationPipeline {
|
|
@@ -339,6 +341,11 @@ export function createNavigationPipeline({
|
|
|
339
341
|
// `currentUrl` is read back off the key rather than computed beside it, so
|
|
340
342
|
// the pathname sent as X-Timber-URL is the one the lookup used.
|
|
341
343
|
const stateTree = currentStateTree();
|
|
344
|
+
// Capture whether reuse is suppressed at the time we build the state
|
|
345
|
+
// tree. If so, segments/slots are empty and the server will send a
|
|
346
|
+
// full render. We restore reuse after the response is processed so
|
|
347
|
+
// only navigations that fetched with suppression can lift it (TIM-1466).
|
|
348
|
+
let reuseSuppressedAtFetch = isSegmentReuseSuppressed();
|
|
342
349
|
const cacheKey = prefetchKeyFor(url, options.departingUrl ?? deps.getCurrentUrl(), stateTree);
|
|
343
350
|
const currentUrl = cacheKey.from;
|
|
344
351
|
|
|
@@ -381,11 +388,16 @@ export function createNavigationPipeline({
|
|
|
381
388
|
// Capture the eviction generation before awaiting the flight.
|
|
382
389
|
// If an action evicts caches while we wait, the result predates
|
|
383
390
|
// the mutation and must be discarded (TIM-1476, codex on #1131).
|
|
391
|
+
// Also check the flight's own starting gen: if the flight started
|
|
392
|
+
// before a per-URL invalidation (TIM-1464), a joiner that arrived
|
|
393
|
+
// after the bump would see genBefore === genAfter but the flight
|
|
394
|
+
// still predates the invalidation.
|
|
384
395
|
const genBefore = prefetchCache.getEvictionGen();
|
|
385
396
|
try {
|
|
386
397
|
const outcome = await raceAbort(inflight, options.signal);
|
|
387
|
-
if (prefetchCache.getEvictionGen() !== genBefore) {
|
|
388
|
-
// Eviction happened while we waited
|
|
398
|
+
if (prefetchCache.getEvictionGen() !== genBefore || outcome.startGen !== genBefore) {
|
|
399
|
+
// Eviction happened while we waited, or the flight predates
|
|
400
|
+
// an invalidation we arrived after — discard stale result,
|
|
389
401
|
// fall through to a fresh fetch below.
|
|
390
402
|
} else if (outcome.kind === 'non-route') {
|
|
391
403
|
throw new NonRscResponse(url);
|
|
@@ -422,7 +434,13 @@ export function createNavigationPipeline({
|
|
|
422
434
|
// No in-flight hover prefetch, or the joined flight failed — fetch
|
|
423
435
|
// directly with the navigation's signal so superseded navigations
|
|
424
436
|
// abort immediately.
|
|
425
|
-
|
|
437
|
+
// Resample both the state tree and the suppression flag: if we fell
|
|
438
|
+
// through because an invalidation bumped the eviction gen,
|
|
439
|
+
// invalidateReuse() has suppressed segment/slot reuse since our
|
|
440
|
+
// original capture (TIM-1466, codex on #1167).
|
|
441
|
+
reuseSuppressedAtFetch = isSegmentReuseSuppressed();
|
|
442
|
+
const freshStateTree = currentStateTree();
|
|
443
|
+
result = await fetchRscPayload(url, deps, freshStateTree, currentUrl, options.signal);
|
|
426
444
|
}
|
|
427
445
|
|
|
428
446
|
// Update the browser history — skip when the Navigation API has already
|
|
@@ -467,6 +485,15 @@ export function createNavigationPipeline({
|
|
|
467
485
|
skippedSegments: result.skippedSegments,
|
|
468
486
|
});
|
|
469
487
|
|
|
488
|
+
// This navigation's fetch used the suppressed state tree (empty
|
|
489
|
+
// segments/slots), so the server sent a full render. The response
|
|
490
|
+
// data is post-invalidation — safe to restore reuse for subsequent
|
|
491
|
+
// navigations. Must happen here (at response time, before the commit
|
|
492
|
+
// thunk), not in set(), because a pre-invalidation navigation's
|
|
493
|
+
// commit thunk also calls set() and would lift suppression too early
|
|
494
|
+
// (codex on #1167).
|
|
495
|
+
if (reuseSuppressedAtFetch) restoreSegmentReuse();
|
|
496
|
+
|
|
470
497
|
return {
|
|
471
498
|
...result,
|
|
472
499
|
payload,
|
package/src/client/router.ts
CHANGED
|
@@ -39,7 +39,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
39
39
|
|
|
40
40
|
// Every write that makes a page "current", and the read that reports the
|
|
41
41
|
// current page back to the server. See navigation-commit.ts.
|
|
42
|
-
const {
|
|
42
|
+
const {
|
|
43
|
+
prepareNavigation,
|
|
44
|
+
currentStateTree,
|
|
45
|
+
updateSegmentCache,
|
|
46
|
+
isSegmentReuseSuppressed,
|
|
47
|
+
restoreSegmentReuse,
|
|
48
|
+
} = createNavigationCommitter({
|
|
43
49
|
segmentCache,
|
|
44
50
|
historyStack,
|
|
45
51
|
clientSegmentCache: () => deps.clientSegmentCache,
|
|
@@ -66,6 +72,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
66
72
|
prefetchCache,
|
|
67
73
|
currentStateTree,
|
|
68
74
|
prepareNavigation,
|
|
75
|
+
isSegmentReuseSuppressed,
|
|
76
|
+
restoreSegmentReuse,
|
|
69
77
|
markHandedOff,
|
|
70
78
|
forgetOlderHandoffs,
|
|
71
79
|
});
|
|
@@ -492,8 +500,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
492
500
|
runWhenIdle: (task: () => void) => lifecycle.runWhenIdle(task),
|
|
493
501
|
settleHandoffs: () => lifecycle.settleHandoffs(),
|
|
494
502
|
invalidatePath(path: string): void {
|
|
495
|
-
|
|
503
|
+
const hasSearch = path.includes('?');
|
|
504
|
+
if (hasSearch) {
|
|
505
|
+
historyStack.delete(path);
|
|
506
|
+
} else {
|
|
507
|
+
// TIM-1465: evict all query variants sharing this pathname
|
|
508
|
+
historyStack.deleteByPathname(path);
|
|
509
|
+
}
|
|
496
510
|
prefetchCache.invalidateUrl(path);
|
|
511
|
+
// TIM-1466: suppress segment/slot reuse so the next navigation
|
|
512
|
+
// gets a full render instead of reusing stale shared layouts.
|
|
513
|
+
// Uses invalidateReuse() instead of clear() to preserve treePaths
|
|
514
|
+
// for interception scoping — a modal mounted on the current tree
|
|
515
|
+
// must still be navigable (codex on #1167).
|
|
516
|
+
segmentCache.invalidateReuse();
|
|
497
517
|
},
|
|
498
518
|
evictStaleCaches(): void {
|
|
499
519
|
prefetchCache.clearReady();
|