@timber-js/app 0.1.0 → 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.
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +43 -58
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- 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
|
+
}
|