@timber-js/app 0.1.1 → 0.1.2

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 (141) hide show
  1. package/dist/index.js +5 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/plugins/entries.d.ts.map +1 -1
  4. package/package.json +2 -1
  5. package/src/adapters/cloudflare.ts +325 -0
  6. package/src/adapters/nitro.ts +366 -0
  7. package/src/adapters/types.ts +63 -0
  8. package/src/cache/index.ts +91 -0
  9. package/src/cache/redis-handler.ts +91 -0
  10. package/src/cache/register-cached-function.ts +99 -0
  11. package/src/cache/singleflight.ts +26 -0
  12. package/src/cache/stable-stringify.ts +21 -0
  13. package/src/cache/timber-cache.ts +116 -0
  14. package/src/cli.ts +201 -0
  15. package/src/client/browser-entry.ts +663 -0
  16. package/src/client/error-boundary.tsx +209 -0
  17. package/src/client/form.tsx +200 -0
  18. package/src/client/head.ts +61 -0
  19. package/src/client/history.ts +46 -0
  20. package/src/client/index.ts +60 -0
  21. package/src/client/link-navigate-interceptor.tsx +62 -0
  22. package/src/client/link-status-provider.tsx +40 -0
  23. package/src/client/link.tsx +310 -0
  24. package/src/client/nuqs-adapter.tsx +117 -0
  25. package/src/client/router-ref.ts +25 -0
  26. package/src/client/router.ts +563 -0
  27. package/src/client/segment-cache.ts +194 -0
  28. package/src/client/segment-context.ts +57 -0
  29. package/src/client/ssr-data.ts +95 -0
  30. package/src/client/types.ts +4 -0
  31. package/src/client/unload-guard.ts +34 -0
  32. package/src/client/use-cookie.ts +122 -0
  33. package/src/client/use-link-status.ts +46 -0
  34. package/src/client/use-navigation-pending.ts +47 -0
  35. package/src/client/use-params.ts +71 -0
  36. package/src/client/use-pathname.ts +43 -0
  37. package/src/client/use-query-states.ts +133 -0
  38. package/src/client/use-router.ts +77 -0
  39. package/src/client/use-search-params.ts +74 -0
  40. package/src/client/use-selected-layout-segment.ts +110 -0
  41. package/src/content/index.ts +13 -0
  42. package/src/cookies/define-cookie.ts +137 -0
  43. package/src/cookies/index.ts +9 -0
  44. package/src/fonts/ast.ts +359 -0
  45. package/src/fonts/css.ts +68 -0
  46. package/src/fonts/fallbacks.ts +248 -0
  47. package/src/fonts/google.ts +332 -0
  48. package/src/fonts/local.ts +177 -0
  49. package/src/fonts/types.ts +88 -0
  50. package/src/index.ts +413 -0
  51. package/src/plugins/adapter-build.ts +118 -0
  52. package/src/plugins/build-manifest.ts +323 -0
  53. package/src/plugins/build-report.ts +353 -0
  54. package/src/plugins/cache-transform.ts +199 -0
  55. package/src/plugins/chunks.ts +90 -0
  56. package/src/plugins/content.ts +136 -0
  57. package/src/plugins/dev-error-overlay.ts +230 -0
  58. package/src/plugins/dev-logs.ts +280 -0
  59. package/src/plugins/dev-server.ts +389 -0
  60. package/src/plugins/dynamic-transform.ts +161 -0
  61. package/src/plugins/entries.ts +207 -0
  62. package/src/plugins/fonts.ts +581 -0
  63. package/src/plugins/mdx.ts +179 -0
  64. package/src/plugins/react-prod.ts +56 -0
  65. package/src/plugins/routing.ts +419 -0
  66. package/src/plugins/server-action-exports.ts +220 -0
  67. package/src/plugins/server-bundle.ts +113 -0
  68. package/src/plugins/shims.ts +168 -0
  69. package/src/plugins/static-build.ts +207 -0
  70. package/src/routing/codegen.ts +396 -0
  71. package/src/routing/index.ts +14 -0
  72. package/src/routing/interception.ts +173 -0
  73. package/src/routing/scanner.ts +487 -0
  74. package/src/routing/status-file-lint.ts +114 -0
  75. package/src/routing/types.ts +100 -0
  76. package/src/search-params/analyze.ts +192 -0
  77. package/src/search-params/codecs.ts +153 -0
  78. package/src/search-params/create.ts +314 -0
  79. package/src/search-params/index.ts +23 -0
  80. package/src/search-params/registry.ts +31 -0
  81. package/src/server/access-gate.tsx +142 -0
  82. package/src/server/action-client.ts +473 -0
  83. package/src/server/action-handler.ts +325 -0
  84. package/src/server/actions.ts +236 -0
  85. package/src/server/asset-headers.ts +81 -0
  86. package/src/server/body-limits.ts +102 -0
  87. package/src/server/build-manifest.ts +234 -0
  88. package/src/server/canonicalize.ts +90 -0
  89. package/src/server/client-module-map.ts +58 -0
  90. package/src/server/csrf.ts +79 -0
  91. package/src/server/deny-renderer.ts +302 -0
  92. package/src/server/dev-logger.ts +419 -0
  93. package/src/server/dev-span-processor.ts +78 -0
  94. package/src/server/dev-warnings.ts +282 -0
  95. package/src/server/early-hints-sender.ts +55 -0
  96. package/src/server/early-hints.ts +142 -0
  97. package/src/server/error-boundary-wrapper.ts +69 -0
  98. package/src/server/error-formatter.ts +184 -0
  99. package/src/server/flush.ts +182 -0
  100. package/src/server/form-data.ts +176 -0
  101. package/src/server/form-flash.ts +93 -0
  102. package/src/server/html-injectors.ts +445 -0
  103. package/src/server/index.ts +222 -0
  104. package/src/server/instrumentation.ts +136 -0
  105. package/src/server/logger.ts +145 -0
  106. package/src/server/manifest-status-resolver.ts +215 -0
  107. package/src/server/metadata-render.ts +527 -0
  108. package/src/server/metadata-routes.ts +189 -0
  109. package/src/server/metadata.ts +263 -0
  110. package/src/server/middleware-runner.ts +32 -0
  111. package/src/server/nuqs-ssr-provider.tsx +63 -0
  112. package/src/server/pipeline.ts +555 -0
  113. package/src/server/prerender.ts +139 -0
  114. package/src/server/primitives.ts +264 -0
  115. package/src/server/proxy.ts +43 -0
  116. package/src/server/request-context.ts +554 -0
  117. package/src/server/route-element-builder.ts +395 -0
  118. package/src/server/route-handler.ts +153 -0
  119. package/src/server/route-matcher.ts +316 -0
  120. package/src/server/rsc-entry/api-handler.ts +112 -0
  121. package/src/server/rsc-entry/error-renderer.ts +177 -0
  122. package/src/server/rsc-entry/helpers.ts +147 -0
  123. package/src/server/rsc-entry/index.ts +688 -0
  124. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  125. package/src/server/slot-resolver.ts +359 -0
  126. package/src/server/ssr-entry.ts +161 -0
  127. package/src/server/ssr-render.ts +200 -0
  128. package/src/server/status-code-resolver.ts +282 -0
  129. package/src/server/tracing.ts +281 -0
  130. package/src/server/tree-builder.ts +354 -0
  131. package/src/server/types.ts +150 -0
  132. package/src/shims/font-google.ts +67 -0
  133. package/src/shims/headers.ts +11 -0
  134. package/src/shims/image.ts +48 -0
  135. package/src/shims/link.ts +9 -0
  136. package/src/shims/navigation-client.ts +52 -0
  137. package/src/shims/navigation.ts +31 -0
  138. package/src/shims/server-only-noop.js +5 -0
  139. package/src/utils/directive-parser.ts +529 -0
  140. package/src/utils/format.ts +10 -0
  141. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,473 @@
