@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,184 @@
1
+ /**
2
+ * Error Formatter — rewrites SSR/RSC error messages to surface user code.
3
+ *
4
+ * When React or Vite throw errors during SSR, stack traces reference
5
+ * vendored dependency paths (e.g. `.vite/deps_ssr/@vitejs_plugin-rsc_vendor_...`)
6
+ * and mangled export names (`__vite_ssr_export_default__`). This module
7
+ * rewrites error messages and stack traces to point at user code instead.
8
+ *
9
+ * Dev-only — in production, errors go through the structured logger
10
+ * without formatting.
11
+ */
12
+
13
+ // ─── Stack Trace Rewriting ──────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Patterns that identify internal Vite/RSC vendor paths in stack traces.
17
+ * These are replaced with human-readable labels.
18
+ */
19
+ const VENDOR_PATH_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
20
+ {
21
+ pattern: /node_modules\/\.vite\/deps_ssr\/@vitejs_plugin-rsc_vendor_react-server-dom[^\s)]+/g,
22
+ replacement: '<react-server-dom>',
23
+ },
24
+ {
25
+ pattern: /node_modules\/\.vite\/deps_ssr\/@vitejs_plugin-rsc_vendor[^\s)]+/g,
26
+ replacement: '<rsc-vendor>',
27
+ },
28
+ {
29
+ pattern: /node_modules\/\.vite\/deps_ssr\/[^\s)]+/g,
30
+ replacement: '<vite-dep>',
31
+ },
32
+ {
33
+ pattern: /node_modules\/\.vite\/deps\/[^\s)]+/g,
34
+ replacement: '<vite-dep>',
35
+ },
36
+ ];
37
+
38
+ /**
39
+ * Patterns that identify Vite-mangled export names in error messages.
40
+ */
41
+ const MANGLED_NAME_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
42
+ {
43
+ pattern: /__vite_ssr_export_default__/g,
44
+ replacement: '<default export>',
45
+ },
46
+ {
47
+ pattern: /__vite_ssr_export_(\w+)__/g,
48
+ replacement: '<export $1>',
49
+ },
50
+ ];
51
+
52
+ /**
53
+ * Rewrite an error's message and stack to replace internal Vite paths
54
+ * and mangled names with human-readable labels.
55
+ */
56
+ export function formatSsrError(error: unknown): string {
57
+ if (!(error instanceof Error)) {
58
+ return String(error);
59
+ }
60
+
61
+ let message = error.message;
62
+ let stack = error.stack ?? '';
63
+
64
+ // Rewrite mangled names in the message
65
+ for (const { pattern, replacement } of MANGLED_NAME_PATTERNS) {
66
+ message = message.replace(pattern, replacement);
67
+ }
68
+
69
+ // Rewrite vendor paths in the stack
70
+ for (const { pattern, replacement } of VENDOR_PATH_PATTERNS) {
71
+ stack = stack.replace(pattern, replacement);
72
+ }
73
+
74
+ // Rewrite mangled names in the stack too
75
+ for (const { pattern, replacement } of MANGLED_NAME_PATTERNS) {
76
+ stack = stack.replace(pattern, replacement);
77
+ }
78
+
79
+ // Extract hints from React-specific error patterns
80
+ const hint = extractErrorHint(error.message);
81
+
82
+ // Build formatted output: cleaned message, hint (if any), then cleaned stack
83
+ const parts: string[] = [];
84
+ parts.push(message);
85
+ if (hint) {
86
+ parts.push(` → ${hint}`);
87
+ }
88
+
89
+ // Include only the user-code frames from the stack (skip the first line
90
+ // which is the message itself, and filter out vendor-only frames)
91
+ const userFrames = extractUserFrames(stack);
92
+ if (userFrames.length > 0) {
93
+ parts.push('');
94
+ parts.push(' User code in stack:');
95
+ for (const frame of userFrames) {
96
+ parts.push(` ${frame}`);
97
+ }
98
+ }
99
+
100
+ return parts.join('\n');
101
+ }
102
+
103
+ // ─── Error Hint Extraction ──────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Extract a human-readable hint from common React/RSC error messages.
107
+ *
108
+ * React error messages contain useful information but the surrounding
109
+ * context (vendor paths, mangled names) obscures it. This extracts the
110
+ * actionable part as a one-line hint.
111
+ */
112
+ function extractErrorHint(message: string): string | null {
113
+ // "Functions cannot be passed directly to Client Components"
114
+ // Extract the component and prop name from the JSX-like syntax in the message
115
+ const fnPassedMatch = message.match(/Functions cannot be passed directly to Client Components/);
116
+ if (fnPassedMatch) {
117
+ // Try to extract the prop name from the message
118
+ // React formats: <... propName={function ...} ...>
119
+ const propMatch = message.match(/<[^>]*?\s(\w+)=\{function/);
120
+ if (propMatch) {
121
+ return `Prop "${propMatch[1]}" is a function — mark it "use server" or call it before passing`;
122
+ }
123
+ return 'A function prop was passed to a Client Component — mark it "use server" or call it before passing';
124
+ }
125
+
126
+ // "Objects are not valid as a React child"
127
+ if (message.includes('Objects are not valid as a React child')) {
128
+ return 'An object was rendered as JSX children — convert to string or extract the value';
129
+ }
130
+
131
+ // "Cannot read properties of undefined/null"
132
+ const nullRefMatch = message.match(
133
+ /Cannot read propert(?:y|ies) of (undefined|null) \(reading '(\w+)'\)/
134
+ );
135
+ if (nullRefMatch) {
136
+ return `Accessed .${nullRefMatch[2]} on ${nullRefMatch[1]} — check that the value exists`;
137
+ }
138
+
139
+ // "X is not a function"
140
+ const notFnMatch = message.match(/(\w+) is not a function/);
141
+ if (notFnMatch) {
142
+ return `"${notFnMatch[1]}" is not a function — check imports and exports`;
143
+ }
144
+
145
+ // "Element type is invalid"
146
+ if (message.includes('Element type is invalid')) {
147
+ return 'A component resolved to undefined/null — check default exports and import paths';
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ // ─── Stack Frame Filtering ──────────────────────────────────────────────────
154
+
155
+ /**
156
+ * Extract stack frames that reference user code (not node_modules,
157
+ * not framework internals).
158
+ *
159
+ * Returns at most 5 frames to keep output concise.
160
+ */
161
+ function extractUserFrames(stack: string): string[] {
162
+ const lines = stack.split('\n');
163
+ const userFrames: string[] = [];
164
+
165
+ for (const line of lines) {
166
+ const trimmed = line.trim();
167
+ // Skip non-frame lines
168
+ if (!trimmed.startsWith('at ')) continue;
169
+ // Skip node_modules, vendor, and internal frames
170
+ if (
171
+ trimmed.includes('node_modules') ||
172
+ trimmed.includes('<react-server-dom>') ||
173
+ trimmed.includes('<rsc-vendor>') ||
174
+ trimmed.includes('<vite-dep>') ||
175
+ trimmed.includes('node:internal')
176
+ ) {
177
+ continue;
178
+ }
179
+ userFrames.push(trimmed);
180
+ if (userFrames.length >= 5) break;
181
+ }
182
+
183
+ return userFrames;
184
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Flush controller for timber.js rendering.
3
+ *
4
+ * Holds the response until `onShellReady` fires, then commits the HTTP status
5
+ * code and flushes the shell. Render-phase signals (deny, redirect, unhandled
6
+ * throws) caught before flush produce correct HTTP status codes.
7
+ *
8
+ * See design/02-rendering-pipeline.md §"The Flush Point" and §"The Hold Window"
9
+ */
10
+
11
+ import { DenySignal, RedirectSignal, RenderError } from './primitives.js';
12
+
13
+ // ─── Types ───────────────────────────────────────────────────────────────────
14
+
15
+ /** The readable stream from React's renderToReadableStream. */
16
+ export interface ReactRenderStream {
17
+ /** The underlying ReadableStream of HTML bytes. */
18
+ readable: ReadableStream<Uint8Array>;
19
+ /** Resolves when the shell has finished rendering (all non-Suspense content). */
20
+ allReady?: Promise<void>;
21
+ }
22
+
23
+ /** Options for the flush controller. */
24
+ export interface FlushOptions {
25
+ /** Response headers to include (from middleware.ts, proxy.ts, etc.). */
26
+ responseHeaders?: Headers;
27
+ /** Default status code when rendering succeeds. Default: 200. */
28
+ defaultStatus?: number;
29
+ }
30
+
31
+ /** Result of the flush process. */
32
+ export interface FlushResult {
33
+ /** The final HTTP Response. */
34
+ response: Response;
35
+ /** The status code committed. */
36
+ status: number;
37
+ /** Whether the response was a redirect. */
38
+ isRedirect: boolean;
39
+ /** Whether the response was a denial. */
40
+ isDenial: boolean;
41
+ }
42
+
43
+ // ─── Render Function Type ────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * A function that performs the React render.
47
+ *
48
+ * The flush controller calls this, catches any signals thrown during the
49
+ * synchronous shell render (before onShellReady), and produces the
50
+ * correct HTTP response.
51
+ *
52
+ * Must return an object with:
53
+ * - `stream`: The ReadableStream from renderToReadableStream
54
+ * - `shellReady`: A Promise that resolves when onShellReady fires
55
+ */
56
+ export interface RenderResult {
57
+ /** The HTML byte stream. */
58
+ stream: ReadableStream<Uint8Array>;
59
+ /** Resolves when the shell is ready (all non-Suspense content rendered). */
60
+ shellReady: Promise<void>;
61
+ }
62
+
63
+ export type RenderFn = () => RenderResult | Promise<RenderResult>;
64
+
65
+ // ─── Flush Controller ────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Execute a render and hold the response until the shell is ready.
69
+ *
70
+ * The flush controller:
71
+ * 1. Calls the render function to start renderToReadableStream
72
+ * 2. Waits for shellReady (onShellReady)
73
+ * 3. If a render-phase signal was thrown (deny, redirect, error), produces
74
+ * the correct HTTP status code
75
+ * 4. If the shell rendered successfully, commits the status and streams
76
+ *
77
+ * Render-phase signals caught before flush:
78
+ * - `DenySignal` → HTTP 4xx with appropriate status code
79
+ * - `RedirectSignal` → HTTP 3xx with Location header
80
+ * - `RenderError` → HTTP status from error (default 500)
81
+ * - Unhandled error → HTTP 500
82
+ *
83
+ * @param renderFn - Function that starts the React render.
84
+ * @param options - Flush configuration.
85
+ * @returns The committed HTTP Response.
86
+ */
87
+ export async function flushResponse(
88
+ renderFn: RenderFn,
89
+ options: FlushOptions = {}
90
+ ): Promise<FlushResult> {
91
+ const { responseHeaders = new Headers(), defaultStatus = 200 } = options;
92
+
93
+ let renderResult: RenderResult;
94
+
95
+ // Phase 1: Start the render. The render function may throw synchronously
96
+ // if there's an immediate error before React even starts.
97
+ try {
98
+ renderResult = await renderFn();
99
+ } catch (error) {
100
+ return handleSignal(error, responseHeaders);
101
+ }
102
+
103
+ // Phase 2: Wait for onShellReady. Render-phase signals (deny, redirect,
104
+ // throws outside Suspense) are caught here.
105
+ try {
106
+ await renderResult.shellReady;
107
+ } catch (error) {
108
+ return handleSignal(error, responseHeaders);
109
+ }
110
+
111
+ // Phase 3: Shell rendered successfully. Commit status and stream.
112
+ responseHeaders.set('Content-Type', 'text/html; charset=utf-8');
113
+
114
+ return {
115
+ response: new Response(renderResult.stream, {
116
+ status: defaultStatus,
117
+ headers: responseHeaders,
118
+ }),
119
+ status: defaultStatus,
120
+ isRedirect: false,
121
+ isDenial: false,
122
+ };
123
+ }
124
+
125
+ // ─── Signal Handling ─────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Handle a render-phase signal and produce the correct HTTP response.
129
+ */
130
+ function handleSignal(error: unknown, responseHeaders: Headers): FlushResult {
131
+ // Redirect signal → HTTP 3xx
132
+ if (error instanceof RedirectSignal) {
133
+ responseHeaders.set('Location', error.location);
134
+ return {
135
+ response: new Response(null, {
136
+ status: error.status,
137
+ headers: responseHeaders,
138
+ }),
139
+ status: error.status,
140
+ isRedirect: true,
141
+ isDenial: false,
142
+ };
143
+ }
144
+
145
+ // Deny signal → HTTP 4xx
146
+ if (error instanceof DenySignal) {
147
+ return {
148
+ response: new Response(null, {
149
+ status: error.status,
150
+ headers: responseHeaders,
151
+ }),
152
+ status: error.status,
153
+ isRedirect: false,
154
+ isDenial: true,
155
+ };
156
+ }
157
+
158
+ // RenderError → HTTP status from error
159
+ if (error instanceof RenderError) {
160
+ return {
161
+ response: new Response(null, {
162
+ status: error.status,
163
+ headers: responseHeaders,
164
+ }),
165
+ status: error.status,
166
+ isRedirect: false,
167
+ isDenial: false,
168
+ };
169
+ }
170
+
171
+ // Unknown error → HTTP 500
172
+ console.error('[timber] Unhandled render-phase error:', error);
173
+ return {
174
+ response: new Response(null, {
175
+ status: 500,
176
+ headers: responseHeaders,
177
+ }),
178
+ status: 500,
179
+ isRedirect: false,
180
+ isDenial: false,
181
+ };
182
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * FormData preprocessing — schema-agnostic conversion of FormData to typed objects.
3
+ *
4
+ * FormData is all strings. Schema validation expects typed values. This module
5
+ * bridges the gap with intelligent coercion that runs *before* schema validation.
6
+ *
7
+ * Inspired by zod-form-data, but schema-agnostic — works with any Standard Schema
8
+ * library (Zod, Valibot, ArkType).
9
+ *
10
+ * See design/08-forms-and-actions.md §"parseFormData() and coerce helpers"
11
+ */
12
+
13
+ // ─── parseFormData ───────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Convert FormData into a plain object with intelligent coercion.
17
+ *
18
+ * Handles:
19
+ * - **Duplicate keys → arrays**: `tags=js&tags=ts` → `{ tags: ["js", "ts"] }`
20
+ * - **Nested dot-paths**: `user.name=Alice` → `{ user: { name: "Alice" } }`
21
+ * - **Empty strings → undefined**: Enables `.optional()` semantics in schemas
22
+ * - **Empty Files → undefined**: File inputs with no selection become `undefined`
23
+ * - **Strips `$ACTION_*` fields**: React's internal hidden fields are excluded
24
+ */
25
+ export function parseFormData(formData: FormData): Record<string, unknown> {
26
+ const flat: Record<string, unknown> = {};
27
+
28
+ for (const key of new Set(formData.keys())) {
29
+ // Skip React internal fields
30
+ if (key.startsWith('$ACTION_')) continue;
31
+
32
+ const values = formData.getAll(key);
33
+ const processed = values.map(normalizeValue);
34
+
35
+ if (processed.length === 1) {
36
+ flat[key] = processed[0];
37
+ } else {
38
+ // Filter out undefined entries from multi-value fields
39
+ flat[key] = processed.filter((v) => v !== undefined);
40
+ }
41
+ }
42
+
43
+ // Expand dot-notation paths into nested objects
44
+ return expandDotPaths(flat);
45
+ }
46
+
47
+ /**
48
+ * Normalize a single FormData entry value.
49
+ * - Empty strings → undefined (enables .optional() semantics)
50
+ * - Empty File objects (no selection) → undefined
51
+ * - Everything else passes through as-is
52
+ */
53
+ function normalizeValue(value: FormDataEntryValue): unknown {
54
+ if (typeof value === 'string') {
55
+ return value === '' ? undefined : value;
56
+ }
57
+
58
+ // File input with no selection: browsers submit a File with name="" and size=0
59
+ if (value instanceof File && value.size === 0 && value.name === '') {
60
+ return undefined;
61
+ }
62
+
63
+ return value;
64
+ }
65
+
66
+ /**
67
+ * Expand dot-notation keys into nested objects.
68
+ * `{ "user.name": "Alice", "user.age": "30" }` → `{ user: { name: "Alice", age: "30" } }`
69
+ *
70
+ * Keys without dots are left as-is. Bracket notation (e.g. `items[0]`) is NOT
71
+ * supported — use dot notation (`items.0`) instead.
72
+ */
73
+ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown> {
74
+ const result: Record<string, unknown> = {};
75
+ let hasDotPaths = false;
76
+
77
+ // First pass: check if any keys have dots
78
+ for (const key of Object.keys(flat)) {
79
+ if (key.includes('.')) {
80
+ hasDotPaths = true;
81
+ break;
82
+ }
83
+ }
84
+
85
+ // Fast path: no dot-notation keys, return as-is
86
+ if (!hasDotPaths) return flat;
87
+
88
+ for (const [key, value] of Object.entries(flat)) {
89
+ if (!key.includes('.')) {
90
+ result[key] = value;
91
+ continue;
92
+ }
93
+
94
+ const parts = key.split('.');
95
+ let current: Record<string, unknown> = result;
96
+
97
+ for (let i = 0; i < parts.length - 1; i++) {
98
+ const part = parts[i];
99
+ if (current[part] === undefined || current[part] === null) {
100
+ current[part] = {};
101
+ }
102
+ // If current[part] is not an object (e.g., a string from a non-dotted key),
103
+ // the dot-path takes precedence
104
+ if (typeof current[part] !== 'object' || current[part] instanceof File) {
105
+ current[part] = {};
106
+ }
107
+ current = current[part] as Record<string, unknown>;
108
+ }
109
+
110
+ current[parts[parts.length - 1]] = value;
111
+ }
112
+
113
+ return result;
114
+ }
115
+
116
+ // ─── Coercion Helpers ────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Schema-agnostic coercion primitives for common FormData patterns.
120
+ *
121
+ * These are plain transform functions — they compose with any schema library's
122
+ * `transform`/`preprocess` pipeline:
123
+ *
124
+ * ```ts
125
+ * // Zod
126
+ * z.preprocess(coerce.number, z.number())
127
+ * // Valibot
128
+ * v.pipe(v.unknown(), v.transform(coerce.number), v.number())
129
+ * ```
130
+ */
131
+ export const coerce = {
132
+ /**
133
+ * Coerce a string to a number.
134
+ * - `"42"` → `42`
135
+ * - `"3.14"` → `3.14`
136
+ * - `""` / `undefined` / `null` → `undefined`
137
+ * - Non-numeric strings → `undefined` (schema validation will catch this)
138
+ */
139
+ number(value: unknown): number | undefined {
140
+ if (value === undefined || value === null || value === '') return undefined;
141
+ if (typeof value === 'number') return value;
142
+ if (typeof value !== 'string') return undefined;
143
+ const num = Number(value);
144
+ if (Number.isNaN(num)) return undefined;
145
+ return num;
146
+ },
147
+
148
+ /**
149
+ * Coerce a checkbox value to a boolean.
150
+ * HTML checkboxes submit "on" when checked and are absent when unchecked.
151
+ * - `"on"` / any truthy string → `true`
152
+ * - `undefined` / `null` / `""` → `false`
153
+ */
154
+ checkbox(value: unknown): boolean {
155
+ if (value === undefined || value === null || value === '') return false;
156
+ if (typeof value === 'boolean') return value;
157
+ // Any non-empty string (typically "on") is true
158
+ return typeof value === 'string' && value.length > 0;
159
+ },
160
+
161
+ /**
162
+ * Parse a JSON string into an object.
163
+ * - Valid JSON string → parsed object
164
+ * - `""` / `undefined` / `null` → `undefined`
165
+ * - Invalid JSON → `undefined` (schema validation will catch this)
166
+ */
167
+ json(value: unknown): unknown {
168
+ if (value === undefined || value === null || value === '') return undefined;
169
+ if (typeof value !== 'string') return value;
170
+ try {
171
+ return JSON.parse(value);
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ },
176
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Form Flash — ALS-based store for no-JS form action results.
3
+ *
4
+ * When a no-JS form action completes, the server re-renders the page with
5
+ * the action result injected via AsyncLocalStorage instead of redirecting
6
+ * (which would discard the result). Server components read the flash and
7
+ * pass it to client form components as the initial `useActionState` value.
8
+ *
9
+ * This follows the Remix/Rails pattern — the form component becomes the
10
+ * single source of truth for both with-JS (React state) and no-JS (flash).
11
+ *
12
+ * The flash data is server-side only — never serialized to cookies or headers.
13
+ *
14
+ * See design/08-forms-and-actions.md §"No-JS Error Round-Trip"
15
+ */
16
+
17
+ import { AsyncLocalStorage } from 'node:async_hooks';
18
+ import type { ValidationErrors } from './action-client.js';
19
+
20
+ // ─── Types ───────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Flash data injected into the re-render after a no-JS form submission.
24
+ *
25
+ * This is the action result from the server action, stored in ALS so server
26
+ * components can read it and pass it to client form components as the initial
27
+ * state for `useActionState`. This makes the form component a single source
28
+ * of truth for both with-JS and no-JS paths.
29
+ *
30
+ * The shape matches `ActionResult<unknown>` — it's one of:
31
+ * - `{ data: ... }` — success
32
+ * - `{ validationErrors, submittedValues }` — validation failure
33
+ * - `{ serverError }` — server error
34
+ */
35
+ export interface FormFlashData {
36
+ /** Success data from the action. */
37
+ data?: unknown;
38
+ /** Validation errors keyed by field name. `_root` for form-level errors. */
39
+ validationErrors?: ValidationErrors;
40
+ /** Raw submitted values for repopulating form fields. File objects are excluded. */
41
+ submittedValues?: Record<string, unknown>;
42
+ /** Server error if the action threw an ActionError. */
43
+ serverError?: { code: string; data?: Record<string, unknown> };
44
+ }
45
+
46
+ // ─── ALS Store ───────────────────────────────────────────────────────────
47
+
48
+ const formFlashAls = new AsyncLocalStorage<FormFlashData>();
49
+
50
+ // ─── Public API ──────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Read the form flash data for the current request.
54
+ *
55
+ * Returns `null` if no flash data is present (i.e., this is a normal page
56
+ * render, not a re-render after a no-JS form submission).
57
+ *
58
+ * Pass the flash as the initial state to `useActionState` so the form
59
+ * component has a single source of truth for both with-JS and no-JS paths:
60
+ *
61
+ * ```tsx
62
+ * // app/contact/page.tsx (server component)
63
+ * import { getFormFlash } from '@timber/app/server'
64
+ *
65
+ * export default function ContactPage() {
66
+ * const flash = getFormFlash()
67
+ * return <ContactForm flash={flash} />
68
+ * }
69
+ *
70
+ * // app/contact/form.tsx (client component)
71
+ * export function ContactForm({ flash }) {
72
+ * const [result, action, isPending] = useActionState(submitContact, flash)
73
+ * // result is the single source of truth — flash seeds it on no-JS
74
+ * }
75
+ * ```
76
+ */
77
+ export function getFormFlash(): FormFlashData | null {
78
+ return formFlashAls.getStore() ?? null;
79
+ }
80
+
81
+ // ─── Framework-Internal ──────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Run a callback with form flash data in scope.
85
+ *
86
+ * Used by the action handler to re-render the page with validation errors
87
+ * available via `getFormFlash()`. Not part of the public API.
88
+ *
89
+ * @internal
90
+ */
91
+ export function runWithFormFlash<T>(data: FormFlashData, fn: () => T): T {
92
+ return formFlashAls.run(data, fn);
93
+ }