@timber-js/app 0.2.0-alpha.194 → 0.2.0-alpha.195

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.
Files changed (34) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{cli-schema-sync-ZwM9u_ob.js → cli-schema-sync-3Wutm8pH.js} +2 -2
  3. package/dist/_chunks/{cli-schema-sync-ZwM9u_ob.js.map → cli-schema-sync-3Wutm8pH.js.map} +1 -1
  4. package/dist/_chunks/{error-boundary-D-ODYX41.js → error-boundary-D-lkwyaD.js} +3 -3
  5. package/dist/_chunks/{error-boundary-D-ODYX41.js.map → error-boundary-D-lkwyaD.js.map} +1 -1
  6. package/dist/_chunks/{router-ref-DuYuV_0Q.js → router-ref-BzqbPwYC.js} +2 -2
  7. package/dist/_chunks/{router-ref-DuYuV_0Q.js.map → router-ref-BzqbPwYC.js.map} +1 -1
  8. package/dist/_chunks/{ssr-data-14MXm7Pj.js → ssr-data-Ya2HJPFp.js} +1 -7
  9. package/dist/_chunks/{ssr-data-14MXm7Pj.js.map → ssr-data-Ya2HJPFp.js.map} +1 -1
  10. package/dist/_chunks/{use-segment-params-C4r4BD9T.js → use-segment-params-DzTBpkvj.js} +3 -3
  11. package/dist/_chunks/{use-segment-params-C4r4BD9T.js.map → use-segment-params-DzTBpkvj.js.map} +1 -1
  12. package/dist/_chunks/{walkers-uCu3WW6_.js → walkers-BU6z9xRV.js} +2 -2
  13. package/dist/_chunks/{walkers-uCu3WW6_.js.map → walkers-BU6z9xRV.js.map} +1 -1
  14. package/dist/cli.js +1 -1
  15. package/dist/client/error-boundary.js +1 -1
  16. package/dist/client/index.js +3 -3
  17. package/dist/client/internal.js +4 -4
  18. package/dist/config-types.d.ts +12 -9
  19. package/dist/config-types.d.ts.map +1 -1
  20. package/dist/cookies/index.js +1 -1
  21. package/dist/index.d.ts +0 -15
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +79 -35
  24. package/dist/index.js.map +1 -1
  25. package/dist/routing/index.js +1 -1
  26. package/dist/server/index.js +1 -6
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/server/internal.js +1 -1
  29. package/docs/api/34-api-config.mdx +7 -4
  30. package/docs/learn/13-configuration.mdx +1 -1
  31. package/package.json +12 -12
  32. package/src/cli.ts +0 -0
  33. package/src/config-types.ts +12 -9
  34. package/src/index.ts +44 -58
@@ -1,5 +1,5 @@
1
1
  import { n as scanRoutes, r as collectInterceptionRewrites } from "../_chunks/scanner-BRIOmHE2.js";
2
2
  import { a as INTERCEPTION_MARKERS, i as DEFAULT_PAGE_EXTENSIONS, n as classifyUrlSegment, t as classifySegment } from "../_chunks/segment-classify-C539Pa2O.js";
3
3
  import { n as generateRouteMap } from "../_chunks/file-cache-Dw6BJPG7.js";
4
- import { n as collectDynamicSegmentsFromTree, r as validateSchemaAgainstRoutes, t as collectLeafRoutes } from "../_chunks/walkers-uCu3WW6_.js";
4
+ import { n as collectDynamicSegmentsFromTree, r as validateSchemaAgainstRoutes, t as collectLeafRoutes } from "../_chunks/walkers-BU6z9xRV.js";
5
5
  export { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS, classifySegment, classifyUrlSegment, collectDynamicSegmentsFromTree, collectInterceptionRewrites, collectLeafRoutes, generateRouteMap, scanRoutes, validateSchemaAgainstRoutes };
@@ -129,11 +129,6 @@ function resolveSensitivePredicate(perAction, global) {
129
129
  const extras = chosen;
130
130
  return (name) => isBuiltinSensitive(name, extras);
131
131
  }
132
- var globalConfig;
133
- /** Read the global `forms.stripSensitiveFields` config. */
134
- function getGlobalSensitiveFieldsConfig() {
135
- return globalConfig;
136
- }
137
132
  var warnedFields = /* @__PURE__ */ new Set();
