@timber-js/app 0.2.0-alpha.83 → 0.2.0-alpha.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{actions-Dg-ANYHb.js → actions-DLnUaR65.js} +2 -2
- package/dist/_chunks/{actions-Dg-ANYHb.js.map → actions-DLnUaR65.js.map} +1 -1
- package/dist/_chunks/{chunk-DYhsFzuS.js → chunk-BYIpzuS7.js} +7 -1
- package/dist/_chunks/{define-CZqDwhSu.js → define-Itxvcd7F.js} +2 -2
- package/dist/_chunks/{define-CZqDwhSu.js.map → define-Itxvcd7F.js.map} +1 -1
- package/dist/_chunks/{define-cookie-C2IkoFGN.js → define-cookie-BowvzoP0.js} +4 -4
- package/dist/_chunks/{define-cookie-C2IkoFGN.js.map → define-cookie-BowvzoP0.js.map} +1 -1
- package/dist/_chunks/{request-context-qMsWgy9C.js → request-context-CK5tZqIP.js} +3 -3
- package/dist/_chunks/{request-context-qMsWgy9C.js.map → request-context-CK5tZqIP.js.map} +1 -1
- package/dist/_chunks/{use-query-states-Lo_s_pw2.js → use-query-states-BiV5GJgm.js} +4 -1
- package/dist/_chunks/{use-query-states-Lo_s_pw2.js.map → use-query-states-BiV5GJgm.js.map} +1 -1
- package/dist/client/form.d.ts +4 -1
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +1 -1
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/config-validation.d.ts +51 -0
- package/dist/config-validation.d.ts.map +1 -0
- package/dist/cookies/index.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1169 -51
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-404-page.d.ts +56 -0
- package/dist/plugins/dev-404-page.d.ts.map +1 -0
- package/dist/plugins/dev-error-overlay.d.ts +14 -11
- package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
- package/dist/plugins/dev-error-page.d.ts +58 -0
- package/dist/plugins/dev-error-page.d.ts.map +1 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/dev-terminal-error.d.ts +28 -0
- package/dist/plugins/dev-terminal-error.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +4 -0
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts +41 -0
- package/dist/routing/convention-lint.d.ts.map +1 -0
- package/dist/search-params/index.js +2 -2
- package/dist/server/action-client.d.ts +13 -5
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts +9 -5
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +2 -2
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/form.tsx +10 -5
- package/src/client/use-query-states.ts +6 -0
- package/src/config-validation.ts +299 -0
- package/src/index.ts +17 -0
- package/src/plugins/dev-404-page.ts +418 -0
- package/src/plugins/dev-error-overlay.ts +165 -54
- package/src/plugins/dev-error-page.ts +536 -0
- package/src/plugins/dev-server.ts +63 -10
- package/src/plugins/dev-terminal-error.ts +217 -0
- package/src/plugins/entries.ts +3 -0
- package/src/plugins/fonts.ts +3 -2
- package/src/plugins/routing.ts +37 -5
- package/src/plugins/shims.ts +1 -0
- package/src/routing/convention-lint.ts +356 -0
- package/src/server/action-client.ts +17 -9
- package/src/server/fallback-error.ts +39 -88
- package/src/server/rsc-entry/index.ts +34 -2
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/server/form-data.ts","../../src/server/action-client.ts","../../src/server/form-flash.ts"],"sourcesContent":["/**\n * FormData preprocessing — schema-agnostic conversion of FormData to typed objects.\n *\n * FormData is all strings. Schema validation expects typed values. This module\n * bridges the gap with intelligent coercion that runs *before* schema validation.\n *\n * Inspired by zod-form-data, but schema-agnostic — works with any Standard Schema\n * library (Zod, Valibot, ArkType).\n *\n * See design/08-forms-and-actions.md §\"parseFormData() and coerce helpers\"\n */\n\n// ─── parseFormData ───────────────────────────────────────────────────────\n\n/**\n * Convert FormData into a plain object with intelligent coercion.\n *\n * Handles:\n * - **Duplicate keys → arrays**: `tags=js&tags=ts` → `{ tags: [\"js\", \"ts\"] }`\n * - **Nested dot-paths**: `user.name=Alice` → `{ user: { name: \"Alice\" } }`\n * - **Empty strings → undefined**: Enables `.optional()` semantics in schemas\n * - **Empty Files → undefined**: File inputs with no selection become `undefined`\n * - **Strips `$ACTION_*` fields**: React's internal hidden fields are excluded\n */\nexport function parseFormData(formData: FormData): Record<string, unknown> {\n const flat: Record<string, unknown> = {};\n\n for (const key of new Set(formData.keys())) {\n // Skip React internal fields\n if (key.startsWith('$ACTION_')) continue;\n\n const values = formData.getAll(key);\n const processed = values.map(normalizeValue);\n\n if (processed.length === 1) {\n flat[key] = processed[0];\n } else {\n // Filter out undefined entries from multi-value fields\n flat[key] = processed.filter((v) => v !== undefined);\n }\n }\n\n // Expand dot-notation paths into nested objects\n return expandDotPaths(flat);\n}\n\n/**\n * Normalize a single FormData entry value.\n * - Empty strings → undefined (enables .optional() semantics)\n * - Empty File objects (no selection) → undefined\n * - Everything else passes through as-is\n */\nfunction normalizeValue(value: FormDataEntryValue): unknown {\n if (typeof value === 'string') {\n return value === '' ? undefined : value;\n }\n\n // File input with no selection: browsers submit a File with name=\"\" and size=0\n if (value instanceof File && value.size === 0 && value.name === '') {\n return undefined;\n }\n\n return value;\n}\n\n/**\n * Expand dot-notation keys into nested objects.\n * `{ \"user.name\": \"Alice\", \"user.age\": \"30\" }` → `{ user: { name: \"Alice\", age: \"30\" } }`\n *\n * Keys without dots are left as-is. Bracket notation (e.g. `items[0]`) is NOT\n * supported — use dot notation (`items.0`) instead.\n */\nfunction expandDotPaths(flat: Record<string, unknown>): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n let hasDotPaths = false;\n\n // First pass: check if any keys have dots\n for (const key of Object.keys(flat)) {\n if (key.includes('.')) {\n hasDotPaths = true;\n break;\n }\n }\n\n // Fast path: no dot-notation keys, return as-is\n if (!hasDotPaths) return flat;\n\n for (const [key, value] of Object.entries(flat)) {\n if (!key.includes('.')) {\n result[key] = value;\n continue;\n }\n\n const parts = key.split('.');\n let current: Record<string, unknown> = result;\n\n for (let i = 0; i < parts.length - 1; i++) {\n const part = parts[i];\n if (current[part] === undefined || current[part] === null) {\n current[part] = {};\n }\n // If current[part] is not an object (e.g., a string from a non-dotted key),\n // the dot-path takes precedence\n if (typeof current[part] !== 'object' || current[part] instanceof File) {\n current[part] = {};\n }\n current = current[part] as Record<string, unknown>;\n }\n\n current[parts[parts.length - 1]] = value;\n }\n\n return result;\n}\n\n// ─── Coercion Helpers ────────────────────────────────────────────────────\n\n/**\n * Schema-agnostic coercion primitives for common FormData patterns.\n *\n * These are plain transform functions — they compose with any schema library's\n * `transform`/`preprocess` pipeline:\n *\n * ```ts\n * // Zod\n * z.preprocess(coerce.number, z.number())\n * // Valibot\n * v.pipe(v.unknown(), v.transform(coerce.number), v.number())\n * ```\n */\nexport const coerce = {\n /**\n * Coerce a string to a number.\n * - `\"42\"` → `42`\n * - `\"3.14\"` → `3.14`\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Non-numeric strings → `undefined` (schema validation will catch this)\n */\n number(value: unknown): number | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value === 'number') return value;\n if (typeof value !== 'string') return undefined;\n const num = Number(value);\n if (Number.isNaN(num)) return undefined;\n return num;\n },\n\n /**\n * Coerce a checkbox value to a boolean.\n * HTML checkboxes submit \"on\" when checked and are absent when unchecked.\n * - `\"on\"` / any truthy string → `true`\n * - `undefined` / `null` / `\"\"` → `false`\n */\n checkbox(value: unknown): boolean {\n if (value === undefined || value === null || value === '') return false;\n if (typeof value === 'boolean') return value;\n // Any non-empty string (typically \"on\") is true\n return typeof value === 'string' && value.length > 0;\n },\n\n /**\n * Parse a JSON string into an object.\n * - Valid JSON string → parsed object\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Invalid JSON → `undefined` (schema validation will catch this)\n */\n json(value: unknown): unknown {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value !== 'string') return value;\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n },\n\n /**\n * Coerce a date string to a Date object.\n * Handles `<input type=\"date\">` (`\"2024-01-15\"`), `<input type=\"datetime-local\">`\n * (`\"2024-01-15T10:30\"`), and full ISO 8601 strings.\n * - Valid date string → `Date`\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Invalid date strings → `undefined` (schema validation will catch this)\n * - Impossible dates that `new Date()` silently normalizes (e.g. Feb 31) → `undefined`\n */\n date(value: unknown): Date | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (value instanceof Date) return value;\n if (typeof value !== 'string') return undefined;\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return undefined;\n\n // Overflow detection: extract Y/M/D from the input string and verify\n // they match the parsed Date components. new Date('2024-02-31') silently\n // normalizes to March 2nd — we reject such inputs.\n const ymdMatch = value.match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if (ymdMatch) {\n const inputYear = Number(ymdMatch[1]);\n const inputMonth = Number(ymdMatch[2]);\n const inputDay = Number(ymdMatch[3]);\n\n // Use UTC methods for date-only and Z-suffixed strings to avoid\n // timezone offset shifting the day. For datetime-local (no Z suffix),\n // the Date constructor parses in local time, so use local methods.\n const isUTC = value.length === 10 || value.endsWith('Z');\n const parsedYear = isUTC ? date.getUTCFullYear() : date.getFullYear();\n const parsedMonth = isUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;\n const parsedDay = isUTC ? date.getUTCDate() : date.getDate();\n\n if (inputYear !== parsedYear || inputMonth !== parsedMonth || inputDay !== parsedDay) {\n return undefined;\n }\n }\n\n return date;\n },\n\n /**\n * Create a File coercion function with optional size and mime type validation.\n * Returns the File if valid, `undefined` otherwise.\n *\n * ```ts\n * // Basic — just checks it's a real File\n * z.preprocess(coerce.file(), z.instanceof(File))\n *\n * // With constraints\n * z.preprocess(\n * coerce.file({ maxSize: 5 * 1024 * 1024, accept: ['image/png', 'image/jpeg'] }),\n * z.instanceof(File)\n * )\n * ```\n */\n file(options?: { maxSize?: number; accept?: string[] }): (value: unknown) => File | undefined {\n return (value: unknown): File | undefined => {\n if (value === undefined || value === null || value === '') return undefined;\n if (!(value instanceof File)) return undefined;\n\n // Empty file input (no selection): browsers submit File with name=\"\" and size=0\n if (value.size === 0 && value.name === '') return undefined;\n\n if (options?.maxSize !== undefined && value.size > options.maxSize) {\n return undefined;\n }\n\n if (options?.accept !== undefined && !options.accept.includes(value.type)) {\n return undefined;\n }\n\n return value;\n };\n },\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 for 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\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<undefined, TData>;\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<TInput, TData>;\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\nexport type ActionFn<TInput = unknown, TData = 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 (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.js';\nimport { formatSize } from '../utils/format.js';\nimport { isDebug, isDevMode } from './debug.js';\nimport { RedirectSignal, DenySignal } from './primitives.js';\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<TInput, TData> {\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 // 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 const submittedValues = stripFiles(rawInput);\n return { validationErrors: fileSizeErrors, submittedValues };\n }\n }\n\n // Capture submitted values for repopulation on validation failure.\n // Exclude File objects (can't serialize, shouldn't echo back).\n const submittedValues = schema ? stripFiles(rawInput) : 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<TInput, TData>;\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<TInput, TData> {\n return buildAction(schema, fn);\n },\n };\n },\n action<TData>(\n fn: (ctx: ActionContext<TCtx, undefined>) => Promise<TData>\n ): ActionFn<undefined, TData> {\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<TInput, TData> {\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 errors: ValidationErrors = {};\n const limitKb = Math.round(limit / 1024);\n const limitLabel =\n limit >= 1024 * 1024 ? `${Math.round(limit / (1024 * 1024))}MB` : `${limitKb}KB`;\n\n for (const [key, value] of Object.entries(input)) {\n if (value instanceof File && value.size > limit) {\n errors[key] = [\n `File \"${value.name}\" (${formatSize(value.size)}) exceeds the ${limitLabel} limit`,\n ];\n } else if (Array.isArray(value)) {\n const oversized = value.filter((item) => item instanceof File && item.size > limit);\n if (oversized.length > 0) {\n errors[key] = oversized.map(\n (f: File) => `File \"${f.name}\" (${formatSize(f.size)}) exceeds the ${limitLabel} limit`\n );\n }\n }\n }\n\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.filter((item) => !(item instanceof File));\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.js';\nimport { formFlashAls } from './als-registry.js';\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/server/form-data.ts","../../src/server/action-client.ts","../../src/server/form-flash.ts"],"sourcesContent":["/**\n * FormData preprocessing — schema-agnostic conversion of FormData to typed objects.\n *\n * FormData is all strings. Schema validation expects typed values. This module\n * bridges the gap with intelligent coercion that runs *before* schema validation.\n *\n * Inspired by zod-form-data, but schema-agnostic — works with any Standard Schema\n * library (Zod, Valibot, ArkType).\n *\n * See design/08-forms-and-actions.md §\"parseFormData() and coerce helpers\"\n */\n\n// ─── parseFormData ───────────────────────────────────────────────────────\n\n/**\n * Convert FormData into a plain object with intelligent coercion.\n *\n * Handles:\n * - **Duplicate keys → arrays**: `tags=js&tags=ts` → `{ tags: [\"js\", \"ts\"] }`\n * - **Nested dot-paths**: `user.name=Alice` → `{ user: { name: \"Alice\" } }`\n * - **Empty strings → undefined**: Enables `.optional()` semantics in schemas\n * - **Empty Files → undefined**: File inputs with no selection become `undefined`\n * - **Strips `$ACTION_*` fields**: React's internal hidden fields are excluded\n */\nexport function parseFormData(formData: FormData): Record<string, unknown> {\n const flat: Record<string, unknown> = {};\n\n for (const key of new Set(formData.keys())) {\n // Skip React internal fields\n if (key.startsWith('$ACTION_')) continue;\n\n const values = formData.getAll(key);\n const processed = values.map(normalizeValue);\n\n if (processed.length === 1) {\n flat[key] = processed[0];\n } else {\n // Filter out undefined entries from multi-value fields\n flat[key] = processed.filter((v) => v !== undefined);\n }\n }\n\n // Expand dot-notation paths into nested objects\n return expandDotPaths(flat);\n}\n\n/**\n * Normalize a single FormData entry value.\n * - Empty strings → undefined (enables .optional() semantics)\n * - Empty File objects (no selection) → undefined\n * - Everything else passes through as-is\n */\nfunction normalizeValue(value: FormDataEntryValue): unknown {\n if (typeof value === 'string') {\n return value === '' ? undefined : value;\n }\n\n // File input with no selection: browsers submit a File with name=\"\" and size=0\n if (value instanceof File && value.size === 0 && value.name === '') {\n return undefined;\n }\n\n return value;\n}\n\n/**\n * Expand dot-notation keys into nested objects.\n * `{ \"user.name\": \"Alice\", \"user.age\": \"30\" }` → `{ user: { name: \"Alice\", age: \"30\" } }`\n *\n * Keys without dots are left as-is. Bracket notation (e.g. `items[0]`) is NOT\n * supported — use dot notation (`items.0`) instead.\n */\nfunction expandDotPaths(flat: Record<string, unknown>): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n let hasDotPaths = false;\n\n // First pass: check if any keys have dots\n for (const key of Object.keys(flat)) {\n if (key.includes('.')) {\n hasDotPaths = true;\n break;\n }\n }\n\n // Fast path: no dot-notation keys, return as-is\n if (!hasDotPaths) return flat;\n\n for (const [key, value] of Object.entries(flat)) {\n if (!key.includes('.')) {\n result[key] = value;\n continue;\n }\n\n const parts = key.split('.');\n let current: Record<string, unknown> = result;\n\n for (let i = 0; i < parts.length - 1; i++) {\n const part = parts[i];\n if (current[part] === undefined || current[part] === null) {\n current[part] = {};\n }\n // If current[part] is not an object (e.g., a string from a non-dotted key),\n // the dot-path takes precedence\n if (typeof current[part] !== 'object' || current[part] instanceof File) {\n current[part] = {};\n }\n current = current[part] as Record<string, unknown>;\n }\n\n current[parts[parts.length - 1]] = value;\n }\n\n return result;\n}\n\n// ─── Coercion Helpers ────────────────────────────────────────────────────\n\n/**\n * Schema-agnostic coercion primitives for common FormData patterns.\n *\n * These are plain transform functions — they compose with any schema library's\n * `transform`/`preprocess` pipeline:\n *\n * ```ts\n * // Zod\n * z.preprocess(coerce.number, z.number())\n * // Valibot\n * v.pipe(v.unknown(), v.transform(coerce.number), v.number())\n * ```\n */\nexport const coerce = {\n /**\n * Coerce a string to a number.\n * - `\"42\"` → `42`\n * - `\"3.14\"` → `3.14`\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Non-numeric strings → `undefined` (schema validation will catch this)\n */\n number(value: unknown): number | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value === 'number') return value;\n if (typeof value !== 'string') return undefined;\n const num = Number(value);\n if (Number.isNaN(num)) return undefined;\n return num;\n },\n\n /**\n * Coerce a checkbox value to a boolean.\n * HTML checkboxes submit \"on\" when checked and are absent when unchecked.\n * - `\"on\"` / any truthy string → `true`\n * - `undefined` / `null` / `\"\"` → `false`\n */\n checkbox(value: unknown): boolean {\n if (value === undefined || value === null || value === '') return false;\n if (typeof value === 'boolean') return value;\n // Any non-empty string (typically \"on\") is true\n return typeof value === 'string' && value.length > 0;\n },\n\n /**\n * Parse a JSON string into an object.\n * - Valid JSON string → parsed object\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Invalid JSON → `undefined` (schema validation will catch this)\n */\n json(value: unknown): unknown {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value !== 'string') return value;\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n },\n\n /**\n * Coerce a date string to a Date object.\n * Handles `<input type=\"date\">` (`\"2024-01-15\"`), `<input type=\"datetime-local\">`\n * (`\"2024-01-15T10:30\"`), and full ISO 8601 strings.\n * - Valid date string → `Date`\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Invalid date strings → `undefined` (schema validation will catch this)\n * - Impossible dates that `new Date()` silently normalizes (e.g. Feb 31) → `undefined`\n */\n date(value: unknown): Date | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (value instanceof Date) return value;\n if (typeof value !== 'string') return undefined;\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return undefined;\n\n // Overflow detection: extract Y/M/D from the input string and verify\n // they match the parsed Date components. new Date('2024-02-31') silently\n // normalizes to March 2nd — we reject such inputs.\n const ymdMatch = value.match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if (ymdMatch) {\n const inputYear = Number(ymdMatch[1]);\n const inputMonth = Number(ymdMatch[2]);\n const inputDay = Number(ymdMatch[3]);\n\n // Use UTC methods for date-only and Z-suffixed strings to avoid\n // timezone offset shifting the day. For datetime-local (no Z suffix),\n // the Date constructor parses in local time, so use local methods.\n const isUTC = value.length === 10 || value.endsWith('Z');\n const parsedYear = isUTC ? date.getUTCFullYear() : date.getFullYear();\n const parsedMonth = isUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;\n const parsedDay = isUTC ? date.getUTCDate() : date.getDate();\n\n if (inputYear !== parsedYear || inputMonth !== parsedMonth || inputDay !== parsedDay) {\n return undefined;\n }\n }\n\n return date;\n },\n\n /**\n * Create a File coercion function with optional size and mime type validation.\n * Returns the File if valid, `undefined` otherwise.\n *\n * ```ts\n * // Basic — just checks it's a real File\n * z.preprocess(coerce.file(), z.instanceof(File))\n *\n * // With constraints\n * z.preprocess(\n * coerce.file({ maxSize: 5 * 1024 * 1024, accept: ['image/png', 'image/jpeg'] }),\n * z.instanceof(File)\n * )\n * ```\n */\n file(options?: { maxSize?: number; accept?: string[] }): (value: unknown) => File | undefined {\n return (value: unknown): File | undefined => {\n if (value === undefined || value === null || value === '') return undefined;\n if (!(value instanceof File)) return undefined;\n\n // Empty file input (no selection): browsers submit File with name=\"\" and size=0\n if (value.size === 0 && value.name === '') return undefined;\n\n if (options?.maxSize !== undefined && value.size > options.maxSize) {\n return undefined;\n }\n\n if (options?.accept !== undefined && !options.accept.includes(value.type)) {\n return undefined;\n }\n\n return value;\n };\n },\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 for 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\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.js';\nimport { formatSize } from '../utils/format.js';\nimport { isDebug, isDevMode } from './debug.js';\nimport { RedirectSignal, DenySignal } from './primitives.js';\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 // 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 const submittedValues = stripFiles(rawInput);\n return { validationErrors: fileSizeErrors, submittedValues };\n }\n }\n\n // Capture submitted values for repopulation on validation failure.\n // Exclude File objects (can't serialize, shouldn't echo back).\n const submittedValues = schema ? stripFiles(rawInput) : 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 errors: ValidationErrors = {};\n const limitKb = Math.round(limit / 1024);\n const limitLabel =\n limit >= 1024 * 1024 ? `${Math.round(limit / (1024 * 1024))}MB` : `${limitKb}KB`;\n\n for (const [key, value] of Object.entries(input)) {\n if (value instanceof File && value.size > limit) {\n errors[key] = [\n `File \"${value.name}\" (${formatSize(value.size)}) exceeds the ${limitLabel} limit`,\n ];\n } else if (Array.isArray(value)) {\n const oversized = value.filter((item) => item instanceof File && item.size > limit);\n if (oversized.length > 0) {\n errors[key] = oversized.map(\n (f: File) => `File \"${f.name}\" (${formatSize(f.size)}) exceeds the ${limitLabel} limit`\n );\n }\n }\n }\n\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.filter((item) => !(item instanceof File));\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.js';\nimport { formFlashAls } from './als-registry.js';\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
|
@@ -3,8 +3,8 @@ import { a as warnRedirectInSuspense, c as warnSuspenseWrappingChildren, i as wa
|
|
|
3
3
|
import { t as classifyUrlSegment } from "../_chunks/segment-classify-BDNn6EzD.js";
|
|
4
4
|
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-DS3eKNmf.js";
|
|
5
5
|
import { a as timingAls, r as requestContextAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-HS0LGUl2.js";
|
|
6
|
-
import { f as runWithRequestContext, l as getSetCookieHeaders, m as setSegmentParams, p as setMutableCookieContext, t as applyRequestHeaderOverlay, u as markResponseFlushed } from "../_chunks/request-context-
|
|
7
|
-
import { l as RenderError, n as executeAction, o as DenySignal, r as isRscActionRequest, s as RedirectSignal, t as buildNoJsResponse } from "../_chunks/actions-
|
|
6
|
+
import { f as runWithRequestContext, l as getSetCookieHeaders, m as setSegmentParams, p as setMutableCookieContext, t as applyRequestHeaderOverlay, u as markResponseFlushed } from "../_chunks/request-context-CK5tZqIP.js";
|
|
7
|
+
import { l as RenderError, n as executeAction, o as DenySignal, r as isRscActionRequest, s as RedirectSignal, t as buildNoJsResponse } from "../_chunks/actions-DLnUaR65.js";
|
|
8
8
|
import { c as replaceTraceId, d as withSpan, i as getOtelTraceId, l as runWithTraceId, o as getTraceId, r as generateTraceId, s as getTraceStore, u as setSpanAttribute } from "../_chunks/tracing-CCYbKn5n.js";
|
|
9
9
|
import "../client/error-boundary.js";
|
|
10
10
|
import "../_chunks/segment-context-fHFLF1PE.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA0DA,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAmCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA0DA,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAmCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AAggBD,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAInE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;8BA3SrC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA6ShD,wBAAiE"}
|
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.85",
|
|
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",
|
package/src/client/form.tsx
CHANGED
|
@@ -90,16 +90,21 @@ export function useActionState<TData>(
|
|
|
90
90
|
* </button>
|
|
91
91
|
* ```
|
|
92
92
|
*/
|
|
93
|
-
export function useFormAction<
|
|
94
|
-
action: ActionFn<
|
|
95
|
-
): [
|
|
93
|
+
export function useFormAction<TData = unknown, TInput = unknown>(
|
|
94
|
+
action: ActionFn<TData, TInput> | ((input: TInput) => Promise<ActionResult<TData>>)
|
|
95
|
+
): [
|
|
96
|
+
(
|
|
97
|
+
...args: undefined extends TInput ? [input?: InputHint<TInput>] : [input: InputHint<TInput>]
|
|
98
|
+
) => Promise<ActionResult<TData>>,
|
|
99
|
+
boolean,
|
|
100
|
+
] {
|
|
96
101
|
const [isPending, startTransition] = useTransition();
|
|
97
102
|
|
|
98
|
-
const execute = (input
|
|
103
|
+
const execute = (input?: InputHint<TInput>): Promise<ActionResult<TData>> => {
|
|
99
104
|
return new Promise((resolve) => {
|
|
100
105
|
startTransition(async () => {
|
|
101
106
|
const result = await (action as (input: InputHint<TInput>) => Promise<ActionResult<TData>>)(
|
|
102
|
-
input
|
|
107
|
+
input as InputHint<TInput>
|
|
103
108
|
);
|
|
104
109
|
resolve(result);
|
|
105
110
|
});
|
|
@@ -95,8 +95,14 @@ export function useQueryStates<T extends Record<string, unknown>>(
|
|
|
95
95
|
|
|
96
96
|
const bridged = bridgeCodecs(codecs);
|
|
97
97
|
|
|
98
|
+
// Forward hook-level options (shallow, scroll, history) to nuqs.
|
|
99
|
+
// These become the default for all setter calls from this hook instance.
|
|
100
|
+
// Per-call options in setParams(values, opts) override these defaults.
|
|
98
101
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
102
|
const nuqsOptions: any = {};
|
|
103
|
+
if (_options?.shallow !== undefined) nuqsOptions.shallow = _options.shallow;
|
|
104
|
+
if (_options?.scroll !== undefined) nuqsOptions.scroll = _options.scroll;
|
|
105
|
+
if (_options?.history !== undefined) nuqsOptions.history = _options.history;
|
|
100
106
|
if (resolvedUrlKeys && Object.keys(resolvedUrlKeys).length > 0) {
|
|
101
107
|
nuqsOptions.urlKeys = resolvedUrlKeys;
|
|
102
108
|
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config validation — validates timber.config.ts at startup.
|
|
3
|
+
*
|
|
4
|
+
* Runs in the plugin's configResolved hook (once, at startup/build).
|
|
5
|
+
* Each check produces a clear error message with the invalid value,
|
|
6
|
+
* what's expected, and how to fix it.
|
|
7
|
+
*
|
|
8
|
+
* Design doc: 18-build-system.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TimberUserConfig } from './config-types.js';
|
|
12
|
+
|
|
13
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface ConfigError {
|
|
16
|
+
field: string;
|
|
17
|
+
message: string;
|
|
18
|
+
value?: unknown;
|
|
19
|
+
suggestion?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Validation ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate a TimberUserConfig object.
|
|
26
|
+
*
|
|
27
|
+
* Returns an array of errors. Empty array means the config is valid.
|
|
28
|
+
* Does not throw — the caller decides how to surface errors.
|
|
29
|
+
*/
|
|
30
|
+
export function validateConfig(config: TimberUserConfig): ConfigError[] {
|
|
31
|
+
const errors: ConfigError[] = [];
|
|
32
|
+
|
|
33
|
+
// output
|
|
34
|
+
if (config.output !== undefined && config.output !== 'server' && config.output !== 'static') {
|
|
35
|
+
errors.push({
|
|
36
|
+
field: 'output',
|
|
37
|
+
message: `Invalid output mode: "${String(config.output)}". Must be "server" or "static".`,
|
|
38
|
+
value: config.output,
|
|
39
|
+
suggestion: 'Use output: "server" (default) or output: "static" for static site generation.',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// pageExtensions
|
|
44
|
+
if (config.pageExtensions !== undefined) {
|
|
45
|
+
if (!Array.isArray(config.pageExtensions)) {
|
|
46
|
+
errors.push({
|
|
47
|
+
field: 'pageExtensions',
|
|
48
|
+
message: 'pageExtensions must be an array of strings.',
|
|
49
|
+
value: config.pageExtensions,
|
|
50
|
+
suggestion: 'Example: pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"]',
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
for (const ext of config.pageExtensions) {
|
|
54
|
+
if (typeof ext !== 'string') {
|
|
55
|
+
errors.push({
|
|
56
|
+
field: 'pageExtensions',
|
|
57
|
+
message: `pageExtensions contains a non-string value: ${JSON.stringify(ext)}`,
|
|
58
|
+
value: ext,
|
|
59
|
+
});
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (ext.startsWith('.')) {
|
|
63
|
+
errors.push({
|
|
64
|
+
field: 'pageExtensions',
|
|
65
|
+
message: `pageExtensions should not include the leading dot: "${ext}"`,
|
|
66
|
+
value: ext,
|
|
67
|
+
suggestion: `Use "${ext.slice(1)}" instead of "${ext}".`,
|
|
68
|
+
});
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// slowRequestMs
|
|
76
|
+
if (config.slowRequestMs !== undefined) {
|
|
77
|
+
if (typeof config.slowRequestMs !== 'number' || config.slowRequestMs < 0) {
|
|
78
|
+
errors.push({
|
|
79
|
+
field: 'slowRequestMs',
|
|
80
|
+
message: `slowRequestMs must be a non-negative number (got ${JSON.stringify(config.slowRequestMs)}).`,
|
|
81
|
+
value: config.slowRequestMs,
|
|
82
|
+
suggestion: 'Use slowRequestMs: 3000 (default) or slowRequestMs: 0 to disable.',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// renderTimeoutMs
|
|
88
|
+
if (config.renderTimeoutMs !== undefined) {
|
|
89
|
+
if (typeof config.renderTimeoutMs !== 'number' || config.renderTimeoutMs < 0) {
|
|
90
|
+
errors.push({
|
|
91
|
+
field: 'renderTimeoutMs',
|
|
92
|
+
message: `renderTimeoutMs must be a non-negative number (got ${JSON.stringify(config.renderTimeoutMs)}).`,
|
|
93
|
+
value: config.renderTimeoutMs,
|
|
94
|
+
suggestion: 'Use renderTimeoutMs: 30000 (default) or renderTimeoutMs: 0 to disable.',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// serverTiming
|
|
100
|
+
if (config.serverTiming !== undefined) {
|
|
101
|
+
if (
|
|
102
|
+
config.serverTiming !== 'detailed' &&
|
|
103
|
+
config.serverTiming !== 'total' &&
|
|
104
|
+
config.serverTiming !== false
|
|
105
|
+
) {
|
|
106
|
+
errors.push({
|
|
107
|
+
field: 'serverTiming',
|
|
108
|
+
message: `Invalid serverTiming value: ${JSON.stringify(config.serverTiming)}.`,
|
|
109
|
+
value: config.serverTiming,
|
|
110
|
+
suggestion: 'Use "detailed", "total", or false.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// devBrowserLogs
|
|
116
|
+
if (config.devBrowserLogs !== undefined) {
|
|
117
|
+
const valid = ['error', 'warn', 'info', 'none'];
|
|
118
|
+
if (!valid.includes(config.devBrowserLogs)) {
|
|
119
|
+
errors.push({
|
|
120
|
+
field: 'devBrowserLogs',
|
|
121
|
+
message: `Invalid devBrowserLogs value: ${JSON.stringify(config.devBrowserLogs)}.`,
|
|
122
|
+
value: config.devBrowserLogs,
|
|
123
|
+
suggestion: `Use one of: ${valid.map((v) => `"${v}"`).join(', ')}.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// sitemap
|
|
129
|
+
if (config.sitemap != null && typeof config.sitemap === 'object') {
|
|
130
|
+
if (config.sitemap.enabled && !config.sitemap.baseUrl) {
|
|
131
|
+
errors.push({
|
|
132
|
+
field: 'sitemap.baseUrl',
|
|
133
|
+
message: 'sitemap.baseUrl is required when sitemap is enabled.',
|
|
134
|
+
suggestion: 'Add sitemap: { enabled: true, baseUrl: "https://example.com" }.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
config.sitemap.defaultPriority !== undefined &&
|
|
139
|
+
(config.sitemap.defaultPriority < 0 || config.sitemap.defaultPriority > 1)
|
|
140
|
+
) {
|
|
141
|
+
errors.push({
|
|
142
|
+
field: 'sitemap.defaultPriority',
|
|
143
|
+
message: `sitemap.defaultPriority must be between 0.0 and 1.0 (got ${config.sitemap.defaultPriority}).`,
|
|
144
|
+
value: config.sitemap.defaultPriority,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Unknown top-level keys
|
|
150
|
+
const knownKeys = new Set([
|
|
151
|
+
'output',
|
|
152
|
+
'debug',
|
|
153
|
+
'clientJavascript',
|
|
154
|
+
'adapter',
|
|
155
|
+
'cacheHandler',
|
|
156
|
+
'allowedOrigins',
|
|
157
|
+
'csrf',
|
|
158
|
+
'limits',
|
|
159
|
+
'pageExtensions',
|
|
160
|
+
'slowRequestMs',
|
|
161
|
+
'renderTimeoutMs',
|
|
162
|
+
'devBrowserLogs',
|
|
163
|
+
'dev',
|
|
164
|
+
'serverTiming',
|
|
165
|
+
'appDir',
|
|
166
|
+
'mdx',
|
|
167
|
+
'actionEncryption',
|
|
168
|
+
'reactCompiler',
|
|
169
|
+
'sitemap',
|
|
170
|
+
'buildDir',
|
|
171
|
+
'topLoader',
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
for (const key of Object.keys(config)) {
|
|
175
|
+
if (!knownKeys.has(key)) {
|
|
176
|
+
errors.push({
|
|
177
|
+
field: key,
|
|
178
|
+
message: `Unknown config option: "${key}".`,
|
|
179
|
+
suggestion: `Check for typos. Known options: ${[...knownKeys].sort().join(', ')}.`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return errors;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Formatting ─────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const RED = '\x1b[31m';
|
|
190
|
+
const BOLD = '\x1b[1m';
|
|
191
|
+
const DIM = '\x1b[2m';
|
|
192
|
+
const RESET = '\x1b[0m';
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Format config errors for terminal output.
|
|
196
|
+
*/
|
|
197
|
+
export function formatConfigErrors(errors: ConfigError[]): string {
|
|
198
|
+
if (errors.length === 0) return '';
|
|
199
|
+
|
|
200
|
+
const lines: string[] = [];
|
|
201
|
+
lines.push(
|
|
202
|
+
`${RED}${BOLD}[timber]${RESET} ${RED}${errors.length} config error${errors.length !== 1 ? 's' : ''} in timber.config.ts:${RESET}`
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
for (const err of errors) {
|
|
206
|
+
lines.push('');
|
|
207
|
+
lines.push(` ${RED}✗${RESET} ${BOLD}${err.field}${RESET}: ${err.message}`);
|
|
208
|
+
if (err.suggestion) {
|
|
209
|
+
lines.push(` ${DIM}${err.suggestion}${RESET}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Virtual Module Name Mapping ────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
const VIRTUAL_MODULE_NAMES: Record<string, string> = {
|
|
219
|
+
'virtual:timber-rsc-entry': 'RSC entry (server component handler)',
|
|
220
|
+
'virtual:timber-ssr-entry': 'SSR entry (HTML renderer)',
|
|
221
|
+
'virtual:timber-browser-entry': 'Browser entry (client hydration)',
|
|
222
|
+
'virtual:timber-config': 'Runtime config (timber.config.ts)',
|
|
223
|
+
'virtual:timber-route-manifest': 'Route manifest (app/ file tree)',
|
|
224
|
+
'virtual:timber-instrumentation': 'Instrumentation (instrumentation.ts)',
|
|
225
|
+
'virtual:timber-cache-handler': 'Cache handler (cacheHandler config)',
|
|
226
|
+
'virtual:timber-build-manifest': 'Build manifest (asset mapping)',
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Add timber-specific context to an error message that references virtual modules.
|
|
231
|
+
*
|
|
232
|
+
* If the error message contains a `virtual:timber-*` ID, appends a
|
|
233
|
+
* human-readable explanation. Does not replace the original message.
|
|
234
|
+
*/
|
|
235
|
+
export function addVirtualModuleContext(errorMessage: string): string {
|
|
236
|
+
for (const [id, name] of Object.entries(VIRTUAL_MODULE_NAMES)) {
|
|
237
|
+
if (errorMessage.includes(id)) {
|
|
238
|
+
return `${errorMessage}\n\n [timber] This error references "${id}" — timber's ${name}.\n This is an internal module. The issue is likely in your app code or timber configuration.`;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return errorMessage;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Peer Dependency Check ──────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
interface PeerDepResult {
|
|
247
|
+
name: string;
|
|
248
|
+
status: 'ok' | 'missing';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Required (non-optional) peer dependencies that must be installed.
|
|
253
|
+
*/
|
|
254
|
+
const REQUIRED_PEERS = ['react', 'react-dom', '@vitejs/plugin-react', '@vitejs/plugin-rsc'];
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Check that required peer dependencies are installed.
|
|
258
|
+
*
|
|
259
|
+
* Uses require.resolve with a try/catch — no fs scanning.
|
|
260
|
+
* Returns only the missing packages.
|
|
261
|
+
*/
|
|
262
|
+
export function checkPeerDependencies(projectRoot: string): PeerDepResult[] {
|
|
263
|
+
const results: PeerDepResult[] = [];
|
|
264
|
+
|
|
265
|
+
for (const name of REQUIRED_PEERS) {
|
|
266
|
+
try {
|
|
267
|
+
// Use createRequire from the project root to resolve as the user would
|
|
268
|
+
const { createRequire } = require('node:module');
|
|
269
|
+
const userRequire = createRequire(`${projectRoot}/package.json`);
|
|
270
|
+
userRequire.resolve(name);
|
|
271
|
+
results.push({ name, status: 'ok' });
|
|
272
|
+
} catch {
|
|
273
|
+
results.push({ name, status: 'missing' });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return results;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Format missing peer dependencies as an actionable warning.
|
|
282
|
+
*/
|
|
283
|
+
export function formatMissingPeers(results: PeerDepResult[]): string {
|
|
284
|
+
const missing = results.filter((r) => r.status === 'missing');
|
|
285
|
+
if (missing.length === 0) return '';
|
|
286
|
+
|
|
287
|
+
const names = missing.map((r) => r.name);
|
|
288
|
+
const lines: string[] = [];
|
|
289
|
+
lines.push(`${RED}${BOLD}[timber]${RESET} ${RED}Missing required dependencies:${RESET}`);
|
|
290
|
+
lines.push('');
|
|
291
|
+
for (const name of names) {
|
|
292
|
+
lines.push(` ${RED}✗${RESET} ${name}`);
|
|
293
|
+
}
|
|
294
|
+
lines.push('');
|
|
295
|
+
lines.push(` ${DIM}Install with:${RESET}`);
|
|
296
|
+
lines.push(` ${BOLD}pnpm add ${names.join(' ')}${RESET}`);
|
|
297
|
+
|
|
298
|
+
return lines.join('\n');
|
|
299
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -366,6 +366,23 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
366
366
|
// Start the overall dev server setup timer — ends in timber-dev-server
|
|
367
367
|
ctx.timer.start('dev-server-setup');
|
|
368
368
|
}
|
|
369
|
+
|
|
370
|
+
// Validate config and check peer dependencies at startup.
|
|
371
|
+
// Errors are logged to stderr but don't block startup — Vite may
|
|
372
|
+
// still work partially, and the errors guide the user to fix things.
|
|
373
|
+
const { validateConfig, formatConfigErrors, checkPeerDependencies, formatMissingPeers } =
|
|
374
|
+
require('./config-validation.js') as typeof import('./config-validation.js');
|
|
375
|
+
|
|
376
|
+
const configErrors = validateConfig(ctx.config);
|
|
377
|
+
if (configErrors.length > 0) {
|
|
378
|
+
process.stderr.write(`${formatConfigErrors(configErrors)}\n\n`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const peerResults = checkPeerDependencies(ctx.root);
|
|
382
|
+
const peerWarning = formatMissingPeers(peerResults);
|
|
383
|
+
if (peerWarning) {
|
|
384
|
+
process.stderr.write(`${peerWarning}\n\n`);
|
|
385
|
+
}
|
|
369
386
|
},
|
|
370
387
|
};
|
|
371
388
|
|