@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.
- 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 +2 -1
- 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,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Action Request Handler — dispatches incoming action POST requests.
|
|
3
|
+
*
|
|
4
|
+
* Handles both JS-enabled (RSC) and no-JS (HTML form POST) action submissions.
|
|
5
|
+
* Wired into the RSC entry to intercept action requests before the render pipeline.
|
|
6
|
+
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* 1. Detect action request (POST with `x-rsc-action` header or form action fields)
|
|
9
|
+
* 2. CSRF validation
|
|
10
|
+
* 3. Load and execute the server action
|
|
11
|
+
* 4. Return RSC stream (with-JS) or 302 redirect (no-JS)
|
|
12
|
+
*
|
|
13
|
+
* See design/08-forms-and-actions.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
loadServerAction,
|
|
18
|
+
decodeReply,
|
|
19
|
+
decodeAction,
|
|
20
|
+
renderToReadableStream,
|
|
21
|
+
} from '@vitejs/plugin-rsc/rsc';
|
|
22
|
+
|
|
23
|
+
import { validateCsrf, type CsrfConfig } from './csrf.js';
|
|
24
|
+
import { executeAction, type RevalidateRenderer } from './actions.js';
|
|
25
|
+
import {
|
|
26
|
+
runWithRequestContext,
|
|
27
|
+
setMutableCookieContext,
|
|
28
|
+
getSetCookieHeaders,
|
|
29
|
+
} from './request-context.js';
|
|
30
|
+
import { handleActionError } from './action-client.js';
|
|
31
|
+
import { enforceBodyLimits, enforceFieldLimit, type BodyLimitsConfig } from './body-limits.js';
|
|
32
|
+
import { parseFormData } from './form-data.js';
|
|
33
|
+
import type { FormFlashData } from './form-flash.js';
|
|
34
|
+
|
|
35
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Configuration for the action handler. */
|
|
38
|
+
export interface ActionDispatchConfig {
|
|
39
|
+
/** CSRF configuration. */
|
|
40
|
+
csrf: CsrfConfig;
|
|
41
|
+
/** Renderer for revalidatePath — produces RSC flight payloads. */
|
|
42
|
+
revalidateRenderer?: RevalidateRenderer;
|
|
43
|
+
/** Body size limits (from timber.config.ts). */
|
|
44
|
+
bodyLimits?: BodyLimitsConfig;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Constants ────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const RSC_CONTENT_TYPE = 'text/x-component';
|
|
50
|
+
|
|
51
|
+
// ─── Detection ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if a request is a server action invocation.
|
|
55
|
+
*
|
|
56
|
+
* Two cases:
|
|
57
|
+
* - With JS: POST with `x-rsc-action` header (client callServer dispatch)
|
|
58
|
+
* - Without JS: POST with form data containing `$ACTION_REF` or `$ACTION_KEY`
|
|
59
|
+
* (React's progressive enhancement hidden fields)
|
|
60
|
+
*/
|
|
61
|
+
export function isActionRequest(req: Request): boolean {
|
|
62
|
+
if (req.method !== 'POST') return false;
|
|
63
|
+
|
|
64
|
+
// With-JS case: explicit action header
|
|
65
|
+
if (req.headers.get('x-rsc-action')) return true;
|
|
66
|
+
|
|
67
|
+
// No-JS case: check Content-Type for form data
|
|
68
|
+
const ct = req.headers.get('Content-Type') ?? '';
|
|
69
|
+
if (ct.includes('application/x-www-form-urlencoded') || ct.includes('multipart/form-data')) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Handler ──────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/** Signal from handleFormAction to re-render the page with flash data instead of redirecting. */
|
|
79
|
+
export interface FormRerender {
|
|
80
|
+
rerender: FormFlashData;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Handle a server action request.
|
|
85
|
+
*
|
|
86
|
+
* Returns a Response, a FormRerender signal (for no-JS validation failure re-render),
|
|
87
|
+
* or null if this isn't actually an action request (e.g., a regular form POST to an API route).
|
|
88
|
+
*/
|
|
89
|
+
export async function handleActionRequest(
|
|
90
|
+
req: Request,
|
|
91
|
+
config: ActionDispatchConfig
|
|
92
|
+
): Promise<Response | FormRerender | null> {
|
|
93
|
+
// CSRF validation — reject cross-origin mutation requests.
|
|
94
|
+
const csrfResult = validateCsrf(req, config.csrf);
|
|
95
|
+
if (!csrfResult.ok) {
|
|
96
|
+
return new Response(null, { status: csrfResult.status });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Body size limits — reject oversized requests before parsing.
|
|
100
|
+
// Multipart requests (file uploads) get a higher limit than regular actions.
|
|
101
|
+
const bodyKind = (req.headers.get('Content-Type') ?? '').includes('multipart/form-data')
|
|
102
|
+
? 'upload'
|
|
103
|
+
: 'action';
|
|
104
|
+
const limitsResult = enforceBodyLimits(req, bodyKind, config.bodyLimits ?? {});
|
|
105
|
+
if (!limitsResult.ok) {
|
|
106
|
+
return new Response(null, { status: limitsResult.status });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Run inside request context so headers(), cookies() work in actions.
|
|
110
|
+
// Actions are a mutable context — they can set cookies (design/29-cookies.md).
|
|
111
|
+
return runWithRequestContext(req, async () => {
|
|
112
|
+
setMutableCookieContext(true);
|
|
113
|
+
const actionId = req.headers.get('x-rsc-action');
|
|
114
|
+
|
|
115
|
+
let result: Response | FormRerender | null;
|
|
116
|
+
if (actionId) {
|
|
117
|
+
// With-JS path: client sent action ID in header, args in body
|
|
118
|
+
result = await handleRscAction(req, actionId, config);
|
|
119
|
+
} else {
|
|
120
|
+
// No-JS path: form POST with React's hidden action fields
|
|
121
|
+
result = await handleFormAction(req, config);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Apply cookie jar to action responses
|
|
125
|
+
if (result instanceof Response) {
|
|
126
|
+
for (const value of getSetCookieHeaders()) {
|
|
127
|
+
result.headers.append('Set-Cookie', value);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── With-JS Action ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Handle an RSC action request (JavaScript enabled).
|
|
138
|
+
*
|
|
139
|
+
* The client serialized the action args via `encodeReply` and sent them
|
|
140
|
+
* as the request body. The action ID is in the `x-rsc-action` header.
|
|
141
|
+
*/
|
|
142
|
+
async function handleRscAction(
|
|
143
|
+
req: Request,
|
|
144
|
+
actionId: string,
|
|
145
|
+
config: ActionDispatchConfig
|
|
146
|
+
): Promise<Response> {
|
|
147
|
+
// Load the server action function by reference ID
|
|
148
|
+
const actionFn = (await loadServerAction(actionId)) as (...args: unknown[]) => Promise<unknown>;
|
|
149
|
+
|
|
150
|
+
// Decode the args from the request body (RSC wire format)
|
|
151
|
+
const contentType = req.headers.get('Content-Type') ?? '';
|
|
152
|
+
let args: unknown[];
|
|
153
|
+
|
|
154
|
+
if (contentType.includes('multipart/form-data')) {
|
|
155
|
+
// FormData-based args (file uploads, etc.)
|
|
156
|
+
const formData = await req.formData();
|
|
157
|
+
// Enforce field count limit after parsing FormData.
|
|
158
|
+
const fieldResult = enforceFieldLimit(formData, config.bodyLimits ?? {});
|
|
159
|
+
if (!fieldResult.ok) {
|
|
160
|
+
return new Response(null, { status: fieldResult.status });
|
|
161
|
+
}
|
|
162
|
+
args = (await decodeReply(formData)) as unknown[];
|
|
163
|
+
} else {
|
|
164
|
+
// Text-based args
|
|
165
|
+
const body = await req.text();
|
|
166
|
+
args = (await decodeReply(body)) as unknown[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Execute the action with revalidation tracking.
|
|
170
|
+
// Errors are caught here so raw 'use server' functions (not using
|
|
171
|
+
// createActionClient) still return structured error responses instead
|
|
172
|
+
// of leaking stack traces as 500s.
|
|
173
|
+
let result;
|
|
174
|
+
try {
|
|
175
|
+
result = await executeAction(actionFn, args, {
|
|
176
|
+
renderer: config.revalidateRenderer,
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// Log full error server-side for debugging
|
|
180
|
+
console.error('[timber] server action error:', error);
|
|
181
|
+
|
|
182
|
+
// Return structured error response — ActionError gets its code/data,
|
|
183
|
+
// unexpected errors get sanitized { code: 'INTERNAL_ERROR' }
|
|
184
|
+
const errorResult = handleActionError(error);
|
|
185
|
+
const rscStream = renderToReadableStream(errorResult);
|
|
186
|
+
return new Response(rscStream, {
|
|
187
|
+
status: 200,
|
|
188
|
+
headers: { 'Content-Type': RSC_CONTENT_TYPE },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Handle redirect — encode in RSC stream for client-side SPA navigation.
|
|
193
|
+
// The client detects X-Timber-Redirect and calls router.navigate() instead
|
|
194
|
+
// of following an HTTP 302 (which would cause a full page reload).
|
|
195
|
+
if (result.redirectTo) {
|
|
196
|
+
const redirectPayload = {
|
|
197
|
+
_redirect: result.redirectTo,
|
|
198
|
+
_status: result.redirectStatus ?? 302,
|
|
199
|
+
};
|
|
200
|
+
const rscStream = renderToReadableStream(redirectPayload);
|
|
201
|
+
return new Response(rscStream, {
|
|
202
|
+
status: 200,
|
|
203
|
+
headers: {
|
|
204
|
+
'Content-Type': RSC_CONTENT_TYPE,
|
|
205
|
+
'X-Timber-Redirect': result.redirectTo,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Render the action result as an RSC stream.
|
|
211
|
+
// When revalidatePath was called, piggyback the revalidated element tree
|
|
212
|
+
// alongside the action result in a single renderToReadableStream call.
|
|
213
|
+
// The client detects the X-Timber-Revalidation header and unpacks both.
|
|
214
|
+
const headers: Record<string, string> = {
|
|
215
|
+
'Content-Type': RSC_CONTENT_TYPE,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let payload: unknown;
|
|
219
|
+
if (result.revalidation) {
|
|
220
|
+
// Wrapper object — Next.js-style pattern: action result + element tree
|
|
221
|
+
// serialized together so React Flight handles both in one stream.
|
|
222
|
+
payload = {
|
|
223
|
+
_action: result.actionResult,
|
|
224
|
+
_tree: result.revalidation.element,
|
|
225
|
+
};
|
|
226
|
+
headers['X-Timber-Revalidation'] = '1';
|
|
227
|
+
// Forward head elements as JSON so the client can update <head>.
|
|
228
|
+
if (result.revalidation.headElements.length > 0) {
|
|
229
|
+
headers['X-Timber-Head'] = JSON.stringify(result.revalidation.headElements);
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
payload = result.actionResult;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const rscStream = renderToReadableStream(payload);
|
|
236
|
+
|
|
237
|
+
return new Response(rscStream, {
|
|
238
|
+
status: 200,
|
|
239
|
+
headers,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── No-JS Form Action ───────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Handle a no-JS form action (progressive enhancement fallback).
|
|
247
|
+
*
|
|
248
|
+
* React embeds `$ACTION_REF` / `$ACTION_KEY` hidden fields in the form.
|
|
249
|
+
* We use `decodeAction` to resolve the action function from the form data,
|
|
250
|
+
* execute it, then redirect back to the form's page.
|
|
251
|
+
*/
|
|
252
|
+
async function handleFormAction(
|
|
253
|
+
req: Request,
|
|
254
|
+
config: ActionDispatchConfig
|
|
255
|
+
): Promise<Response | FormRerender | null> {
|
|
256
|
+
// Clone before consuming — if this turns out not to be a server action form,
|
|
257
|
+
// we return null and the original request body must remain readable for
|
|
258
|
+
// downstream route handlers. Clone is cheap (shares the body buffer until read).
|
|
259
|
+
const formData = await req.clone().formData();
|
|
260
|
+
|
|
261
|
+
// Enforce field count limit after parsing FormData.
|
|
262
|
+
const fieldResult = enforceFieldLimit(formData, config.bodyLimits ?? {});
|
|
263
|
+
if (!fieldResult.ok) {
|
|
264
|
+
return new Response(null, { status: fieldResult.status });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Check if this is actually a React server action form.
|
|
268
|
+
// If there's no $ACTION_REF_* or $ACTION_KEY, it's a regular form POST
|
|
269
|
+
// that should be handled by the route's route handler, not here.
|
|
270
|
+
// React uses `$ACTION_REF_` + identifierPrefix — since we don't set one,
|
|
271
|
+
// the suffix is empty. Check for any key starting with $ACTION_REF_ or $ACTION_ID_.
|
|
272
|
+
const allKeys = [...formData.keys()];
|
|
273
|
+
const hasActionField = allKeys.some(
|
|
274
|
+
(k) => k.startsWith('$ACTION_REF_') || k.startsWith('$ACTION_ID_')
|
|
275
|
+
);
|
|
276
|
+
if (!hasActionField && !formData.has('$ACTION_KEY')) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Capture submitted values for re-render on validation failure.
|
|
281
|
+
// Parse before decodeAction consumes the FormData.
|
|
282
|
+
const submittedValues = parseFormData(formData);
|
|
283
|
+
|
|
284
|
+
// decodeAction resolves the action function from the form data's hidden fields.
|
|
285
|
+
// It returns a bound function with the form data already applied.
|
|
286
|
+
const actionFn = (await decodeAction(formData)) as (...args: unknown[]) => Promise<unknown>;
|
|
287
|
+
|
|
288
|
+
// Execute the action — no additional args needed (form data is already bound).
|
|
289
|
+
// Errors are caught to prevent stack traces from leaking in the response.
|
|
290
|
+
let result;
|
|
291
|
+
try {
|
|
292
|
+
result = await executeAction(actionFn, [], {
|
|
293
|
+
renderer: config.revalidateRenderer,
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
console.error('[timber] server action error:', error);
|
|
297
|
+
|
|
298
|
+
// Return the error as flash data for re-render.
|
|
299
|
+
// handleActionError produces { serverError } for ActionErrors
|
|
300
|
+
// and { serverError: { code: 'INTERNAL_ERROR' } } for unexpected errors.
|
|
301
|
+
const errorResult = handleActionError(error);
|
|
302
|
+
return {
|
|
303
|
+
rerender: {
|
|
304
|
+
...errorResult,
|
|
305
|
+
submittedValues,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Handle redirect from the action (e.g. redirect() called in the action body)
|
|
311
|
+
if (result.redirectTo) {
|
|
312
|
+
return new Response(null, {
|
|
313
|
+
status: result.redirectStatus ?? 302,
|
|
314
|
+
headers: { Location: result.redirectTo },
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Re-render the page with the action result as flash data.
|
|
319
|
+
// The server component reads the flash via getFormFlash() and passes it
|
|
320
|
+
// to the client form component as the initial useActionState value.
|
|
321
|
+
// This handles both success ({ data }) and validation failure
|
|
322
|
+
// ({ validationErrors, submittedValues }) — the form is the single source of truth.
|
|
323
|
+
const actionResult = result.actionResult as FormFlashData;
|
|
324
|
+
return { rerender: actionResult };
|
|
325
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server action primitives: revalidatePath, revalidateTag, and the action handler.
|
|
3
|
+
*
|
|
4
|
+
* - revalidatePath(path) re-renders the route at that path and returns the RSC
|
|
5
|
+
* flight payload for inline reconciliation.
|
|
6
|
+
* - revalidateTag(tag) invalidates cached shells and 'use cache' entries by tag.
|
|
7
|
+
*
|
|
8
|
+
* Both are callable from anywhere on the server — actions, API routes, handlers.
|
|
9
|
+
*
|
|
10
|
+
* The action handler processes incoming action requests, validates CSRF,
|
|
11
|
+
* enforces body limits, executes the action, and returns the response
|
|
12
|
+
* (with piggybacked RSC payload if revalidatePath was called).
|
|
13
|
+
*
|
|
14
|
+
* See design/08-forms-and-actions.md
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
18
|
+
import type { CacheHandler } from '#/cache/index';
|
|
19
|
+
import { RedirectSignal } from './primitives';
|
|
20
|
+
import { withSpan } from './tracing';
|
|
21
|
+
|
|
22
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Result of rendering a revalidation — element tree before RSC serialization. */
|
|
25
|
+
export interface RevalidationResult {
|
|
26
|
+
/** React element tree (pre-serialization — passed to renderToReadableStream). */
|
|
27
|
+
element: unknown;
|
|
28
|
+
/** Resolved head elements for metadata. */
|
|
29
|
+
headElements: unknown[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Renderer function that builds a React element tree for a given path. */
|
|
33
|
+
export type RevalidateRenderer = (path: string) => Promise<RevalidationResult>;
|
|
34
|
+
|
|
35
|
+
/** Per-request revalidation state — tracks revalidatePath/Tag calls within an action. */
|
|
36
|
+
export interface RevalidationState {
|
|
37
|
+
/** Paths to re-render (populated by revalidatePath calls). */
|
|
38
|
+
paths: string[];
|
|
39
|
+
/** Tags to invalidate (populated by revalidateTag calls). */
|
|
40
|
+
tags: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Options for creating the action handler. */
|
|
44
|
+
export interface ActionHandlerConfig {
|
|
45
|
+
/** Cache handler for tag invalidation. */
|
|
46
|
+
cacheHandler?: CacheHandler;
|
|
47
|
+
/** Renderer for producing RSC payloads during revalidation. */
|
|
48
|
+
renderer?: RevalidateRenderer;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Result of handling a server action request. */
|
|
52
|
+
export interface ActionHandlerResult {
|
|
53
|
+
/** The action's return value (serialized). */
|
|
54
|
+
actionResult: unknown;
|
|
55
|
+
/** Revalidation result if revalidatePath was called (element tree, not yet serialized). */
|
|
56
|
+
revalidation?: RevalidationResult;
|
|
57
|
+
/** Redirect location if a RedirectSignal was thrown during revalidation. */
|
|
58
|
+
redirectTo?: string;
|
|
59
|
+
/** Redirect status code. */
|
|
60
|
+
redirectStatus?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Revalidation State ──────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
// Per-request revalidation state stored in AsyncLocalStorage.
|
|
66
|
+
// This ensures concurrent requests never share or overwrite each other's state
|
|
67
|
+
// (the previous module-level global was vulnerable to cross-request pollution).
|
|
68
|
+
const revalidationAls = new AsyncLocalStorage<RevalidationState>();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Set the revalidation state for the current action execution.
|
|
72
|
+
* @internal — kept for test compatibility; prefer executeAction() which uses ALS.
|
|
73
|
+
*/
|
|
74
|
+
export function _setRevalidationState(state: RevalidationState): void {
|
|
75
|
+
// Enter ALS scope — this is only used by tests that call revalidatePath/Tag
|
|
76
|
+
// directly without going through executeAction().
|
|
77
|
+
revalidationAls.enterWith(state);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Clear the revalidation state after action execution.
|
|
82
|
+
* @internal — kept for test compatibility.
|
|
83
|
+
*/
|
|
84
|
+
export function _clearRevalidationState(): void {
|
|
85
|
+
revalidationAls.enterWith(undefined as unknown as RevalidationState);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the current revalidation state. Throws if called outside an action context.
|
|
90
|
+
* @internal
|
|
91
|
+
*/
|
|
92
|
+
function getRevalidationState(): RevalidationState {
|
|
93
|
+
const state = revalidationAls.getStore();
|
|
94
|
+
if (!state) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
'revalidatePath/revalidateTag called outside of a server action context. ' +
|
|
97
|
+
'These functions can only be called during action execution.'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Public API ──────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Re-render the route at `path` and include the RSC flight payload in the
|
|
107
|
+
* action response. The client reconciles inline — no separate fetch needed.
|
|
108
|
+
*
|
|
109
|
+
* Can be called from server actions, API routes, or any server-side context.
|
|
110
|
+
*
|
|
111
|
+
* @param path - The path to re-render (e.g. '/dashboard', '/todos').
|
|
112
|
+
*/
|
|
113
|
+
export function revalidatePath(path: string): void {
|
|
114
|
+
const state = getRevalidationState();
|
|
115
|
+
if (!state.paths.includes(path)) {
|
|
116
|
+
state.paths.push(path);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Invalidate all pre-rendered shells and 'use cache' entries tagged with `tag`.
|
|
122
|
+
* Does not return a payload — the next request for an invalidated route re-renders fresh.
|
|
123
|
+
*
|
|
124
|
+
* @param tag - The cache tag to invalidate (e.g. 'products', 'user:123').
|
|
125
|
+
*/
|
|
126
|
+
export function revalidateTag(tag: string): void {
|
|
127
|
+
const state = getRevalidationState();
|
|
128
|
+
if (!state.tags.includes(tag)) {
|
|
129
|
+
state.tags.push(tag);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Action Handler ──────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Execute a server action and process revalidation.
|
|
137
|
+
*
|
|
138
|
+
* 1. Sets up revalidation state
|
|
139
|
+
* 2. Calls the action function
|
|
140
|
+
* 3. Processes revalidateTag calls (invalidates cache entries)
|
|
141
|
+
* 4. Processes revalidatePath calls (re-renders and captures RSC payload)
|
|
142
|
+
* 5. Returns the action result + optional RSC payload
|
|
143
|
+
*
|
|
144
|
+
* @param actionFn - The server action function to execute.
|
|
145
|
+
* @param args - Arguments to pass to the action.
|
|
146
|
+
* @param config - Handler configuration (cache handler, renderer).
|
|
147
|
+
*/
|
|
148
|
+
export async function executeAction(
|
|
149
|
+
actionFn: (...args: unknown[]) => Promise<unknown>,
|
|
150
|
+
args: unknown[],
|
|
151
|
+
config: ActionHandlerConfig = {},
|
|
152
|
+
spanMeta?: { actionFile?: string; actionName?: string }
|
|
153
|
+
): Promise<ActionHandlerResult> {
|
|
154
|
+
const state: RevalidationState = { paths: [], tags: [] };
|
|
155
|
+
let actionResult: unknown;
|
|
156
|
+
let redirectTo: string | undefined;
|
|
157
|
+
let redirectStatus: number | undefined;
|
|
158
|
+
|
|
159
|
+
// Run the action inside ALS scope so revalidatePath/Tag resolve to this
|
|
160
|
+
// request's state object — concurrent requests each get their own scope.
|
|
161
|
+
await revalidationAls.run(state, async () => {
|
|
162
|
+
try {
|
|
163
|
+
actionResult = await withSpan(
|
|
164
|
+
'timber.action',
|
|
165
|
+
{
|
|
166
|
+
...(spanMeta?.actionFile ? { 'timber.action_file': spanMeta.actionFile } : {}),
|
|
167
|
+
...(spanMeta?.actionName ? { 'timber.action_name': spanMeta.actionName } : {}),
|
|
168
|
+
},
|
|
169
|
+
() => actionFn(...args)
|
|
170
|
+
);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof RedirectSignal) {
|
|
173
|
+
redirectTo = error.location;
|
|
174
|
+
redirectStatus = error.status;
|
|
175
|
+
} else {
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Process tag invalidation
|
|
182
|
+
if (state.tags.length > 0 && config.cacheHandler) {
|
|
183
|
+
await Promise.all(state.tags.map((tag) => config.cacheHandler!.invalidate({ tag })));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Process path revalidation — build element tree (not yet serialized)
|
|
187
|
+
let revalidation: RevalidationResult | undefined;
|
|
188
|
+
if (state.paths.length > 0 && config.renderer) {
|
|
189
|
+
// For now, render the first revalidated path.
|
|
190
|
+
// Multiple paths could be supported via multipart streaming in the future.
|
|
191
|
+
const path = state.paths[0];
|
|
192
|
+
try {
|
|
193
|
+
revalidation = await config.renderer(path);
|
|
194
|
+
} catch (renderError) {
|
|
195
|
+
if (renderError instanceof RedirectSignal) {
|
|
196
|
+
// Revalidation triggered a redirect (e.g., session expired)
|
|
197
|
+
redirectTo = renderError.location;
|
|
198
|
+
redirectStatus = renderError.status;
|
|
199
|
+
} else {
|
|
200
|
+
// Log but don't fail the action — revalidation is best-effort
|
|
201
|
+
console.error('[timber] revalidatePath render failed:', renderError);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
actionResult,
|
|
208
|
+
revalidation,
|
|
209
|
+
...(redirectTo ? { redirectTo, redirectStatus } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build an HTTP Response for a no-JS form submission.
|
|
215
|
+
* Standard POST → 302 redirect pattern.
|
|
216
|
+
*
|
|
217
|
+
* @param redirectPath - Where to redirect after the action executes.
|
|
218
|
+
*/
|
|
219
|
+
export function buildNoJsResponse(redirectPath: string, status: number = 302): Response {
|
|
220
|
+
return new Response(null, {
|
|
221
|
+
status,
|
|
222
|
+
headers: { Location: redirectPath },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Detect whether the incoming request is an RSC action request (with JS)
|
|
228
|
+
* or a plain HTML form POST (no JS).
|
|
229
|
+
*
|
|
230
|
+
* RSC action requests use Accept: text/x-component or Content-Type: text/x-component.
|
|
231
|
+
*/
|
|
232
|
+
export function isRscActionRequest(req: Request): boolean {
|
|
233
|
+
const accept = req.headers.get('Accept') ?? '';
|
|
234
|
+
const contentType = req.headers.get('Content-Type') ?? '';
|
|
235
|
+
return accept.includes('text/x-component') || contentType.includes('text/x-component');
|
|
236
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static asset cache header utilities.
|
|
3
|
+
*
|
|
4
|
+
* Hashed assets (e.g. /assets/layout-abc123.css) are immutable — the hash
|
|
5
|
+
* changes when content changes. These get long-lived cache headers:
|
|
6
|
+
* Cache-Control: public, max-age=31536000, immutable
|
|
7
|
+
*
|
|
8
|
+
* Unhashed assets (e.g. /favicon.ico) get a shorter cache with revalidation:
|
|
9
|
+
* Cache-Control: public, max-age=3600, must-revalidate
|
|
10
|
+
*
|
|
11
|
+
* This is applied automatically by the framework for static assets served
|
|
12
|
+
* from the build output. Page responses are NOT affected — those are
|
|
13
|
+
* controlled by the developer via middleware.ts or proxy.ts.
|
|
14
|
+
*
|
|
15
|
+
* Design docs: 18-build-system.md, 06-caching.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Regex matching Vite-hashed asset filenames.
|
|
20
|
+
*
|
|
21
|
+
* Vite's default naming: `[name]-[hash].[ext]` where hash is 8+ hex chars.
|
|
22
|
+
* Also matches `[name].[hash].[ext]` pattern.
|
|
23
|
+
*
|
|
24
|
+
* Examples:
|
|
25
|
+
* /assets/layout-a1b2c3d4.css → match
|
|
26
|
+
* /assets/page-e5f6g7h8.js → match
|
|
27
|
+
* /assets/chunk.a1b2c3d4.js → match
|
|
28
|
+
* /favicon.ico → no match
|
|
29
|
+
* /index.html → no match
|
|
30
|
+
*/
|
|
31
|
+
const HASHED_ASSET_RE = /[-.][\da-f]{8,}\.\w+$/;
|
|
32
|
+
|
|
33
|
+
/** One year in seconds (365 days). */
|
|
34
|
+
const ONE_YEAR = 31_536_000;
|
|
35
|
+
|
|
36
|
+
/** One hour in seconds. */
|
|
37
|
+
const ONE_HOUR = 3_600;
|
|
38
|
+
|
|
39
|
+
/** Cache-Control value for hashed (immutable) assets. */
|
|
40
|
+
export const IMMUTABLE_CACHE = `public, max-age=${ONE_YEAR}, immutable`;
|
|
41
|
+
|
|
42
|
+
/** Cache-Control value for unhashed static assets. */
|
|
43
|
+
export const STATIC_CACHE = `public, max-age=${ONE_HOUR}, must-revalidate`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if a URL path looks like a hashed asset.
|
|
47
|
+
*/
|
|
48
|
+
export function isHashedAsset(pathname: string): boolean {
|
|
49
|
+
return HASHED_ASSET_RE.test(pathname);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the appropriate Cache-Control header for a static asset path.
|
|
54
|
+
*
|
|
55
|
+
* Returns `immutable` for hashed assets, short-lived for unhashed.
|
|
56
|
+
*/
|
|
57
|
+
export function getAssetCacheControl(pathname: string): string {
|
|
58
|
+
return isHashedAsset(pathname) ? IMMUTABLE_CACHE : STATIC_CACHE;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generate a `_headers` file for static asset cache control.
|
|
63
|
+
*
|
|
64
|
+
* The `_headers` file is a platform convention supported by Cloudflare Workers
|
|
65
|
+
* Static Assets, Cloudflare Pages, and Netlify. It maps URL patterns to
|
|
66
|
+
* HTTP response headers.
|
|
67
|
+
*
|
|
68
|
+
* Vite places all hashed chunks under `/assets/` — these get immutable caching.
|
|
69
|
+
* Everything else (favicon.ico, robots.txt, etc.) gets a shorter cache.
|
|
70
|
+
*/
|
|
71
|
+
export function generateHeadersFile(): string {
|
|
72
|
+
return `# Auto-generated by @timber/app — static asset cache headers.
|
|
73
|
+
# See design/25-production-deployments.md §"CDN / Edge Cache"
|
|
74
|
+
|
|
75
|
+
/assets/*
|
|
76
|
+
Cache-Control: ${IMMUTABLE_CACHE}
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
Cache-Control: ${STATIC_CACHE}
|
|
80
|
+
`;
|
|
81
|
+
}
|