138
133
  function warnStripped(name) {
139
134
  if (!isDebug()) return;
@@ -300,7 +295,7 @@ function createActionClient(config = {}) {
300
295
  if (args.length === 2 && args[1] instanceof FormData) rawInput = schema ? parseFormData(args[1]) : args[1];
301
296
  else if (args.length === 1 && args[0] instanceof FormData) rawInput = schema ? parseFormData(args[0]) : args[0];
302
297
  else rawInput = args[0];
303
- const sensitivePredicate = resolveSensitivePredicate(config.stripSensitiveFields, getGlobalSensitiveFieldsConfig());
298
+ const sensitivePredicate = resolveSensitivePredicate(config.stripSensitiveFields, void 0);
304
299
  const buildSubmittedValues = () => {
305
300
  const withoutFiles = stripFiles(rawInput);
306
301
  if (withoutFiles === void 0) return void 0;
@@ -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 { RedirectSignal, DenySignal } 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 (error instanceof RedirectSignal || error instanceof DenySignal) {\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 '~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 { RedirectSignal, DenySignal } 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 (error instanceof RedirectSignal || error instanceof DenySignal) {\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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"}
@@ -8,7 +8,7 @@ import { i as isRscCacheKeyShape, n as RSC_KEY_PARAM, s as rscCacheKey, t as RSC
8
8
  import { i as collectRouteModulepreloads, n as collectRouteCss, r as collectRouteFonts } from "../_chunks/build-manifest-DWppEdLB.js";
9
9
  import { i as coerce, t as executeAction } from "../_chunks/actions-CWYtq6ii.js";
10
10
  import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPayload, d as checkVersionSkew, i as storeOverlayEntry, l as setPrebuiltPayloadSource, n as lookupOverlay, o as hasPrebuiltPayloadSource, r as overlayKey, s as isParamIndependent, u as applyReloadHeaders, v as currentInvalidationEpoch, x as createSingleflight, y as lastInvalidationEpochFor } from "../_chunks/cache-api-CQeYzA5g.js";
11
- import "../_chunks/error-boundary-D-ODYX41.js";
11
+ import "../_chunks/error-boundary-D-lkwyaD.js";
12
12
  import { n as toBracketKey } from "../_chunks/resolve-schema-CBR6Lm4i.js";
13
13
  import { r as normalizeParamValue } from "../_chunks/segment-context-CjOlyB8Y.js";
14
14
  import { n as rscErrorEnvelope } from "../_chunks/rsc-error-envelope-tT5PJs4q.js";
@@ -252,17 +252,20 @@ Disable encryption in dev mode for easier debugging of bound args. Has no effect
252
252
  ### `reactCompiler`
253
253
 
254
254
  - **Type:** `boolean | { compilationMode?: string; target?: string }`
255
- - **Default:** `false`
255
+ - **Default:** `true`
256
+
257
+ Enable the React Compiler for automatic memoization of components and hooks at build time. Uses `@vitejs/plugin-react`'s native OXC-based compiler path — JSX and compilation run in a single Rust pass with no Babel overhead.
256
258
 
257
- Enable the React Compiler (`babel-plugin-react-compiler`) for automatic memoization of components and hooks at build time.
259
+ Enabled by default. Set to `false` to disable. Enabling the compiler disables React Fast Refresh — HMR still works but component state is not preserved across edits.
258
260
 
259
261
  ```ts
260
- reactCompiler: true // enable with defaults
262
+ reactCompiler: true // enable with defaults (the default)
261
263
  reactCompiler: { compilationMode: 'annotation' } // only compile 'use memo' files
262
264
  reactCompiler: { target: '18' } // target React 18
265
+ reactCompiler: false // disable
263
266
  ```
264
267
 
265
- Requires `babel-plugin-react-compiler` as a peer dependency.
268
+ `oxc-transform-react` is included automatically as a dependency of `@timber-js/app`.
266
269
 
267
270
  ### `sitemap`
268
271
 
@@ -147,7 +147,7 @@ Server actions still work — HTML forms submit natively via POST without JavaSc
147
147
  | `appDir` | `string` | auto-detected | Override app directory location |
148
148
  | `mdx` | `object` | — | MDX remark/rehype plugins |
149
149
  | `actionEncryption` | `object` | — | Server action bound args encryption |
150
- | `reactCompiler` | `boolean \| object` | `false` | React Compiler auto-memoization |
150
+ | `reactCompiler` | `boolean \| object` | `true` | React Compiler auto-memoization |
151
151
  | `sitemap` | `object` | — | Auto-generated sitemap.xml |
152
152
  | `clientSegmentCache`| `boolean` | `false` | Opt-in client segment cache for partial nav |
153
153
  | `topLoader` | `object` | enabled | Navigation progress bar |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.194",
3
+ "version": "0.2.0-alpha.195",
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",
@@ -146,28 +146,23 @@
146
146
  "publishConfig": {
147
147
  "access": "public"
148
148
  },
149
- "scripts": {
150
- "copy-docs": "node scripts/copy-docs.js",
151
- "build": "vite build --config vite.lib.config.ts && node scripts/build-types.js && pnpm run copy-docs",
152
- "typecheck": "tsgo --noEmit",
153
- "prepublishOnly": "pnpm run build"
154
- },
155
149
  "dependencies": {
156
150
  "@opentelemetry/api": "^1.9.1",
157
151
  "@opentelemetry/context-async-hooks": "^2.10.0",
158
152
  "@opentelemetry/sdk-trace-base": "^2.10.0",
159
153
  "cookie": "^2.0.1",
160
154
  "jsonc-parser": "^3.3.1",
161
- "magic-string": "^1.2.0",
155
+ "magic-string": "^1.2.2",
162
156
  "nitro": "3.0.260610-beta",
163
- "srvx": "^0.12.5"
157
+ "oxc-transform-react": "^0.145.0",
158
+ "srvx": "^0.12.7"
164
159
  },
165
160
  "peerDependencies": {
166
161
  "@content-collections/core": "^0.14.0 || ^0.15.0",
167
162
  "@content-collections/mdx": "^0.2.0",
168
163
  "@content-collections/vite": "^0.2.0 || ^0.3.0",
169
164
  "@typescript/native-preview": "^7.0.0-dev.0",
170
- "@vitejs/plugin-react": "^6.0.0",
165
+ "@vitejs/plugin-react": "^6.1.0",
171
166
  "@vitejs/plugin-rsc": ">=0.5.28",
172
167
  "nuqs": "^2.0.0",
173
168
  "react": "19.2.8",
@@ -205,9 +200,14 @@
205
200
  }
206
201
  },
207
202
  "devDependencies": {
208
- "@typescript/native-preview": "catalog:"
203
+ "@typescript/native-preview": "7.0.0-dev.20260707.2"
209
204
  },
210
205
  "engines": {
211
206
  "node": ">=22.18.0"
207
+ },
208
+ "scripts": {
209
+ "copy-docs": "node scripts/copy-docs.js",
210
+ "build": "vite build --config vite.lib.config.ts && node scripts/build-types.js && pnpm run copy-docs",
211
+ "typecheck": "tsgo --noEmit"
212
212
  }
213
- }
213
+ }
package/src/cli.ts CHANGED
File without changes
@@ -209,21 +209,24 @@ export interface TimberUserConfig {
209
209
  disableInDev?: boolean;
210
210
  };
211
211
  /**
212
- * Enable the React Compiler (babel-plugin-react-compiler) for automatic
213
- * memoization of components and hooks at build time.
212
+ * Enable the React Compiler for automatic memoization of components
213
+ * and hooks at build time.
214
214
  *
215
- * - `true` — enable with default options
215
+ * - `true` or omitted — enabled with default options (default)
216
216
  * - `{ compilationMode, target }` — enable with custom options
217
217
  * - `compilationMode: 'annotation'` — only compile files with `'use memo'`
218
218
  * - `target: '18'` — target React 18 (uses react-compiler-runtime package)
219
- * - `false` or omitted — disabled (default)
219
+ * - `false` — disabled
220
220
  *
221
- * Uses `@vitejs/plugin-react`'s built-in `reactCompilerPreset`, which:
222
- * - Applies Babel only for the compiler pass (OXC handles JSX)
223
- * - Automatically scopes to client environment only
224
- * - Uses `react/compiler-runtime` built into React 19
221
+ * Uses `@vitejs/plugin-react`'s native OXC-based compiler path
222
+ * (`react({ compiler: ... })`), which handles JSX and compilation in
223
+ * a single Rust pass — no Babel required.
225
224
  *
226
- * Requires `babel-plugin-react-compiler` as a peer dependency.
225
+ * Note: enabling the compiler disables React Fast Refresh. HMR still
226
+ * works (modules re-evaluate on save) but component state is not
227
+ * preserved across edits.
228
+ *
229
+ * `oxc-transform-react` is included as a direct dependency.
227
230
  */
228
231
  reactCompiler?: boolean | { compilationMode?: string; target?: string };
229
232
  /**
package/src/index.ts CHANGED
@@ -11,10 +11,11 @@
11
11
  */
12
12
 
13
13
  import type { Plugin, PluginOption } from 'vite';
14
+ import type { ReactCompilerOptions } from '@vitejs/plugin-react';
14
15
  import { createLogger } from 'vite';
15
16
  import { join, relative, resolve } from 'node:path';
16
17
  import { createRequire } from 'node:module';
17
- import react, { reactCompilerPreset } from '@vitejs/plugin-react';
18
+ import react from '@vitejs/plugin-react';
18
19
  import { timberContent } from './plugins/content.ts';
19
20
  import { timberDevServer } from './plugins/dev-server.ts';
20
21
  import { timberEntries } from './plugins/entries.ts';
@@ -113,38 +114,6 @@ export function defineConfig(config: TimberUserConfig): TimberUserConfig {
113
114
 
114
115
  // ── Private helpers ───────────────────────────────────────────────────────
115
116
 
116
- /**
117
- * Resolve the React Compiler plugin via @rolldown/plugin-babel.
118
- *
119
- * Uses the `reactCompilerPreset` from @vitejs/plugin-react, which:
120
- * - Uses Babel ONLY for the compiler pass (OXC handles JSX)
121
- * - Automatically scopes to client environment via applyToEnvironmentHook
122
- * - Uses react/compiler-runtime built into React 19
123
- *
124
- * @rolldown/plugin-babel and babel-plugin-react-compiler are optional peer deps.
125
- * If either is missing, require() fails with a clear error message.
126
- */
127
- function resolveReactCompilerPlugin(
128
- config: true | { compilationMode?: string; target?: string },
129
- req: NodeJS.Require
130
- ): PluginOption {
131
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
- let babel: any;
133
- try {
134
- babel = req('@rolldown/plugin-babel');
135
- } catch {
136
- throw new Error(
137
- '[timber] reactCompiler requires @rolldown/plugin-babel. ' +
138
- 'Install it: pnpm add -D @rolldown/plugin-babel babel-plugin-react-compiler'
139
- );
140
- }
141
- const options = typeof config === 'object' ? config : {};
142
- const babelPlugin = babel.default ?? babel;
143
- return babelPlugin({
144
- presets: [reactCompilerPreset(options as Parameters<typeof reactCompilerPreset>[0])],
145
- }) as PluginOption;
146
- }
147
-
148
117
  /**
149
118
  * Build the options object for @vitejs/plugin-rsc.
150
119
  *
@@ -220,6 +189,32 @@ function createRscOptions(
220
189
  * causing file-based config for reactCompiler, actionEncryption, and
221
190
  * output mode to be silently ignored. See TIM-451.
222
191
  */
192
+ /**
193
+ * Wrap plugin-react's `vite:react-compiler` configResolved so it always
194
+ * sees `isProduction: true` during builds. The compiler plugin uses
195
+ * `!config.isProduction` to decide whether to emit jsxDEV calls, but
196
+ * Vite derives isProduction from process.env.NODE_ENV — which may be
197
+ * "development" in the shell. Rather than mutating the env, we pass a
198
+ * prototype-based view with the single property overridden.
199
+ */
200
+ function patchReactCompilerForProdJsx(plugins: PluginOption[]): PluginOption[] {
201
+ for (const p of plugins) {
202
+ if (p && typeof p === 'object' && 'name' in p && p.name === 'vite:react-compiler') {
203
+ const orig = (p as Plugin).configResolved;
204
+ if (typeof orig === 'function') {
205
+ (p as Plugin).configResolved = function (config) {
206
+ const view =
207
+ config.command === 'build' && !config.isProduction
208
+ ? Object.create(config, { isProduction: { value: true } })
209
+ : config;
210
+ return orig.call(this, view);
211
+ };
212
+ }
213
+ }
214
+ }
215
+ return plugins;
216
+ }
217
+
223
218
  export function timber(config?: TimberUserConfig): PluginOption[] {
224
219
  const ctx = createPluginContext(config);
225
220
 
@@ -252,6 +247,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
252
247
  const earlyFileConfig = loadTimberConfigFile(process.cwd());
253
248
 
254
249
  // ── Step 4: Build rootSync plugin ───────────────────────────────────
250
+
255
251
  // rootSync loads timber.config.ts from Vite's resolved root and merges
256
252
  // it into ctx.config. Plugin resolution is handled eagerly above — the
257
253
  // config() hook must NOT return a `plugins` field (Vite ignores it).
@@ -314,19 +310,14 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
314
310
  }
315
311
  }
316
312
 
317
- // Force production JSX transform for builds.
318
- //
319
- // Vite determines dev vs prod JSX via `isProduction`, which checks
320
- // `process.env.NODE_ENV === 'production'`. If the shell has
321
- // NODE_ENV=development (common in dev toolchains), `vite build`
322
- // respects that and emits jsxDEV calls with fileName/lineNumber
323
- // args. This causes runtime crashes because the production React
324
- // jsx-runtime doesn't export jsxDEV, and also leaks file paths
325
- // into production bundles (security concern).
313
+ // Force production JSX for builds.
326
314
  //
327
- // We explicitly set `oxc.jsx.development: false` for builds so
328
- // the client bundle always uses jsx/jsxs from react/jsx-runtime,
329
- // regardless of the ambient NODE_ENV value.
315
+ // Vite derives `config.isProduction` from `process.env.NODE_ENV`.
316
+ // If the shell has NODE_ENV=development, isProduction stays false
317
+ // and Vite's built-in OXC transform emits jsxDEV calls (the
318
+ // production jsx-runtime doesn't export jsxDEV). The oxc.jsx
319
+ // override below fixes Vite's native path; plugin-react's compiler
320
+ // plugin is handled by patchReactCompilerForProdJsx above.
330
321
  // ── Resolve dev/preview port (TIM-842) ───────────────────────
331
322
  // Default port is 3000 with auto-bump on conflict. Explicit user
332
323
  // overrides (`--port`, `PORT` env, `vite.config.ts` server.port)
@@ -540,19 +531,11 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
540
531
  },