1
+ /**
2
+ * createActionClient — typed middleware and schema validation for server actions.
3
+ *
4
+ * Inspired by next-safe-action. Provides a builder API:
5
+ * createActionClient({ middleware }) → .schema(z.object(...)) → .action(fn)
6
+ *
7
+ * The resulting action function satisfies both:
8
+ * 1. Direct call: action(input) → Promise<ActionResult>
9
+ * 2. React useActionState: (prevState, formData) => Promise<ActionResult>
10
+ *
11
+ * See design/08-forms-and-actions.md §"Middleware for Server Actions"
12
+ */
13
+
14
+ // ─── ActionError ─────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Typed error class for server actions. Carries a string code and optional data.
18
+ * When thrown from middleware or the action body, the action short-circuits and
19
+ * the client receives `result.serverError`.
20
+ *
21
+ * In production, unexpected errors (non-ActionError) return `{ code: 'INTERNAL_ERROR' }`
22
+ * with no message. In dev, `data.message` is included.
23
+ */
24
+ export class ActionError<TCode extends string = string> extends Error {
25
+ readonly code: TCode;
26
+ readonly data: Record<string, unknown> | undefined;
27
+
28
+ constructor(code: TCode, data?: Record<string, unknown>) {
29
+ super(`ActionError: ${code}`);
30
+ this.name = 'ActionError';
31
+ this.code = code;
32
+ this.data = data;
33
+ }
34
+ }
35
+
36
+ // ─── Standard Schema ──────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Standard Schema v1 interface (subset).
40
+ * Zod ≥3.24, Valibot ≥1.0, and ArkType all implement this.
41
+ * See https://github.com/standard-schema/standard-schema
42
+ *
43
+ * We use permissive types here to accept all compliant libraries without
44
+ * requiring exact structural matches on issues/path shapes.
45
+ */
46
+ interface StandardSchemaV1<Output = unknown> {
47
+ '~standard': {
48
+ validate(value: unknown): StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
49
+ };
50
+ }
51
+
52
+ type StandardSchemaResult<Output> =
53
+ | { value: Output; issues?: undefined }
54
+ | { value?: undefined; issues: ReadonlyArray<StandardSchemaIssue> };
55
+
56
+ interface StandardSchemaIssue {
57
+ message: string;
58
+ path?: ReadonlyArray<PropertyKey | { key: PropertyKey }>;
59
+ }
60
+
61
+ /** Check if a schema implements the Standard Schema protocol. */
62
+ function isStandardSchema(schema: unknown): schema is StandardSchemaV1 {
63
+ return (
64
+ typeof schema === 'object' &&
65
+ schema !== null &&
66
+ '~standard' in schema &&
67
+ typeof (schema as StandardSchemaV1)['~standard'].validate === 'function'
68
+ );
69
+ }
70
+
71
+ // ─── Types ───────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Minimal schema interface — compatible with Zod, Valibot, ArkType, etc.
75
+ *
76
+ * Accepts either:
77
+ * - Standard Schema (preferred): any object with `~standard.validate()`
78
+ * - Legacy parse interface: objects with `.parse()` / `.safeParse()`
79
+ *
80
+ * At runtime, Standard Schema is detected via `~standard` property and
81
+ * takes priority over the legacy interface.
82
+ */
83
+ export type ActionSchema<T = unknown> = StandardSchemaV1<T> | LegacyActionSchema<T>;
84
+
85
+ /** Legacy schema interface with .parse() / .safeParse(). */
86
+ interface LegacyActionSchema<T = unknown> {
87
+ 'parse'(data: unknown): T;
88
+ 'safeParse'?(data: unknown): { success: true; data: T } | { success: false; error: SchemaError };
89
+ // Exclude Standard Schema objects from matching this interface
90
+ '~standard'?: never;
91
+ }
92
+
93
+ /** Schema validation error shape (for legacy .safeParse()/.parse() interface). */
94
+ export interface SchemaError {
95
+ issues?: Array<{ path?: Array<string | number>; message: string }>;
96
+ flatten?(): { fieldErrors: Record<string, string[]> };
97
+ }
98
+
99
+ /** Flattened validation errors keyed by field name. */
100
+ export type ValidationErrors = Record<string, string[]>;
101
+
102
+ /** Middleware function: returns context to merge into the action body's ctx. */
103
+ export type ActionMiddleware<TCtx = Record<string, unknown>> = () => Promise<TCtx> | TCtx;
104
+
105
+ /** The result type returned to the client. */
106
+ export type ActionResult<TData = unknown> =
107
+ | { data: TData; validationErrors?: never; serverError?: never; submittedValues?: never }
108
+ | {
109
+ data?: never;
110
+ validationErrors: ValidationErrors;
111
+ serverError?: never;
112
+ /** Raw input values on validation failure — for repopulating form fields. */
113
+ submittedValues?: Record<string, unknown>;
114
+ }
115
+ | {
116
+ data?: never;
117
+ validationErrors?: never;
118
+ serverError: { code: string; data?: Record<string, unknown> };
119
+ submittedValues?: never;
120
+ };
121
+
122
+ /** Context passed to the action body. */
123
+ export interface ActionContext<TCtx, TInput> {
124
+ ctx: TCtx;
125
+ input: TInput;
126
+ }
127
+
128
+ // ─── Builder ─────────────────────────────────────────────────────────────
129
+
130
+ interface ActionClientConfig<TCtx> {
131
+ middleware?: ActionMiddleware<TCtx> | ActionMiddleware<Record<string, unknown>>[];
132
+ /** Max file size in bytes. Files exceeding this are rejected with validation errors. */
133
+ fileSizeLimit?: number;
134
+ }
135
+
136
+ /** Intermediate builder returned by createActionClient(). */
137
+ export interface ActionBuilder<TCtx> {
138
+ /** Declare the input schema. Validation errors are returned typed. */
139
+ schema<TInput>(schema: ActionSchema<TInput>): ActionBuilderWithSchema<TCtx, TInput>;
140
+ /** Define the action body without input validation. */
141
+ action<TData>(fn: (ctx: ActionContext<TCtx, undefined>) => Promise<TData>): ActionFn<TData>;
142
+ }
143
+
144
+ /** Builder after .schema() has been called. */
145
+ export interface ActionBuilderWithSchema<TCtx, TInput> {
146
+ /** Define the action body with validated input. */
147
+ action<TData>(fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>): ActionFn<TData>;
148
+ }
149
+
150
+ /**
151
+ * The final action function. Callable two ways:
152
+ * - Direct: action(input) → Promise<ActionResult<TData>>
153
+ * - React useActionState: action(prevState, formData) → Promise<ActionResult<TData>>
154
+ */
155
+ export type ActionFn<TData> = {
156
+ (input?: unknown): Promise<ActionResult<TData>>;
157
+ (prevState: ActionResult<TData> | null, formData: FormData): Promise<ActionResult<TData>>;
158
+ };
159
+
160
+ // ─── Implementation ──────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Run middleware array or single function. Returns merged context.
164
+ */
165
+ async function runActionMiddleware<TCtx>(
166
+ middleware: ActionMiddleware<TCtx> | ActionMiddleware<Record<string, unknown>>[] | undefined
167
+ ): Promise<TCtx> {
168
+ if (!middleware) {
169
+ return {} as TCtx;
170
+ }
171
+
172
+ if (Array.isArray(middleware)) {
173
+ let merged = {} as Record<string, unknown>;
174
+ for (const mw of middleware) {
175
+ const result = await mw();
176
+ merged = { ...merged, ...result };
177
+ }
178
+ return merged as TCtx;
179
+ }
180
+
181
+ return await middleware();
182
+ }
183
+
184
+ // Re-export parseFormData for use throughout the framework
185
+ import { parseFormData } from './form-data.js';
186
+ import { formatSize } from '#/utils/format.js';
187
+
188
+ /**
189
+ * Extract validation errors from a schema error.
190
+ * Supports Zod's flatten() and generic issues array.
191
+ */
192
+ function extractValidationErrors(error: SchemaError): ValidationErrors {
193
+ // Zod-style flatten
194
+ if (typeof error.flatten === 'function') {
195
+ return error.flatten().fieldErrors;
196
+ }
197
+
198
+ // Generic issues array
199
+ if (error.issues) {
200
+ const errors: ValidationErrors = {};
201
+ for (const issue of error.issues) {
202
+ const path = issue.path?.join('.') ?? '_root';
203
+ if (!errors[path]) errors[path] = [];
204
+ errors[path].push(issue.message);
205
+ }
206
+ return errors;
207
+ }
208
+
209
+ return { _root: ['Validation failed'] };
210
+ }
211
+
212
+ /**
213
+ * Extract validation errors from Standard Schema issues.
214
+ */
215
+ function extractStandardSchemaErrors(issues: ReadonlyArray<StandardSchemaIssue>): ValidationErrors {
216
+ const errors: ValidationErrors = {};
217
+ for (const issue of issues) {
218
+ const path =
219
+ issue.path
220
+ ?.map((p) => {
221
+ // Standard Schema path items can be { key: ... } objects or bare PropertyKey values
222
+ if (typeof p === 'object' && p !== null && 'key' in p) return String(p.key);
223
+ return String(p);
224
+ })
225
+ .join('.') ?? '_root';
226
+ if (!errors[path]) errors[path] = [];
227
+ errors[path].push(issue.message);
228
+ }
229
+ return Object.keys(errors).length > 0 ? errors : { _root: ['Validation failed'] };
230
+ }
231
+
232
+ /**
233
+ * Wrap unexpected errors into a safe server error result.
234
+ * ActionError → typed result. Other errors → INTERNAL_ERROR (no leak).
235
+ *
236
+ * Exported for use by action-handler.ts to catch errors from raw 'use server'
237
+ * functions that don't use createActionClient.
238
+ */
239
+ export function handleActionError(error: unknown): ActionResult<never> {
240
+ if (error instanceof ActionError) {
241
+ return {
242
+ serverError: {
243
+ code: error.code,
244
+ ...(error.data ? { data: error.data } : {}),
245
+ },
246
+ };
247
+ }
248
+
249
+ // In dev, include the message for debugging
250
+ const isDev = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
251
+ return {
252
+ serverError: {
253
+ code: 'INTERNAL_ERROR',
254
+ ...(isDev && error instanceof Error ? { data: { message: error.message } } : {}),
255
+ },
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Create a typed action client with middleware and schema validation.
261
+ *
262
+ * @example
263
+ * ```ts
264
+ * const action = createActionClient({
265
+ * middleware: async () => {
266
+ * const user = await getUser()
267
+ * if (!user) throw new ActionError('UNAUTHORIZED')
268
+ * return { user }
269
+ * },
270
+ * })
271
+ *
272
+ * export const createTodo = action
273
+ * .schema(z.object({ title: z.string().min(1) }))
274
+ * .action(async ({ input, ctx }) => {
275
+ * await db.todos.create({ ...input, userId: ctx.user.id })
276
+ * })
277
+ * ```
278
+ */
279
+ export function createActionClient<TCtx = Record<string, never>>(
280
+ config: ActionClientConfig<TCtx> = {}
281
+ ): ActionBuilder<TCtx> {
282
+ function buildAction<TInput, TData>(
283
+ schema: ActionSchema<TInput> | undefined,
284
+ fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>
285
+ ): ActionFn<TData> {
286
+ async function actionHandler(...args: unknown[]): Promise<ActionResult<TData>> {
287
+ try {
288
+ // Run middleware
289
+ const ctx = await runActionMiddleware(config.middleware);
290
+
291
+ // Determine input — either FormData (from useActionState) or direct arg
292
+ let rawInput: unknown;
293
+ if (args.length === 2 && args[1] instanceof FormData) {
294
+ // Called as (prevState, formData) by React useActionState
295
+ rawInput = schema ? parseFormData(args[1]) : args[1];
296
+ } else {
297
+ // Direct call: action(input)
298
+ rawInput = args[0];
299
+ }
300
+
301
+ // Validate file sizes before schema validation.
302
+ if (config.fileSizeLimit !== undefined && rawInput && typeof rawInput === 'object') {
303
+ const fileSizeErrors = validateFileSizes(
304
+ rawInput as Record<string, unknown>,
305
+ config.fileSizeLimit
306
+ );
307
+ if (fileSizeErrors) {
308
+ const submittedValues = stripFiles(rawInput);
309
+ return { validationErrors: fileSizeErrors, submittedValues };
310
+ }
311
+ }
312
+
313
+ // Capture submitted values for repopulation on validation failure.
314
+ // Exclude File objects (can't serialize, shouldn't echo back).
315
+ const submittedValues = schema ? stripFiles(rawInput) : undefined;
316
+
317
+ // Validate with schema if provided
318
+ let input: TInput;
319
+ if (schema) {
320
+ if (isStandardSchema(schema)) {
321
+ // Standard Schema protocol (Zod ≥3.24, Valibot ≥1.0, ArkType)
322
+ const result = schema['~standard'].validate(rawInput);
323
+ if (result instanceof Promise) {
324
+ throw new Error(
325
+ '[timber] createActionClient: schema returned a Promise — only sync schemas are supported.'
326
+ );
327
+ }
328
+ if (result.issues) {
329
+ const validationErrors = extractStandardSchemaErrors(result.issues);
330
+ logValidationFailure(validationErrors);
331
+ return { validationErrors, submittedValues };
332
+ }
333
+ input = result.value;
334
+ } else if (typeof schema.safeParse === 'function') {
335
+ const result = schema.safeParse(rawInput);
336
+ if (!result.success) {
337
+ const validationErrors = extractValidationErrors(result.error);
338
+ logValidationFailure(validationErrors);
339
+ return { validationErrors, submittedValues };
340
+ }
341
+ input = result.data;
342
+ } else {
343
+ try {
344
+ input = schema.parse(rawInput);
345
+ } catch (parseError) {
346
+ const validationErrors = extractValidationErrors(parseError as SchemaError);
347
+ logValidationFailure(validationErrors);
348
+ return { validationErrors, submittedValues };
349
+ }
350
+ }
351
+ } else {
352
+ input = rawInput as TInput;
353
+ }
354
+
355
+ // Execute the action body
356
+ const data = await fn({ ctx, input });
357
+ return { data };
358
+ } catch (error) {
359
+ return handleActionError(error);
360
+ }
361
+ }
362
+
363
+ return actionHandler as ActionFn<TData>;
364
+ }
365
+
366
+ return {
367
+ schema<TInput>(schema: ActionSchema<TInput>) {
368
+ return {
369
+ action<TData>(fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>): ActionFn<TData> {
370
+ return buildAction(schema, fn);
371
+ },
372
+ };
373
+ },
374
+ action<TData>(fn: (ctx: ActionContext<TCtx, undefined>) => Promise<TData>): ActionFn<TData> {
375
+ return buildAction(undefined, fn as (ctx: ActionContext<TCtx, unknown>) => Promise<TData>);
376
+ },
377
+ };
378
+ }
379
+
380
+ // ─── validated() ────────────────────────────────────────────────────────
381
+
382
+ /**
383
+ * Convenience wrapper for the common case: validate input, run handler.
384
+ * No middleware needed.
385
+ *
386
+ * @example
387
+ * ```ts
388
+ * 'use server'
389
+ * import { validated } from '@timber/app/server'
390
+ * import { z } from 'zod'
391
+ *
392
+ * export const createTodo = validated(
393
+ * z.object({ title: z.string().min(1) }),
394
+ * async (input) => {
395
+ * await db.todos.create(input)
396
+ * }
397
+ * )
398
+ * ```
399
+ */
400
+ export function validated<TInput, TData>(
401
+ schema: ActionSchema<TInput>,
402
+ handler: (input: TInput) => Promise<TData>
403
+ ): ActionFn<TData> {
404
+ return createActionClient()
405
+ .schema(schema)
406
+ .action(async ({ input }) => handler(input));
407
+ }
408
+
409
+ // ─── Helpers ────────────────────────────────────────────────────────────
410
+
411
+ /**
412
+ * Log validation failures in dev mode so developers can see what went wrong.
413
+ * In production, validation errors are only returned to the client.
414
+ */
415
+ function logValidationFailure(errors: ValidationErrors): void {
416
+ const isDev = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
417
+ if (!isDev) return;
418
+
419
+ const fields = Object.entries(errors)
420
+ .map(([field, messages]) => ` ${field}: ${messages.join(', ')}`)
421
+ .join('\n');
422
+ console.warn(`[timber] action schema validation failed:\n${fields}`);
423
+ }
424
+
425
+ /**
426
+ * Validate that all File objects in the input are within the size limit.
427
+ * Returns validation errors keyed by field name, or null if all files are ok.
428
+ */
429
+ function validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {
430
+ const errors: ValidationErrors = {};
431
+ const limitKb = Math.round(limit / 1024);
432
+ const limitLabel =
433
+ limit >= 1024 * 1024 ? `${Math.round(limit / (1024 * 1024))}MB` : `${limitKb}KB`;
434
+
435
+ for (const [key, value] of Object.entries(input)) {
436
+ if (value instanceof File && value.size > limit) {
437
+ errors[key] = [
438
+ `File "${value.name}" (${formatSize(value.size)}) exceeds the ${limitLabel} limit`,
439
+ ];
440
+ } else if (Array.isArray(value)) {
441
+ const oversized = value.filter((item) => item instanceof File && item.size > limit);
442
+ if (oversized.length > 0) {
443
+ errors[key] = oversized.map(
444
+ (f: File) => `File "${f.name}" (${formatSize(f.size)}) exceeds the ${limitLabel} limit`
445
+ );
446
+ }
447
+ }
448
+ }
449
+
450
+ return Object.keys(errors).length > 0 ? errors : null;
451
+ }
452
+
453
+ /**
454
+ * Strip File objects from a value, returning a plain object safe for
455
+ * serialization. File objects can't be serialized and shouldn't be echoed back.
456
+ */
457
+ function stripFiles(value: unknown): Record<string, unknown> | undefined {
458
+ if (value === null || value === undefined) return undefined;
459
+ if (typeof value !== 'object') return undefined;
460
+
461
+ const result: Record<string, unknown> = {};
462
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
463
+ if (v instanceof File) continue;
464
+ if (Array.isArray(v)) {
465
+ result[k] = v.filter((item) => !(item instanceof File));
466
+ } else if (typeof v === 'object' && v !== null && !(v instanceof File)) {
467
+ result[k] = stripFiles(v) ?? {};
468
+ } else {
469
+ result[k] = v;
470
+ }
471
+ }
472
+ return result;
473
+ }