@timber-js/app 0.2.0-alpha.200 → 0.2.0-alpha.201
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/bin/timber.mjs +15 -1
- package/dist/_chunks/{actions-d1hCqnU3.js → actions-HUdJADAD.js} +3 -3
- package/dist/_chunks/{actions-d1hCqnU3.js.map → actions-HUdJADAD.js.map} +1 -1
- package/dist/_chunks/{build-manifest-DTmSGLRz.js → build-manifest-DWppEdLB.js} +2 -51
- package/dist/_chunks/build-manifest-DWppEdLB.js.map +1 -0
- package/dist/_chunks/{cache-api-ByagcC-J.js → cache-api-CAPbZTga.js} +2 -2
- package/dist/_chunks/{cache-api-ByagcC-J.js.map → cache-api-CAPbZTga.js.map} +1 -1
- package/dist/_chunks/{chains-Bpb0W4ax.js → chains-CBNA0Ozj.js} +3 -3
- package/dist/_chunks/{chains-Bpb0W4ax.js.map → chains-CBNA0Ozj.js.map} +1 -1
- package/dist/_chunks/{cli-check-D6VolrDV.js → cli-check-BzMGuIH6.js} +3 -3
- package/dist/_chunks/{cli-check-D6VolrDV.js.map → cli-check-BzMGuIH6.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js → cli-schema-sync-DnXqcIIj.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js.map → cli-schema-sync-DnXqcIIj.js.map} +1 -1
- package/dist/_chunks/{cloudflare-BFb__LYG.js → cloudflare-DxX1SU0g.js} +3 -3
- package/dist/_chunks/{cloudflare-BFb__LYG.js.map → cloudflare-DxX1SU0g.js.map} +1 -1
- package/dist/_chunks/{convention-lint-fRkwVwEH.js → convention-lint-DHOFvX5s.js} +5 -95
- package/dist/_chunks/convention-lint-DHOFvX5s.js.map +1 -0
- package/dist/_chunks/csp-nonce-hOGniaG4.js +227 -0
- package/dist/_chunks/csp-nonce-hOGniaG4.js.map +1 -0
- package/dist/_chunks/dev-server-DioP7tkQ.js +2288 -0
- package/dist/_chunks/dev-server-DioP7tkQ.js.map +1 -0
- package/dist/_chunks/{error-boundary-BfPHZjm0.js → error-boundary-BQKxl6EX.js} +11 -17
- package/dist/_chunks/{error-boundary-BfPHZjm0.js.map → error-boundary-BQKxl6EX.js.map} +1 -1
- package/dist/_chunks/{graph-cache-CP4GEmf9.js → graph-cache-Cv3njEH8.js} +2 -2
- package/dist/_chunks/{graph-cache-CP4GEmf9.js.map → graph-cache-Cv3njEH8.js.map} +1 -1
- package/dist/_chunks/{live-graph-D_2D32Ad.js → live-graph-VjHFF5EV.js} +3 -3
- package/dist/_chunks/{live-graph-D_2D32Ad.js.map → live-graph-VjHFF5EV.js.map} +1 -1
- package/dist/_chunks/{logger-uLBuGKDI.js → logger-DqJ2VoAY.js} +450 -458
- package/dist/_chunks/logger-DqJ2VoAY.js.map +1 -0
- package/dist/_chunks/{segment-keys-lqtdookO.js → metadata-routes-DSDjM_hJ.js} +2 -61
- package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -0
- package/dist/_chunks/{poison-scan-Bm9Yyqk9.js → poison-scan-lEbz4pQE.js} +2 -2
- package/dist/_chunks/{poison-scan-Bm9Yyqk9.js.map → poison-scan-lEbz4pQE.js.map} +1 -1
- package/dist/_chunks/{scanner-AiazgH_f.js → scanner-B_tnqFcF.js} +37 -137
- package/dist/_chunks/scanner-B_tnqFcF.js.map +1 -0
- package/dist/_chunks/segment-keys-D5hu1hz4.js +62 -0
- package/dist/_chunks/segment-keys-D5hu1hz4.js.map +1 -0
- package/dist/_chunks/tree-match-CdbvYTBz.js +122 -0
- package/dist/_chunks/tree-match-CdbvYTBz.js.map +1 -0
- package/dist/_chunks/{walkers-B6XUtmqK.js → walkers-Cm3PC5JT.js} +2 -2
- package/dist/_chunks/{walkers-B6XUtmqK.js.map → walkers-Cm3PC5JT.js.map} +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/analyze/crawl-entry.js +2 -2
- package/dist/analyze/graph-command.js +3 -3
- package/dist/cache/index.js +1 -1
- package/dist/cli.d.ts +7 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -7
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/action-queue.d.ts +20 -1
- package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
- package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts +3 -15
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/error-reconstituter.d.ts +4 -4
- package/dist/client/error-reconstituter.d.ts.map +1 -1
- package/dist/client/internal.js +1 -1
- package/dist/dev-tools/debug-channel.d.ts +55 -0
- package/dist/dev-tools/debug-channel.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -2119
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts +7 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/routing/scanner.d.ts +4 -4
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/server/access-gate.d.ts +2 -2
- package/dist/server/deny-boundary.d.ts +3 -5
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +0 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +5 -12
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +20 -195
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline.d.ts +8 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +15 -13
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts +3 -7
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +6 -27
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +1 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +4 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +1 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/docs/api/30-api-server.mdx +5 -5
- package/docs/api/34-api-config.mdx +2 -2
- package/docs/api/36-cli.mdx +1 -1
- package/docs/learn/12-error-handling.mdx +7 -8
- package/package.json +1 -2
- package/src/cli.ts +62 -8
- package/src/client/browser-entry/action-dispatch.ts +12 -0
- package/src/client/browser-entry/action-queue.ts +70 -8
- package/src/client/browser-entry/rsc-stream.ts +78 -22
- package/src/client/error-boundary.tsx +13 -39
- package/src/client/error-reconstituter.tsx +5 -5
- package/src/dev-tools/debug-channel.ts +151 -0
- package/src/index.ts +8 -12
- package/src/plugins/dev-server.ts +58 -7
- package/src/plugins/mdx.ts +2 -1
- package/src/routing/convention-lint.ts +4 -111
- package/src/routing/scanner.ts +33 -22
- package/src/server/access-gate.tsx +3 -3
- package/src/server/deny-boundary.ts +5 -23
- package/src/server/deny-renderer.ts +4 -7
- package/src/server/error-boundary-wrapper.ts +11 -35
- package/src/server/pipeline.ts +9 -0
- package/src/server/primitives.ts +20 -26
- package/src/server/rsc-entry/error-renderer.ts +15 -43
- package/src/server/rsc-entry/helpers.ts +10 -67
- package/src/server/rsc-entry/index.ts +1 -0
- package/src/server/rsc-entry/render-route.ts +12 -1
- package/src/server/rsc-entry/rsc-stream.ts +27 -24
- package/src/server/rsc-entry/ssr-renderer.ts +7 -1
- package/dist/_chunks/build-manifest-DTmSGLRz.js.map +0 -1
- package/dist/_chunks/convention-lint-fRkwVwEH.js.map +0 -1
- package/dist/_chunks/logger-uLBuGKDI.js.map +0 -1
- package/dist/_chunks/scanner-AiazgH_f.js.map +0 -1
- package/dist/_chunks/segment-keys-lqtdookO.js.map +0 -1
- package/dist/server/utils/mdx-file.d.ts +0 -17
- package/dist/server/utils/mdx-file.d.ts.map +0 -1
- package/src/server/utils/mdx-file.ts +0 -22
package/bin/timber.mjs
CHANGED
|
@@ -2,4 +2,18 @@
|
|
|
2
2
|
// Thin CLI wrapper — delegates to the compiled CLI entry.
|
|
3
3
|
// This file is the bin entry point and never moves, regardless of build output changes.
|
|
4
4
|
// See design/28-npm-packaging.md §"CLI Binary".
|
|
5
|
-
import '
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { resolve, dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const distEntry = resolve(__dirname, '../dist/cli.js');
|
|
11
|
+
|
|
12
|
+
if (!existsSync(distEntry)) {
|
|
13
|
+
console.error(
|
|
14
|
+
'[timber] dist/cli.js not found. Run `pnpm --filter @timber-js/app build` first.'
|
|
15
|
+
);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
await import(pathToFileURL(distEntry).href);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { i as revalidationAls } from "./als-registry-C6kcfprT.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Z as withSpan, b as isDenySignal, x as isRedirectSignal } from "./logger-DqJ2VoAY.js";
|
|
3
3
|
import { r as canonicalize } from "./canonicalize-CgHoscYO.js";
|
|
4
|
-
import { t as cache } from "./cache-api-
|
|
4
|
+
import { t as cache } from "./cache-api-CAPbZTga.js";
|
|
5
5
|
//#region src/server/form-data.ts
|
|
6
6
|
/**
|
|
7
7
|
* FormData preprocessing — schema-agnostic conversion of FormData to typed objects.
|
|
@@ -342,4 +342,4 @@ function revalidationPathMatches(revalidatedPath, requestPathname, requestSearch
|
|
|
342
342
|
//#endregion
|
|
343
343
|
export { parseFormData as a, coerce as i, revalidatePath as n, revalidateTag as r, executeAction as t };
|
|
344
344
|
|
|
345
|
-
//# sourceMappingURL=actions-
|
|
345
|
+
//# sourceMappingURL=actions-HUdJADAD.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actions-d1hCqnU3.js","names":[],"sources":["../../src/server/form-data.ts","../../src/server/actions.ts"],"sourcesContent":["/**\n * FormData preprocessing — schema-agnostic conversion of FormData to typed objects.\n *\n * FormData is all strings. Schema validation expects typed values. This module\n * bridges the gap with intelligent coercion that runs *before* schema validation.\n *\n * Inspired by zod-form-data, but schema-agnostic — works with any Standard Schema\n * library (Zod, Valibot, ArkType).\n *\n * See design/08-forms-and-actions.md §\"parseFormData() and coerce helpers\"\n */\n\n// ─── parseFormData ───────────────────────────────────────────────────────\n\n/**\n * Convert FormData into a plain object with intelligent coercion.\n *\n * Handles:\n * - **Duplicate keys → arrays**: `tags=js&tags=ts` → `{ tags: [\"js\", \"ts\"] }`\n * - **Nested dot-paths**: `user.name=Alice` → `{ user: { name: \"Alice\" } }`\n * - **Empty strings → undefined**: Enables `.optional()` semantics in schemas\n * - **Empty Files → undefined**: File inputs with no selection become `undefined`\n * - **Strips `$ACTION_*` fields**: React's internal hidden fields are excluded\n */\nexport function parseFormData(formData: FormData): Record<string, unknown> {\n const flat: Record<string, unknown> = {};\n\n for (const key of new Set(formData.keys())) {\n // Skip React internal fields\n if (key.startsWith('$ACTION_')) continue;\n\n // Skip prototype-pollution keys at the top level — assigning to\n // `flat['__proto__']` invokes the prototype setter and can splice\n // attacker-controlled data into `flat`'s prototype chain.\n // The dot-path case is filtered separately in `expandDotPaths`.\n if (DANGEROUS_KEYS.has(key)) continue;\n\n const values = formData.getAll(key);\n const processed = values.map(normalizeValue);\n\n if (processed.length === 1) {\n flat[key] = processed[0];\n } else {\n // Filter out undefined (empty Files) and empty strings from multi-value fields.\n // Multi-value fields (e.g. tags=js&tags=&tags=ts) should not include blanks.\n flat[key] = processed.filter((v) => v !== undefined && v !== '');\n }\n }\n\n // Expand dot-notation paths into nested objects\n return expandDotPaths(flat);\n}\n\n/**\n * Normalize a single FormData entry value.\n * - Empty File objects (no selection) → undefined\n * - Strings pass through as-is (empty strings stay as empty strings so\n * schema validators like `z.string().min(1, 'Required')` can produce\n * their custom error messages instead of a generic type mismatch)\n * - Everything else passes through as-is\n */\nfunction normalizeValue(value: FormDataEntryValue): unknown {\n // File input with no selection: browsers submit a File with name=\"\" and size=0\n if (value instanceof File && value.size === 0 && value.name === '') {\n return undefined;\n }\n\n return value;\n}\n\n/**\n * Expand dot-notation keys into nested objects.\n * `{ \"user.name\": \"Alice\", \"user.age\": \"30\" }` → `{ user: { name: \"Alice\", age: \"30\" } }`\n *\n * Keys without dots are left as-is. Bracket notation (e.g. `items[0]`) is NOT\n * supported — use dot notation (`items.0`) instead.\n */\nfunction expandDotPaths(flat: Record<string, unknown>): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n let hasDotPaths = false;\n\n // First pass: check if any keys have dots\n for (const key of Object.keys(flat)) {\n if (key.includes('.')) {\n hasDotPaths = true;\n break;\n }\n }\n\n // Fast path: no dot-notation keys, return as-is.\n // Top-level dangerous keys are already filtered in `parseFormData` above.\n if (!hasDotPaths) return flat;\n\n for (const [key, value] of Object.entries(flat)) {\n // Reject any path that contains __proto__ / constructor / prototype.\n // Without this, `__proto__.x=1` writes to Object.prototype because\n // `result['__proto__']` resolves to the prototype object itself.\n const parts = key.split('.');\n if (parts.some((p) => DANGEROUS_KEYS.has(p))) continue;\n\n if (parts.length === 1) {\n result[parts[0]] = value;\n continue;\n }\n\n let current: Record<string, unknown> = result;\n for (let i = 0; i < parts.length - 1; i++) {\n const part = parts[i];\n if (current[part] === undefined || current[part] === null) {\n current[part] = {};\n }\n // If current[part] is not an object (e.g., a string from a non-dotted key),\n // the dot-path takes precedence\n if (typeof current[part] !== 'object' || current[part] instanceof File) {\n current[part] = {};\n }\n current = current[part] as Record<string, unknown>;\n }\n\n current[parts[parts.length - 1]] = value;\n }\n\n return result;\n}\n\n// `__proto__` is the only key in this parser that escapes the dot-path\n// walk's `typeof !== 'object'` reset: accessing `obj['__proto__']` returns\n// `Object.prototype` (itself an object), so the walk steps into the global\n// prototype and the leaf write mutates it. `constructor` resolves to a\n// function (reset fires) and `prototype` is undefined on fresh objects\n// (reset fires), so neither escapes the walk — they parse as legit nested\n// keys and do not mutate anything global.\nconst DANGEROUS_KEYS = new Set(['__proto__']);\n\n// ─── Coercion Helpers ────────────────────────────────────────────────────\n\n/**\n * Schema-agnostic coercion primitives for common FormData patterns.\n *\n * These are plain transform functions — they compose with any schema library's\n * `transform`/`preprocess` pipeline:\n *\n * ```ts\n * // Zod\n * z.preprocess(coerce.number, z.number())\n * // Valibot\n * v.pipe(v.unknown(), v.transform(coerce.number), v.number())\n * ```\n */\nexport const coerce = {\n /**\n * Coerce an empty string to undefined for optional text fields.\n * Use with `.optional()` schemas where an empty input means \"not provided\".\n *\n * ```ts\n * // Zod\n * z.unknown().transform(coerce.text).pipe(z.string().optional())\n * // Valibot\n * v.pipe(v.unknown(), v.transform(coerce.text), v.optional(v.string()))\n * ```\n */\n text(value: unknown): string | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value === 'string') return value;\n return undefined;\n },\n\n /**\n * Coerce a string to a number.\n * - `\"42\"` → `42`\n * - `\"3.14\"` → `3.14`\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Non-numeric strings → `undefined` (schema validation will catch this)\n */\n number(value: unknown): number | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value === 'number') return value;\n if (typeof value !== 'string') return undefined;\n const num = Number(value);\n if (Number.isNaN(num)) return undefined;\n return num;\n },\n\n /**\n * Coerce a checkbox value to a boolean.\n * HTML checkboxes submit \"on\" when checked and are absent when unchecked.\n * - `\"on\"` / any truthy string → `true`\n * - `undefined` / `null` / `\"\"` → `false`\n */\n checkbox(value: unknown): boolean {\n if (value === undefined || value === null || value === '') return false;\n if (typeof value === 'boolean') return value;\n // Any non-empty string (typically \"on\") is true\n return typeof value === 'string' && value.length > 0;\n },\n\n /**\n * Parse a JSON string into an object.\n * - Valid JSON string → parsed object\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Invalid JSON → `undefined` (schema validation will catch this)\n */\n json(value: unknown): unknown {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value !== 'string') return value;\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n },\n\n /**\n * Coerce a date string to a Date object.\n * Handles `<input type=\"date\">` (`\"2024-01-15\"`), `<input type=\"datetime-local\">`\n * (`\"2024-01-15T10:30\"`), and full ISO 8601 strings.\n * - Valid date string → `Date`\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Invalid date strings → `undefined` (schema validation will catch this)\n * - Impossible dates that `new Date()` silently normalizes (e.g. Feb 31) → `undefined`\n */\n date(value: unknown): Date | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (value instanceof Date) return value;\n if (typeof value !== 'string') return undefined;\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return undefined;\n\n // Overflow detection: extract Y/M/D from the input string and verify\n // they match the parsed Date components. new Date('2024-02-31') silently\n // normalizes to March 2nd — we reject such inputs.\n const ymdMatch = value.match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if (ymdMatch) {\n const inputYear = Number(ymdMatch[1]);\n const inputMonth = Number(ymdMatch[2]);\n const inputDay = Number(ymdMatch[3]);\n\n // Use UTC methods for date-only and Z-suffixed strings to avoid\n // timezone offset shifting the day. For datetime-local (no Z suffix),\n // the Date constructor parses in local time, so use local methods.\n const isUTC = value.length === 10 || value.endsWith('Z');\n const parsedYear = isUTC ? date.getUTCFullYear() : date.getFullYear();\n const parsedMonth = isUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;\n const parsedDay = isUTC ? date.getUTCDate() : date.getDate();\n\n if (inputYear !== parsedYear || inputMonth !== parsedMonth || inputDay !== parsedDay) {\n return undefined;\n }\n }\n\n return date;\n },\n\n /**\n * Create a File coercion function with optional size and mime type validation.\n * Returns the File if valid, `undefined` otherwise.\n *\n * ```ts\n * // Basic — just checks it's a real File\n * z.preprocess(coerce.file(), z.instanceof(File))\n *\n * // With constraints\n * z.preprocess(\n * coerce.file({ maxSize: 5 * 1024 * 1024, accept: ['image/png', 'image/jpeg'] }),\n * z.instanceof(File)\n * )\n * ```\n */\n file(options?: { maxSize?: number; accept?: string[] }): (value: unknown) => File | undefined {\n return (value: unknown): File | undefined => {\n if (value === undefined || value === null || value === '') return undefined;\n if (!(value instanceof File)) return undefined;\n\n // Empty file input (no selection): browsers submit File with name=\"\" and size=0\n if (value.size === 0 && value.name === '') return undefined;\n\n if (options?.maxSize !== undefined && value.size > options.maxSize) {\n return undefined;\n }\n\n if (options?.accept !== undefined && !options.accept.includes(value.type)) {\n return undefined;\n }\n\n return value;\n };\n },\n};\n","/**\n * Server action primitives: revalidatePath, revalidateTag, and the action handler.\n *\n * - revalidatePath(path) re-renders the route at that path and returns the RSC\n * flight payload for inline reconciliation.\n * - revalidateTag(tag) invalidates timber.cache entries by tag.\n *\n * Both are callable from anywhere on the server — actions, API routes, handlers.\n *\n * The action handler processes incoming action requests, validates CSRF,\n * enforces body limits, executes the action, and returns the response\n * (with piggybacked RSC payload if revalidatePath was called).\n *\n * See design/08-forms-and-actions.md\n */\n\nimport { cache } from '../cache/cache-api.ts';\nimport { canonicalize } from './canonicalize.ts';\nimport { isDenySignal, isRedirectSignal } from './primitives.ts';\nimport { withSpan } from './tracing.ts';\nimport { revalidationAls, type RevalidationState } from './als-registry.ts';\n// ─── Types ───────────────────────────────────────────────────────────────\n\n/** Result of rendering a revalidation — payload root before RSC serialization. */\nexport interface RevalidationResult {\n /**\n * The payload root — tree plus the params it rendered with, exactly as an\n * ordinary route payload. The client splits it in `applyActionResult`.\n */\n payload: unknown;\n}\n\n/** Renderer function that builds a React element tree for a given path. */\nexport type RevalidateRenderer = (path: string) => Promise<RevalidationResult>;\n\n// Re-export the type from the registry for public API consumers.\nexport type { RevalidationState } from './als-registry.ts';\n\n/** Options for creating the action handler. */\nexport interface ActionHandlerConfig {\n /** Renderer for producing RSC payloads during revalidation. */\n renderer?: RevalidateRenderer;\n /**\n * Pathname of the page the action was submitted from. When set,\n * revalidatePath renders are skipped for non-matching paths — the\n * server cache is already invalidated, so the next navigation fetches\n * fresh data. See TIM-1454.\n */\n requestPathname?: string;\n /** Search string of the action request URL (e.g. `?page=1`). */\n requestSearch?: string;\n}\n\n/** Result of handling a server action request. */\nexport interface ActionHandlerResult {\n /** The action's return value (serialized). */\n actionResult: unknown;\n /** Revalidation result if revalidatePath was called (element tree, not yet serialized). */\n revalidation?: RevalidationResult;\n /**\n * Paths passed to `revalidatePath()` that don't match the current page\n * (TIM-1454). The client invalidates its caches for these so the next\n * navigation fetches fresh data.\n */\n invalidatedPaths?: string[];\n /** Redirect location if a RedirectSignal was thrown during revalidation. */\n redirectTo?: string;\n /** Redirect status code. */\n redirectStatus?: number;\n}\n\n// ─── Revalidation State ──────────────────────────────────────────────────\n\n// Per-request revalidation state stored in AsyncLocalStorage (from als-registry.ts).\n// This ensures concurrent requests never share or overwrite each other's state\n// (the previous module-level global was vulnerable to cross-request pollution).\n\n/**\n * Run `fn` inside a revalidation ALS scope.\n * @internal — for tests that call revalidatePath/Tag directly without executeAction().\n */\nexport function _runWithRevalidationState<T>(state: RevalidationState, fn: () => T): T {\n return revalidationAls.run(state, fn);\n}\n\n/**\n * Get the current revalidation state. Throws if called outside an action context.\n * @internal\n */\nfunction getRevalidationState(): RevalidationState {\n const state = revalidationAls.getStore();\n if (!state) {\n throw new Error(\n 'revalidatePath/revalidateTag called outside of a server action context. ' +\n 'These functions can only be called during action execution.'\n );\n }\n return state;\n}\n\n// ─── Public API ──────────────────────────────────────────────────────────\n\n/**\n * Re-render the route at `path` and include the RSC flight payload in the\n * action response. The client reconciles inline — no separate fetch needed.\n *\n * Can be called from server actions, API routes, or any server-side context.\n *\n * @param path - The path to re-render (e.g. '/dashboard', '/todos').\n */\nexport function revalidatePath(path: string): void {\n const state = getRevalidationState();\n if (!state.paths.includes(path)) {\n state.paths.push(path);\n }\n}\n\n/**\n * Invalidate all timber.cache entries tagged with `tag`.\n * Does not return a payload — the next request for an invalidated entry re-executes.\n *\n * @param tag - The cache tag to invalidate (e.g. 'products', 'user:123').\n */\nexport function revalidateTag(tag: string): void {\n const state = getRevalidationState();\n if (!state.tags.includes(tag)) {\n state.tags.push(tag);\n }\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\nfunction stripFragment(path: string): string {\n const idx = path.indexOf('#');\n return idx >= 0 ? path.slice(0, idx) : path;\n}\n\n// ─── Action Handler ──────────────────────────────────────────────────────\n\n/**\n * Execute a server action and process revalidation.\n *\n * 1. Sets up revalidation state\n * 2. Calls the action function\n * 3. Processes revalidateTag calls (invalidates cache entries)\n * 4. Processes revalidatePath calls (re-renders and captures RSC payload)\n * 5. Returns the action result + optional RSC payload\n *\n * @param actionFn - The server action function to execute.\n * @param args - Arguments to pass to the action.\n * @param config - Handler configuration (cache handler, renderer).\n */\nexport async function executeAction(\n actionFn: (...args: unknown[]) => Promise<unknown>,\n args: unknown[],\n config: ActionHandlerConfig = {},\n spanMeta?: { actionFile?: string; actionName?: string }\n): Promise<ActionHandlerResult> {\n const state: RevalidationState = { paths: [], tags: [] };\n let actionResult: unknown;\n let redirectTo: string | undefined;\n let redirectStatus: number | undefined;\n\n // Run the action inside ALS scope so revalidatePath/Tag resolve to this\n // request's state object — concurrent requests each get their own scope.\n await revalidationAls.run(state, async () => {\n try {\n actionResult = await withSpan(\n 'timber.action',\n {\n ...(spanMeta?.actionFile ? { 'timber.action_file': spanMeta.actionFile } : {}),\n ...(spanMeta?.actionName ? { 'timber.action_name': spanMeta.actionName } : {}),\n },\n () => actionFn(...args)\n );\n } catch (error) {\n if (isRedirectSignal(error)) {\n redirectTo = error.location;\n redirectStatus = error.status;\n } else {\n throw error;\n }\n }\n });\n\n // Process tag invalidation through cache.invalidate so it records the\n // invalidation epoch — an in-flight cached fn must not re-store data this\n // mutation just invalidated (TIM-1028). cache.invalidate resolves the\n // module-level handler singleton: setCacheHandler() is called at boot from\n // rsc-entry when timber.config.ts provides a cacheHandler; otherwise falls\n // back to in-memory LRU (TIM-599).\n if (state.tags.length > 0) {\n // cache.invalidate handles both data-cache clearing AND component-seed\n // tombstone writes (design/45 §ISR mechanics) — no separate tombstone\n // step needed here.\n // Best-effort: the action body already executed and its mutation committed.\n // Use allSettled so all tags settle before proceeding — Promise.all would\n // short-circuit on the first rejection, leaving other invalidations (including\n // component-seed tombstone writes) in-flight during revalidatePath rendering.\n const results = await Promise.allSettled(state.tags.map((tag) => cache.invalidate({ tag })));\n for (const r of results) {\n if (r.status === 'rejected') {\n console.error('[timber] revalidateTag invalidation failed:', r.reason);\n }\n }\n }\n\n // Process path revalidation — build element tree (not yet serialized).\n //\n // TIM-1454: Only render the path if it matches the current page. Non-matching\n // paths are collected as `invalidatedPaths` — the client evicts them from its\n // caches so the next navigation fetches fresh data.\n let revalidation: RevalidationResult | undefined;\n let invalidatedPaths: string[] | undefined;\n\n if (state.paths.length > 0) {\n const path = state.paths[0];\n\n if (\n config.requestPathname &&\n !revalidationPathMatches(path, config.requestPathname, config.requestSearch)\n ) {\n invalidatedPaths = [stripFragment(path)];\n } else if (config.renderer) {\n try {\n revalidation = await config.renderer(path);\n } catch (renderError) {\n if (isRedirectSignal(renderError)) {\n redirectTo = renderError.location;\n redirectStatus = renderError.status;\n } else if (isDenySignal(renderError)) {\n console.error(\n `[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`\n );\n } else {\n console.error('[timber] revalidatePath render failed:', renderError);\n }\n }\n }\n }\n\n return {\n actionResult,\n revalidation,\n invalidatedPaths,\n ...(redirectTo ? { redirectTo, redirectStatus } : {}),\n };\n}\n\n/**\n * Check if a `revalidatePath(path)` argument matches the request pathname.\n/**\n * Canonicalizes the revalidated path the same way the renderer does so\n * equivalent paths compare equal. Returns true when the paths match or\n * when comparison is inconclusive (validation failure → render to be safe).\n */\nfunction revalidationPathMatches(\n revalidatedPath: string,\n requestPathname: string,\n requestSearch?: string\n): boolean {\n const hashIdx = revalidatedPath.indexOf('#');\n const noFragment = hashIdx >= 0 ? revalidatedPath.slice(0, hashIdx) : revalidatedPath;\n const queryIdx = noFragment.indexOf('?');\n const pathnameOnly = queryIdx >= 0 ? noFragment.slice(0, queryIdx) : noFragment;\n const search = queryIdx >= 0 ? noFragment.slice(queryIdx) : '';\n const canonical = canonicalize(pathnameOnly, true);\n if (!canonical.ok) return true;\n if (canonical.pathname !== requestPathname) return false;\n // revalidatePath('/dashboard') (no search) matches any query variant.\n // revalidatePath('/dashboard?tab=x') only matches that exact search.\n if (search && search !== (requestSearch ?? '')) return false;\n return true;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"actions-HUdJADAD.js","names":[],"sources":["../../src/server/form-data.ts","../../src/server/actions.ts"],"sourcesContent":["/**\n * FormData preprocessing — schema-agnostic conversion of FormData to typed objects.\n *\n * FormData is all strings. Schema validation expects typed values. This module\n * bridges the gap with intelligent coercion that runs *before* schema validation.\n *\n * Inspired by zod-form-data, but schema-agnostic — works with any Standard Schema\n * library (Zod, Valibot, ArkType).\n *\n * See design/08-forms-and-actions.md §\"parseFormData() and coerce helpers\"\n */\n\n// ─── parseFormData ───────────────────────────────────────────────────────\n\n/**\n * Convert FormData into a plain object with intelligent coercion.\n *\n * Handles:\n * - **Duplicate keys → arrays**: `tags=js&tags=ts` → `{ tags: [\"js\", \"ts\"] }`\n * - **Nested dot-paths**: `user.name=Alice` → `{ user: { name: \"Alice\" } }`\n * - **Empty strings → undefined**: Enables `.optional()` semantics in schemas\n * - **Empty Files → undefined**: File inputs with no selection become `undefined`\n * - **Strips `$ACTION_*` fields**: React's internal hidden fields are excluded\n */\nexport function parseFormData(formData: FormData): Record<string, unknown> {\n const flat: Record<string, unknown> = {};\n\n for (const key of new Set(formData.keys())) {\n // Skip React internal fields\n if (key.startsWith('$ACTION_')) continue;\n\n // Skip prototype-pollution keys at the top level — assigning to\n // `flat['__proto__']` invokes the prototype setter and can splice\n // attacker-controlled data into `flat`'s prototype chain.\n // The dot-path case is filtered separately in `expandDotPaths`.\n if (DANGEROUS_KEYS.has(key)) continue;\n\n const values = formData.getAll(key);\n const processed = values.map(normalizeValue);\n\n if (processed.length === 1) {\n flat[key] = processed[0];\n } else {\n // Filter out undefined (empty Files) and empty strings from multi-value fields.\n // Multi-value fields (e.g. tags=js&tags=&tags=ts) should not include blanks.\n flat[key] = processed.filter((v) => v !== undefined && v !== '');\n }\n }\n\n // Expand dot-notation paths into nested objects\n return expandDotPaths(flat);\n}\n\n/**\n * Normalize a single FormData entry value.\n * - Empty File objects (no selection) → undefined\n * - Strings pass through as-is (empty strings stay as empty strings so\n * schema validators like `z.string().min(1, 'Required')` can produce\n * their custom error messages instead of a generic type mismatch)\n * - Everything else passes through as-is\n */\nfunction normalizeValue(value: FormDataEntryValue): unknown {\n // File input with no selection: browsers submit a File with name=\"\" and size=0\n if (value instanceof File && value.size === 0 && value.name === '') {\n return undefined;\n }\n\n return value;\n}\n\n/**\n * Expand dot-notation keys into nested objects.\n * `{ \"user.name\": \"Alice\", \"user.age\": \"30\" }` → `{ user: { name: \"Alice\", age: \"30\" } }`\n *\n * Keys without dots are left as-is. Bracket notation (e.g. `items[0]`) is NOT\n * supported — use dot notation (`items.0`) instead.\n */\nfunction expandDotPaths(flat: Record<string, unknown>): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n let hasDotPaths = false;\n\n // First pass: check if any keys have dots\n for (const key of Object.keys(flat)) {\n if (key.includes('.')) {\n hasDotPaths = true;\n break;\n }\n }\n\n // Fast path: no dot-notation keys, return as-is.\n // Top-level dangerous keys are already filtered in `parseFormData` above.\n if (!hasDotPaths) return flat;\n\n for (const [key, value] of Object.entries(flat)) {\n // Reject any path that contains __proto__ / constructor / prototype.\n // Without this, `__proto__.x=1` writes to Object.prototype because\n // `result['__proto__']` resolves to the prototype object itself.\n const parts = key.split('.');\n if (parts.some((p) => DANGEROUS_KEYS.has(p))) continue;\n\n if (parts.length === 1) {\n result[parts[0]] = value;\n continue;\n }\n\n let current: Record<string, unknown> = result;\n for (let i = 0; i < parts.length - 1; i++) {\n const part = parts[i];\n if (current[part] === undefined || current[part] === null) {\n current[part] = {};\n }\n // If current[part] is not an object (e.g., a string from a non-dotted key),\n // the dot-path takes precedence\n if (typeof current[part] !== 'object' || current[part] instanceof File) {\n current[part] = {};\n }\n current = current[part] as Record<string, unknown>;\n }\n\n current[parts[parts.length - 1]] = value;\n }\n\n return result;\n}\n\n// `__proto__` is the only key in this parser that escapes the dot-path\n// walk's `typeof !== 'object'` reset: accessing `obj['__proto__']` returns\n// `Object.prototype` (itself an object), so the walk steps into the global\n// prototype and the leaf write mutates it. `constructor` resolves to a\n// function (reset fires) and `prototype` is undefined on fresh objects\n// (reset fires), so neither escapes the walk — they parse as legit nested\n// keys and do not mutate anything global.\nconst DANGEROUS_KEYS = new Set(['__proto__']);\n\n// ─── Coercion Helpers ────────────────────────────────────────────────────\n\n/**\n * Schema-agnostic coercion primitives for common FormData patterns.\n *\n * These are plain transform functions — they compose with any schema library's\n * `transform`/`preprocess` pipeline:\n *\n * ```ts\n * // Zod\n * z.preprocess(coerce.number, z.number())\n * // Valibot\n * v.pipe(v.unknown(), v.transform(coerce.number), v.number())\n * ```\n */\nexport const coerce = {\n /**\n * Coerce an empty string to undefined for optional text fields.\n * Use with `.optional()` schemas where an empty input means \"not provided\".\n *\n * ```ts\n * // Zod\n * z.unknown().transform(coerce.text).pipe(z.string().optional())\n * // Valibot\n * v.pipe(v.unknown(), v.transform(coerce.text), v.optional(v.string()))\n * ```\n */\n text(value: unknown): string | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value === 'string') return value;\n return undefined;\n },\n\n /**\n * Coerce a string to a number.\n * - `\"42\"` → `42`\n * - `\"3.14\"` → `3.14`\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Non-numeric strings → `undefined` (schema validation will catch this)\n */\n number(value: unknown): number | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value === 'number') return value;\n if (typeof value !== 'string') return undefined;\n const num = Number(value);\n if (Number.isNaN(num)) return undefined;\n return num;\n },\n\n /**\n * Coerce a checkbox value to a boolean.\n * HTML checkboxes submit \"on\" when checked and are absent when unchecked.\n * - `\"on\"` / any truthy string → `true`\n * - `undefined` / `null` / `\"\"` → `false`\n */\n checkbox(value: unknown): boolean {\n if (value === undefined || value === null || value === '') return false;\n if (typeof value === 'boolean') return value;\n // Any non-empty string (typically \"on\") is true\n return typeof value === 'string' && value.length > 0;\n },\n\n /**\n * Parse a JSON string into an object.\n * - Valid JSON string → parsed object\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Invalid JSON → `undefined` (schema validation will catch this)\n */\n json(value: unknown): unknown {\n if (value === undefined || value === null || value === '') return undefined;\n if (typeof value !== 'string') return value;\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n },\n\n /**\n * Coerce a date string to a Date object.\n * Handles `<input type=\"date\">` (`\"2024-01-15\"`), `<input type=\"datetime-local\">`\n * (`\"2024-01-15T10:30\"`), and full ISO 8601 strings.\n * - Valid date string → `Date`\n * - `\"\"` / `undefined` / `null` → `undefined`\n * - Invalid date strings → `undefined` (schema validation will catch this)\n * - Impossible dates that `new Date()` silently normalizes (e.g. Feb 31) → `undefined`\n */\n date(value: unknown): Date | undefined {\n if (value === undefined || value === null || value === '') return undefined;\n if (value instanceof Date) return value;\n if (typeof value !== 'string') return undefined;\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return undefined;\n\n // Overflow detection: extract Y/M/D from the input string and verify\n // they match the parsed Date components. new Date('2024-02-31') silently\n // normalizes to March 2nd — we reject such inputs.\n const ymdMatch = value.match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if (ymdMatch) {\n const inputYear = Number(ymdMatch[1]);\n const inputMonth = Number(ymdMatch[2]);\n const inputDay = Number(ymdMatch[3]);\n\n // Use UTC methods for date-only and Z-suffixed strings to avoid\n // timezone offset shifting the day. For datetime-local (no Z suffix),\n // the Date constructor parses in local time, so use local methods.\n const isUTC = value.length === 10 || value.endsWith('Z');\n const parsedYear = isUTC ? date.getUTCFullYear() : date.getFullYear();\n const parsedMonth = isUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;\n const parsedDay = isUTC ? date.getUTCDate() : date.getDate();\n\n if (inputYear !== parsedYear || inputMonth !== parsedMonth || inputDay !== parsedDay) {\n return undefined;\n }\n }\n\n return date;\n },\n\n /**\n * Create a File coercion function with optional size and mime type validation.\n * Returns the File if valid, `undefined` otherwise.\n *\n * ```ts\n * // Basic — just checks it's a real File\n * z.preprocess(coerce.file(), z.instanceof(File))\n *\n * // With constraints\n * z.preprocess(\n * coerce.file({ maxSize: 5 * 1024 * 1024, accept: ['image/png', 'image/jpeg'] }),\n * z.instanceof(File)\n * )\n * ```\n */\n file(options?: { maxSize?: number; accept?: string[] }): (value: unknown) => File | undefined {\n return (value: unknown): File | undefined => {\n if (value === undefined || value === null || value === '') return undefined;\n if (!(value instanceof File)) return undefined;\n\n // Empty file input (no selection): browsers submit File with name=\"\" and size=0\n if (value.size === 0 && value.name === '') return undefined;\n\n if (options?.maxSize !== undefined && value.size > options.maxSize) {\n return undefined;\n }\n\n if (options?.accept !== undefined && !options.accept.includes(value.type)) {\n return undefined;\n }\n\n return value;\n };\n },\n};\n","/**\n * Server action primitives: revalidatePath, revalidateTag, and the action handler.\n *\n * - revalidatePath(path) re-renders the route at that path and returns the RSC\n * flight payload for inline reconciliation.\n * - revalidateTag(tag) invalidates timber.cache entries by tag.\n *\n * Both are callable from anywhere on the server — actions, API routes, handlers.\n *\n * The action handler processes incoming action requests, validates CSRF,\n * enforces body limits, executes the action, and returns the response\n * (with piggybacked RSC payload if revalidatePath was called).\n *\n * See design/08-forms-and-actions.md\n */\n\nimport { cache } from '../cache/cache-api.ts';\nimport { canonicalize } from './canonicalize.ts';\nimport { isDenySignal, isRedirectSignal } from './primitives.ts';\nimport { withSpan } from './tracing.ts';\nimport { revalidationAls, type RevalidationState } from './als-registry.ts';\n// ─── Types ───────────────────────────────────────────────────────────────\n\n/** Result of rendering a revalidation — payload root before RSC serialization. */\nexport interface RevalidationResult {\n /**\n * The payload root — tree plus the params it rendered with, exactly as an\n * ordinary route payload. The client splits it in `applyActionResult`.\n */\n payload: unknown;\n}\n\n/** Renderer function that builds a React element tree for a given path. */\nexport type RevalidateRenderer = (path: string) => Promise<RevalidationResult>;\n\n// Re-export the type from the registry for public API consumers.\nexport type { RevalidationState } from './als-registry.ts';\n\n/** Options for creating the action handler. */\nexport interface ActionHandlerConfig {\n /** Renderer for producing RSC payloads during revalidation. */\n renderer?: RevalidateRenderer;\n /**\n * Pathname of the page the action was submitted from. When set,\n * revalidatePath renders are skipped for non-matching paths — the\n * server cache is already invalidated, so the next navigation fetches\n * fresh data. See TIM-1454.\n */\n requestPathname?: string;\n /** Search string of the action request URL (e.g. `?page=1`). */\n requestSearch?: string;\n}\n\n/** Result of handling a server action request. */\nexport interface ActionHandlerResult {\n /** The action's return value (serialized). */\n actionResult: unknown;\n /** Revalidation result if revalidatePath was called (element tree, not yet serialized). */\n revalidation?: RevalidationResult;\n /**\n * Paths passed to `revalidatePath()` that don't match the current page\n * (TIM-1454). The client invalidates its caches for these so the next\n * navigation fetches fresh data.\n */\n invalidatedPaths?: string[];\n /** Redirect location if a RedirectSignal was thrown during revalidation. */\n redirectTo?: string;\n /** Redirect status code. */\n redirectStatus?: number;\n}\n\n// ─── Revalidation State ──────────────────────────────────────────────────\n\n// Per-request revalidation state stored in AsyncLocalStorage (from als-registry.ts).\n// This ensures concurrent requests never share or overwrite each other's state\n// (the previous module-level global was vulnerable to cross-request pollution).\n\n/**\n * Run `fn` inside a revalidation ALS scope.\n * @internal — for tests that call revalidatePath/Tag directly without executeAction().\n */\nexport function _runWithRevalidationState<T>(state: RevalidationState, fn: () => T): T {\n return revalidationAls.run(state, fn);\n}\n\n/**\n * Get the current revalidation state. Throws if called outside an action context.\n * @internal\n */\nfunction getRevalidationState(): RevalidationState {\n const state = revalidationAls.getStore();\n if (!state) {\n throw new Error(\n 'revalidatePath/revalidateTag called outside of a server action context. ' +\n 'These functions can only be called during action execution.'\n );\n }\n return state;\n}\n\n// ─── Public API ──────────────────────────────────────────────────────────\n\n/**\n * Re-render the route at `path` and include the RSC flight payload in the\n * action response. The client reconciles inline — no separate fetch needed.\n *\n * Can be called from server actions, API routes, or any server-side context.\n *\n * @param path - The path to re-render (e.g. '/dashboard', '/todos').\n */\nexport function revalidatePath(path: string): void {\n const state = getRevalidationState();\n if (!state.paths.includes(path)) {\n state.paths.push(path);\n }\n}\n\n/**\n * Invalidate all timber.cache entries tagged with `tag`.\n * Does not return a payload — the next request for an invalidated entry re-executes.\n *\n * @param tag - The cache tag to invalidate (e.g. 'products', 'user:123').\n */\nexport function revalidateTag(tag: string): void {\n const state = getRevalidationState();\n if (!state.tags.includes(tag)) {\n state.tags.push(tag);\n }\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\nfunction stripFragment(path: string): string {\n const idx = path.indexOf('#');\n return idx >= 0 ? path.slice(0, idx) : path;\n}\n\n// ─── Action Handler ──────────────────────────────────────────────────────\n\n/**\n * Execute a server action and process revalidation.\n *\n * 1. Sets up revalidation state\n * 2. Calls the action function\n * 3. Processes revalidateTag calls (invalidates cache entries)\n * 4. Processes revalidatePath calls (re-renders and captures RSC payload)\n * 5. Returns the action result + optional RSC payload\n *\n * @param actionFn - The server action function to execute.\n * @param args - Arguments to pass to the action.\n * @param config - Handler configuration (cache handler, renderer).\n */\nexport async function executeAction(\n actionFn: (...args: unknown[]) => Promise<unknown>,\n args: unknown[],\n config: ActionHandlerConfig = {},\n spanMeta?: { actionFile?: string; actionName?: string }\n): Promise<ActionHandlerResult> {\n const state: RevalidationState = { paths: [], tags: [] };\n let actionResult: unknown;\n let redirectTo: string | undefined;\n let redirectStatus: number | undefined;\n\n // Run the action inside ALS scope so revalidatePath/Tag resolve to this\n // request's state object — concurrent requests each get their own scope.\n await revalidationAls.run(state, async () => {\n try {\n actionResult = await withSpan(\n 'timber.action',\n {\n ...(spanMeta?.actionFile ? { 'timber.action_file': spanMeta.actionFile } : {}),\n ...(spanMeta?.actionName ? { 'timber.action_name': spanMeta.actionName } : {}),\n },\n () => actionFn(...args)\n );\n } catch (error) {\n if (isRedirectSignal(error)) {\n redirectTo = error.location;\n redirectStatus = error.status;\n } else {\n throw error;\n }\n }\n });\n\n // Process tag invalidation through cache.invalidate so it records the\n // invalidation epoch — an in-flight cached fn must not re-store data this\n // mutation just invalidated (TIM-1028). cache.invalidate resolves the\n // module-level handler singleton: setCacheHandler() is called at boot from\n // rsc-entry when timber.config.ts provides a cacheHandler; otherwise falls\n // back to in-memory LRU (TIM-599).\n if (state.tags.length > 0) {\n // cache.invalidate handles both data-cache clearing AND component-seed\n // tombstone writes (design/45 §ISR mechanics) — no separate tombstone\n // step needed here.\n // Best-effort: the action body already executed and its mutation committed.\n // Use allSettled so all tags settle before proceeding — Promise.all would\n // short-circuit on the first rejection, leaving other invalidations (including\n // component-seed tombstone writes) in-flight during revalidatePath rendering.\n const results = await Promise.allSettled(state.tags.map((tag) => cache.invalidate({ tag })));\n for (const r of results) {\n if (r.status === 'rejected') {\n console.error('[timber] revalidateTag invalidation failed:', r.reason);\n }\n }\n }\n\n // Process path revalidation — build element tree (not yet serialized).\n //\n // TIM-1454: Only render the path if it matches the current page. Non-matching\n // paths are collected as `invalidatedPaths` — the client evicts them from its\n // caches so the next navigation fetches fresh data.\n let revalidation: RevalidationResult | undefined;\n let invalidatedPaths: string[] | undefined;\n\n if (state.paths.length > 0) {\n const path = state.paths[0];\n\n if (\n config.requestPathname &&\n !revalidationPathMatches(path, config.requestPathname, config.requestSearch)\n ) {\n invalidatedPaths = [stripFragment(path)];\n } else if (config.renderer) {\n try {\n revalidation = await config.renderer(path);\n } catch (renderError) {\n if (isRedirectSignal(renderError)) {\n redirectTo = renderError.location;\n redirectStatus = renderError.status;\n } else if (isDenySignal(renderError)) {\n console.error(\n `[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`\n );\n } else {\n console.error('[timber] revalidatePath render failed:', renderError);\n }\n }\n }\n }\n\n return {\n actionResult,\n revalidation,\n invalidatedPaths,\n ...(redirectTo ? { redirectTo, redirectStatus } : {}),\n };\n}\n\n/**\n * Check if a `revalidatePath(path)` argument matches the request pathname.\n/**\n * Canonicalizes the revalidated path the same way the renderer does so\n * equivalent paths compare equal. Returns true when the paths match or\n * when comparison is inconclusive (validation failure → render to be safe).\n */\nfunction revalidationPathMatches(\n revalidatedPath: string,\n requestPathname: string,\n requestSearch?: string\n): boolean {\n const hashIdx = revalidatedPath.indexOf('#');\n const noFragment = hashIdx >= 0 ? revalidatedPath.slice(0, hashIdx) : revalidatedPath;\n const queryIdx = noFragment.indexOf('?');\n const pathnameOnly = queryIdx >= 0 ? noFragment.slice(0, queryIdx) : noFragment;\n const search = queryIdx >= 0 ? noFragment.slice(queryIdx) : '';\n const canonical = canonicalize(pathnameOnly, true);\n if (!canonical.ok) return true;\n if (canonical.pathname !== requestPathname) return false;\n // revalidatePath('/dashboard') (no search) matches any query variant.\n // revalidatePath('/dashboard?tab=x') only matches that exact search.\n if (search && search !== (requestSearch ?? '')) return false;\n return true;\n}\n"],"mappings":"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"}
|
|
@@ -1,52 +1,3 @@
|
|
|
1
|
-
import { r as requestContextAls } from "./als-registry-C6kcfprT.js";
|
|
2
|
-
//#region src/server/csp-nonce.ts
|
|
3
|
-
/**
|
|
4
|
-
* CSP nonce — per-request, generated on demand.
|
|
5
|
-
*
|
|
6
|
-
* timber emits several inline `<script>` tags per HTML response — React's
|
|
7
|
-
* bootstrap script and Fizz runtime, the Flight init/chunk scripts, the
|
|
8
|
-
* segment-tree script, and the post-flush noindex script. Under a strict
|
|
9
|
-
* `Content-Security-Policy` (`script-src 'nonce-…' 'strict-dynamic'`) every
|
|
10
|
-
* one of them needs the request's nonce or the browser refuses to run it.
|
|
11
|
-
*
|
|
12
|
-
* The app opts in by reading `ctx.nonce` in `middleware.ts` and putting it
|
|
13
|
-
* in the CSP header it sets through `ctx.headers`. The first read generates
|
|
14
|
-
* the value and stores it on the request context; the render pipeline reads
|
|
15
|
-
* it back with `getCspNonce()` and stamps it on every inline script. A
|
|
16
|
-
* request whose middleware never reads `ctx.nonce` has no nonce, and its
|
|
17
|
-
* markup is byte-identical to a render without this feature. timber does
|
|
18
|
-
* not parse the CSP header — the app owns the policy, timber owns the
|
|
19
|
-
* markup, and `ctx.nonce` is the one value both are built from.
|
|
20
|
-
*
|
|
21
|
-
* `proxy.ts` cannot participate: it only sees the finished response from
|
|
22
|
-
* `next()`, after the markup exists.
|
|
23
|
-
*
|
|
24
|
-
* See design/13-security.md §"Content Security Policy" (TIM-1414).
|
|
25
|
-
*/
|
|
26
|
-
/**
|
|
27
|
-
* Generate a CSP nonce: 128 random bits, base64. Matches CSP's
|
|
28
|
-
* `base64-value` grammar, so it is valid in the header verbatim and safe
|
|
29
|
-
* inside a double-quoted HTML attribute without escaping.
|
|
30
|
-
*/
|
|
31
|
-
function generateNonce() {
|
|
32
|
-
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
33
|
-
let binary = "";
|
|
34
|
-
for (const b of bytes) binary += String.fromCharCode(b);
|
|
35
|
-
return btoa(binary);
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* The request's CSP nonce, creating it on first call. Backs the
|
|
39
|
-
* `ctx.nonce` getter on `MiddlewareContext`. Outside a request context
|
|
40
|
-
* (tests, build-time rendering) a fresh value is returned but not
|
|
41
|
-
* retained, since there is no request for it to belong to.
|
|
42
|
-
*/
|
|
43
|
-
function requestCspNonce() {
|
|
44
|
-
const store = requestContextAls.getStore();
|
|
45
|
-
if (!store) return generateNonce();
|
|
46
|
-
store.cspNonce ??= generateNonce();
|
|
47
|
-
return store.cspNonce;
|
|
48
|
-
}
|
|
49
|
-
//#endregion
|
|
50
1
|
//#region src/server/build-manifest.ts
|
|
51
2
|
/**
|
|
52
3
|
* Empty build manifest used in dev mode, and the single source of truth for
|
|
@@ -130,6 +81,6 @@ function collectRouteModulepreloads(segments, manifest) {
|
|
|
130
81
|
return result;
|
|
131
82
|
}
|
|
132
83
|
//#endregion
|
|
133
|
-
export {
|
|
84
|
+
export { collectRouteModulepreloads as i, collectRouteCss as n, collectRouteFonts as r, EMPTY_BUILD_MANIFEST as t };
|
|
134
85
|
|
|
135
|
-
//# sourceMappingURL=build-manifest-
|
|
86
|
+
//# sourceMappingURL=build-manifest-DWppEdLB.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-manifest-DWppEdLB.js","names":[],"sources":["../../src/server/build-manifest.ts"],"sourcesContent":["/**\n * Build manifest types and utilities for CSS and JS asset tracking.\n *\n * The build manifest maps route segment file paths to their output\n * chunks from Vite's client build. This enables:\n * - <link rel=\"stylesheet\"> injection in HTML <head>\n * - <script type=\"module\"> with hashed URLs in production\n * - <link rel=\"modulepreload\"> for client chunk dependencies\n * - Link preload headers for Early Hints (103)\n *\n * In dev mode, Vite's HMR client handles CSS/JS injection, so the build\n * manifest is empty. In production, it's populated from Vite's\n * .vite/manifest.json after the client build.\n *\n * Design docs: 18-build-system.md §\"Build Manifest\", 02-rendering-pipeline.md §\"Early Hints\"\n */\n\n/** A font asset entry in the build manifest. */\nexport interface ManifestFontEntry {\n /** URL path to the font file (e.g. `/_timber/fonts/inter-latin-400-abc123.woff2`). */\n href: string;\n /** Font format (e.g. `woff2`). */\n format: string;\n /** Crossorigin attribute — always `anonymous` for fonts. */\n crossOrigin: string;\n}\n\n/** Build manifest mapping input file paths to output asset URLs. */\nexport interface BuildManifest {\n /** Map from input file path (relative to project root) to output CSS URLs. */\n css: Record<string, string[]>;\n /** Map from input file path to output JS chunk URL (hashed filename). */\n js: Record<string, string>;\n /** Map from input file path to transitive JS dependency URLs for modulepreload. */\n modulepreload: Record<string, string[]>;\n /** Map from input file path to font assets used by that module. */\n fonts: Record<string, ManifestFontEntry[]>;\n /**\n * Cache-busting hashes for metadata route files (opengraph-image, etc.).\n * Map from file path (relative to project root) to short content hash.\n * In dev mode this is empty — a startup nonce is used instead.\n */\n metadataRouteHashes?: Record<string, string>;\n /**\n * Static request-dependency map from the Vite plugin's module-graph analysis.\n * Map from segment entry file path to whether it's request-dependent.\n * Used by buildSegmentInfo() for X-Timber-Segments in production.\n */\n requestDep?: Record<string, boolean>;\n}\n\n/**\n * Empty build manifest used in dev mode, and the single source of truth for\n * what an empty manifest contains.\n *\n * `virtual:timber-build-manifest` serializes this constant into its generated\n * source rather than restating it — one literal for the dev module, one for the\n * production module's `globalThis.__TIMBER_BUILD_MANIFEST__ ?? …` fallback.\n * They were three hand-written objects that had already drifted: this one and\n * the dev literal were missing `requestDep` while the production fallback had\n * it, so a reader who grepped the built bundle for `requestDep` found the one\n * copy that mentioned it and read the fallback as the shipped map (TIM-1300).\n * A field added here now reaches every copy.\n */\nexport const EMPTY_BUILD_MANIFEST: BuildManifest = {\n css: {},\n js: {},\n modulepreload: {},\n fonts: {},\n metadataRouteHashes: {},\n requestDep: {},\n};\n\n/** Segment shape expected by collectRouteCss (matches ManifestSegmentNode). */\ninterface SegmentWithFiles {\n layout?: { filePath: string };\n page?: { filePath: string };\n}\n\n/**\n * Collect all CSS files needed for a matched route's segment chain.\n *\n * Walks segments root → leaf, collecting CSS for each layout and page.\n * Deduplicates while preserving order (root layout CSS first).\n */\nexport function collectRouteCss(segments: SegmentWithFiles[], manifest: BuildManifest): string[] {\n const seen = new Set<string>();\n const result: string[] = [];\n\n for (const segment of segments) {\n for (const file of [segment.layout, segment.page]) {\n if (!file) continue;\n const cssFiles = manifest.css[file.filePath];\n if (!cssFiles) continue;\n for (const url of cssFiles) {\n if (!seen.has(url)) {\n seen.add(url);\n result.push(url);\n }\n }\n }\n }\n\n return result;\n}\n\n/**\n * Generate <link rel=\"stylesheet\"> tags for CSS URLs.\n *\n * Returns an HTML string to prepend to headHtml for injection\n * via injectHead() before </head>.\n */\nexport function buildCssLinkTags(cssUrls: string[]): string {\n // Emit only <link rel=\"stylesheet\"> — no preload tags. CSS preloading\n // is handled by 103 Early Hints (Link header) which fires before the\n // HTML stream. Float also emits `<link rel=\"stylesheet\" data-precedence>`\n // at the top of <head> during Fizz rendering — the browser discovers\n // CSS from that tag. A <link rel=\"preload\"> here would arrive *after*\n // Float's stylesheet tag and trigger \"Preload was ignored\" warnings.\n return cssUrls.map((url) => `<link rel=\"stylesheet\" href=\"${url}\">`).join('');\n}\n\n// ─── Font utilities ──────────────────────────────────────────────────────\n\n/**\n * Collect all font entries needed for a matched route's segment chain.\n *\n * Walks segments root → leaf, collecting fonts for each layout and page.\n * Deduplicates by href while preserving order.\n */\nexport function collectRouteFonts(\n segments: SegmentWithFiles[],\n manifest: BuildManifest\n): ManifestFontEntry[] {\n const seen = new Set<string>();\n const result: ManifestFontEntry[] = [];\n\n for (const segment of segments) {\n for (const file of [segment.layout, segment.page]) {\n if (!file) continue;\n const fonts = manifest.fonts[file.filePath];\n if (!fonts) continue;\n for (const entry of fonts) {\n if (!seen.has(entry.href)) {\n seen.add(entry.href);\n result.push(entry);\n }\n }\n }\n }\n\n return result;\n}\n\n/**\n * Generate <link rel=\"preload\"> tags for font assets.\n *\n * Font preloads use `as=font` and always include `crossorigin` (required\n * for font preloads even for same-origin resources per the spec).\n */\nexport function buildFontPreloadTags(fonts: ManifestFontEntry[]): string {\n return fonts\n .map(\n (f) =>\n `<link rel=\"preload\" href=\"${f.href}\" as=\"font\" type=\"font/${f.format}\" crossorigin=\"${f.crossOrigin}\">`\n )\n .join('');\n}\n\n// ─── JS chunk utilities ──────────────────────────────────────────────────\n\n/**\n * Collect modulepreload URLs for a matched route's segment chain.\n *\n * Walks segments root → leaf, collecting transitive JS dependencies\n * for each layout and page. Deduplicates across segments.\n */\nexport function collectRouteModulepreloads(\n segments: SegmentWithFiles[],\n manifest: BuildManifest\n): string[] {\n const seen = new Set<string>();\n const result: string[] = [];\n\n for (const segment of segments) {\n for (const file of [segment.layout, segment.page]) {\n if (!file) continue;\n const preloads = manifest.modulepreload[file.filePath];\n if (!preloads) continue;\n for (const url of preloads) {\n if (!seen.has(url)) {\n seen.add(url);\n result.push(url);\n }\n }\n }\n }\n\n return result;\n}\n\n/**\n * Generate <link rel=\"modulepreload\"> tags for JS dependency URLs.\n *\n * Modulepreload hints tell the browser to fetch and parse JS modules\n * before they're needed, reducing waterfall latency for dynamic imports.\n */\nexport function buildModulepreloadTags(urls: string[]): string {\n return urls.map((url) => `<link rel=\"modulepreload\" href=\"${url}\">`).join('');\n}\n"],"mappings":";;;;;;;;;;;;;;AAgEA,IAAa,uBAAsC;CACjD,KAAK,CAAC;CACN,IAAI,CAAC;CACL,eAAe,CAAC;CAChB,OAAO,CAAC;CACR,qBAAqB,CAAC;CACtB,YAAY,CAAC;AACf;;;;;;;AAcA,SAAgB,gBAAgB,UAA8B,UAAmC;CAC/F,MAAM,uBAAO,IAAI,IAAY;CAC7B,MAAM,SAAmB,CAAC;CAE1B,KAAK,MAAM,WAAW,UACpB,KAAK,MAAM,QAAQ,CAAC,QAAQ,QAAQ,QAAQ,IAAI,GAAG;EACjD,IAAI,CAAC,MAAM;EACX,MAAM,WAAW,SAAS,IAAI,KAAK;EACnC,IAAI,CAAC,UAAU;EACf,KAAK,MAAM,OAAO,UAChB,IAAI,CAAC,KAAK,IAAI,GAAG,GAAG;GAClB,KAAK,IAAI,GAAG;GACZ,OAAO,KAAK,GAAG;EACjB;CAEJ;CAGF,OAAO;AACT;;;;;;;AA0BA,SAAgB,kBACd,UACA,UACqB;CACrB,MAAM,uBAAO,IAAI,IAAY;CAC7B,MAAM,SAA8B,CAAC;CAErC,KAAK,MAAM,WAAW,UACpB,KAAK,MAAM,QAAQ,CAAC,QAAQ,QAAQ,QAAQ,IAAI,GAAG;EACjD,IAAI,CAAC,MAAM;EACX,MAAM,QAAQ,SAAS,MAAM,KAAK;EAClC,IAAI,CAAC,OAAO;EACZ,KAAK,MAAM,SAAS,OAClB,IAAI,CAAC,KAAK,IAAI,MAAM,IAAI,GAAG;GACzB,KAAK,IAAI,MAAM,IAAI;GACnB,OAAO,KAAK,KAAK;EACnB;CAEJ;CAGF,OAAO;AACT;;;;;;;AAyBA,SAAgB,2BACd,UACA,UACU;CACV,MAAM,uBAAO,IAAI,IAAY;CAC7B,MAAM,SAAmB,CAAC;CAE1B,KAAK,MAAM,WAAW,UACpB,KAAK,MAAM,QAAQ,CAAC,QAAQ,QAAQ,QAAQ,IAAI,GAAG;EACjD,IAAI,CAAC,MAAM;EACX,MAAM,WAAW,SAAS,cAAc,KAAK;EAC7C,IAAI,CAAC,UAAU;EACf,KAAK,MAAM,OAAO,UAChB,IAAI,CAAC,KAAK,IAAI,GAAG,GAAG;GAClB,KAAK,IAAI,GAAG;GACZ,OAAO,KAAK,GAAG;EACjB;CAEJ;CAGF,OAAO;AACT"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { U as addSpanEventSync, d as logSwrRefetchFailed, h as swallow, z as getWaitUntil } from "./logger-DqJ2VoAY.js";
|
|
2
2
|
import { t as fnv1aHash } from "./fast-hash-C-tVYrLb.js";
|
|
3
3
|
import { n as createSingleflight } from "./singleflight-2lUWfcAk.js";
|
|
4
4
|
import { t as estimateByteSize } from "./sizeof-lXffQ1Vr.js";
|
|
@@ -1146,4 +1146,4 @@ var cache = {
|
|
|
1146
1146
|
//#endregion
|
|
1147
1147
|
export { setDefaultSingleflightTimeout as _, getManifestEntry as a, wasInvalidatedSince as b, lookupPrebuiltPayload as c, checkVersionSkew as d, MemoryCacheHandler as f, setCacheHandler as g, getCacheHandler as h, storeOverlayEntry as i, setPrebuiltPayloadSource as l, RedisCacheHandler as m, lookupOverlay as n, hasPrebuiltPayloadSource as o, TagAwareCacheHandler as p, overlayKey as r, isParamIndependent as s, cache as t, applyReloadHeaders as u, currentInvalidationEpoch as v, lastInvalidationEpochFor as y };
|
|
1148
1148
|
|
|
1149
|
-
//# sourceMappingURL=cache-api-
|
|
1149
|
+
//# sourceMappingURL=cache-api-CAPbZTga.js.map
|