541
532
  };
542
533
 
543
- // ── Step 5: Resolve optional plugins ────────────────────────────────
544
- // React Compiler — resolved eagerly from either inline or file config.
545
- // Vite's config() hook return type is Omit<UserConfig, 'plugins'>, so
546
- // plugins MUST be in the top-level array — returning them from config()
547
- // silently drops them. See TIM-632.
548
- //
534
+ // ── Step 5: Resolve React Compiler config ───────────────────────────
549
535
  // Inline config takes precedence. File config (from the early cwd-based
550
536
  // load) is used when inline config doesn't set reactCompiler.
551
- const reactCompilerPlugins: PluginOption[] = [];
552
- const effectiveReactCompiler = config?.reactCompiler ?? earlyFileConfig?.reactCompiler;
553
- if (effectiveReactCompiler) {
554
- reactCompilerPlugins.push(resolveReactCompilerPlugin(effectiveReactCompiler, consumerRequire));
555
- }
537
+ // Default: true — oxc-transform-react is a direct dependency.
538
+ const effectiveReactCompiler = config?.reactCompiler ?? earlyFileConfig?.reactCompiler ?? true;
556
539
 
557
540
  // ── Step 6: Assemble plugin array ─────────────────────────────────────
558
541
  // @vitejs/plugin-rsc handles:
@@ -580,8 +563,11 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
580
563
  // for client components via Babel transform. Placed before @vitejs/plugin-rsc
581
564
  // following Vinext's convention — the RSC plugin's virtual browser entry
582
565
  // coordinates with plugin-react via __vite_plugin_react_preamble_installed__.
583
- react(),
584
- ...reactCompilerPlugins,
566
+ ...patchReactCompilerForProdJsx(
567
+ react({
568
+ compiler: (effectiveReactCompiler || false) as boolean | ReactCompilerOptions,
569
+ })
570
+ ),
585
571
  vitePluginRsc(createRscOptions(ctx, encryptionKeyExpr)),
586
572
  timberShims(ctx),
587
573
  timberRouting(ctx),