@timber-js/app 0.2.0-alpha.156 → 0.2.0-alpha.158
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/_chunks/{actions-8lJZYYGq.js → actions-BA5tIpVu.js} +23 -7
- package/dist/_chunks/actions-BA5tIpVu.js.map +1 -0
- package/dist/_chunks/{build-output-helper-Rp9r7gSA.js → build-output-helper-C8q4Zpf5.js} +2 -2
- package/dist/_chunks/{build-output-helper-Rp9r7gSA.js.map → build-output-helper-C8q4Zpf5.js.map} +1 -1
- package/dist/_chunks/{cache-api-e1E4i3Vy.js → cache-api-Cbyh3RW4.js} +2 -2
- package/dist/_chunks/{cache-api-e1E4i3Vy.js.map → cache-api-Cbyh3RW4.js.map} +1 -1
- package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -1
- package/dist/_chunks/{cli-schema-sync-ChsRzw8o.js → cli-schema-sync-DcmQrzJG.js} +14 -6
- package/dist/_chunks/{cli-schema-sync-ChsRzw8o.js.map → cli-schema-sync-DcmQrzJG.js.map} +1 -1
- package/dist/_chunks/define-CFmvb4Bt.js.map +1 -1
- package/dist/_chunks/fast-hash-D6hIVt1Y.js.map +1 -1
- package/dist/_chunks/format-CfwjgPz9.js.map +1 -1
- package/dist/_chunks/{href-validation-FiA_rToW.js → href-validation-CMc5JRls.js} +3 -3
- package/dist/_chunks/{href-validation-FiA_rToW.js.map → href-validation-CMc5JRls.js.map} +1 -1
- package/dist/_chunks/{logger-DvmCWhN9.js → logger-C5v1IMuh.js} +4 -4
- package/dist/_chunks/{logger-DvmCWhN9.js.map → logger-C5v1IMuh.js.map} +1 -1
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
- package/dist/_chunks/{plugin-context-GfDWsdd2.js → plugin-context-DUl3dnCF.js} +47 -2
- package/dist/_chunks/{plugin-context-GfDWsdd2.js.map → plugin-context-DUl3dnCF.js.map} +1 -1
- package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -1
- package/dist/_chunks/use-query-states-CbeQmext.js.map +1 -1
- package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
- package/dist/_chunks/{validation-taUkvA4Q.js → validation-CnKhGJsp.js} +2 -2
- package/dist/_chunks/{validation-taUkvA4Q.js.map → validation-CnKhGJsp.js.map} +1 -1
- package/dist/_chunks/{walkers-DZuFKqFX.js → walkers-D5jCGPum.js} +2 -2
- package/dist/_chunks/{walkers-DZuFKqFX.js.map → walkers-D5jCGPum.js.map} +1 -1
- package/dist/adapters/cloudflare-dev.js.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/nitro.js +2 -2
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js.map +1 -1
- package/dist/codec.js.map +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.js +15 -42
- package/dist/index.js.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/search-params/index.js.map +1 -1
- package/dist/server/form-data.d.ts +12 -0
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +8 -8
- package/dist/server/internal.js.map +1 -1
- package/package.json +1 -1
- package/src/server/form-data.ts +23 -7
- package/src/server/stream-utils.ts +1 -1
- package/dist/_chunks/actions-8lJZYYGq.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-D4urWkeV.js +0 -2
- package/dist/_chunks/plugin-context-DurqFBuY.js +0 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { $ as revalidationAls, Y as withSpan, _ as RedirectSignal, g as DenySignal } from "./logger-
|
|
2
|
-
import { t as cache } from "./cache-api-
|
|
1
|
+
import { $ as revalidationAls, Y as withSpan, _ as RedirectSignal, g as DenySignal } from "./logger-C5v1IMuh.js";
|
|
2
|
+
import { t as cache } from "./cache-api-Cbyh3RW4.js";
|
|
3
3
|
//#region src/server/form-data.ts
|
|
4
4
|
/**
|
|
5
5
|
* FormData preprocessing — schema-agnostic conversion of FormData to typed objects.
|
|
@@ -29,18 +29,19 @@ function parseFormData(formData) {
|
|
|
29
29
|
if (DANGEROUS_KEYS.has(key)) continue;
|
|
30
30
|
const processed = formData.getAll(key).map(normalizeValue);
|
|
31
31
|
if (processed.length === 1) flat[key] = processed[0];
|
|
32
|
-
else flat[key] = processed.filter((v) => v !== void 0);
|
|
32
|
+
else flat[key] = processed.filter((v) => v !== void 0 && v !== "");
|
|
33
33
|
}
|
|
34
34
|
return expandDotPaths(flat);
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* Normalize a single FormData entry value.
|
|
38
|
-
* - Empty strings → undefined (enables .optional() semantics)
|
|
39
38
|
* - Empty File objects (no selection) → undefined
|
|
39
|
+
* - Strings pass through as-is (empty strings stay as empty strings so
|
|
40
|
+
* schema validators like `z.string().min(1, 'Required')` can produce
|
|
41
|
+
* their custom error messages instead of a generic type mismatch)
|
|
40
42
|
* - Everything else passes through as-is
|
|
41
43
|
*/
|
|
42
44
|
function normalizeValue(value) {
|
|
43
|
-
if (typeof value === "string") return value === "" ? void 0 : value;
|
|
44
45
|
if (value instanceof File && value.size === 0 && value.name === "") return;
|
|
45
46
|
return value;
|
|
46
47
|
}
|
|
@@ -77,7 +78,7 @@ function expandDotPaths(flat) {
|
|
|
77
78
|
}
|
|
78
79
|
return result;
|
|
79
80
|
}
|
|
80
|
-
var DANGEROUS_KEYS = new Set(["__proto__"]);
|
|
81
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__"]);
|
|
81
82
|
/**
|
|
82
83
|
* Schema-agnostic coercion primitives for common FormData patterns.
|
|
83
84
|
*
|
|
@@ -92,6 +93,21 @@ var DANGEROUS_KEYS = new Set(["__proto__"]);
|
|
|
92
93
|
* ```
|
|
93
94
|
*/
|
|
94
95
|
var coerce = {
|
|
96
|
+
/**
|
|
97
|
+
* Coerce an empty string to undefined for optional text fields.
|
|
98
|
+
* Use with `.optional()` schemas where an empty input means "not provided".
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* // Zod
|
|
102
|
+
* z.unknown().transform(coerce.text).pipe(z.string().optional())
|
|
103
|
+
* // Valibot
|
|
104
|
+
* v.pipe(v.unknown(), v.transform(coerce.text), v.optional(v.string()))
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
text(value) {
|
|
108
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
109
|
+
if (typeof value === "string") return value;
|
|
110
|
+
},
|
|
95
111
|
/**
|
|
96
112
|
* Coerce a string to a number.
|
|
97
113
|
* - `"42"` → `42`
|
|
@@ -295,4 +311,4 @@ async function executeAction(actionFn, args, config = {}, spanMeta) {
|
|
|
295
311
|
//#endregion
|
|
296
312
|
export { parseFormData as a, coerce as i, revalidatePath as n, revalidateTag as r, executeAction as t };
|
|
297
313
|
|
|
298
|
-
//# sourceMappingURL=actions-
|
|
314
|
+
//# sourceMappingURL=actions-BA5tIpVu.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"actions-BA5tIpVu.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';\nimport { DenySignal, RedirectSignal } from './primitives';\nimport { withSpan } from './tracing';\nimport { revalidationAls, type RevalidationState } from './als-registry.js';\nimport type { HeadElementPayload } from '../shared/head-header.js';\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\n/** Result of rendering a revalidation — element tree before RSC serialization. */\nexport interface RevalidationResult {\n /** React element tree (pre-serialization — passed to renderToReadableStream). */\n element: unknown;\n /** Resolved head elements for metadata (forwarded via X-Timber-Head). */\n headElements: HeadElementPayload[];\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.js';\n\n/** Options for creating the action handler. */\nexport interface ActionHandlerConfig {\n /** Renderer for producing RSC payloads during revalidation. */\n renderer?: RevalidateRenderer;\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 /** 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// ─── 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 (error instanceof RedirectSignal) {\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 await Promise.all(state.tags.map((tag) => cache.invalidate({ tag })));\n }\n\n // Process path revalidation — build element tree (not yet serialized)\n let revalidation: RevalidationResult | undefined;\n if (state.paths.length > 0 && config.renderer) {\n // For now, render the first revalidated path.\n // Multiple paths could be supported via multipart streaming in the future.\n const path = state.paths[0];\n try {\n revalidation = await config.renderer(path);\n } catch (renderError) {\n if (renderError instanceof RedirectSignal) {\n // Revalidation triggered a redirect (e.g., session expired,\n // middleware on the target path called `redirect()`).\n redirectTo = renderError.location;\n redirectStatus = renderError.status;\n } else if (renderError instanceof DenySignal) {\n // Target path's middleware/access denied this user.\n //\n // Dropping the revalidation here is the security boundary added\n // in TIM-996: without this branch, a DenySignal would fall into\n // the `console.error` path AND the element tree would have\n // already been built and serialized — leaking a privileged RSC\n // payload to the action submitter. By the time the signal\n // surfaces, the renderer has thrown BEFORE building the tree,\n // so we simply drop `revalidation` and ship the action result\n // alone. The action itself still succeeded.\n console.error(\n `[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`\n );\n } else {\n // Log but don't fail the action — revalidation is best-effort.\n // Includes input-validation rejections from the renderer\n // (e.g., revalidatePath('//evil.com/admin')).\n console.error('[timber] revalidatePath render failed:', renderError);\n }\n }\n }\n\n return {\n actionResult,\n revalidation,\n ...(redirectTo ? { redirectTo, redirectStatus } : {}),\n };\n}\n"],"mappings":"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"}
|
|
@@ -90,7 +90,7 @@ async function runSharedBuildSteps(opts) {
|
|
|
90
90
|
*
|
|
91
91
|
* @internal Exported for testing.
|
|
92
92
|
*/
|
|
93
|
-
var PRIORITY_FILES = new Set([
|
|
93
|
+
var PRIORITY_FILES = /* @__PURE__ */ new Set([
|
|
94
94
|
"/favicon.ico",
|
|
95
95
|
"/robots.txt",
|
|
96
96
|
"/sitemap.xml",
|
|
@@ -152,4 +152,4 @@ async function collectFiles(dir) {
|
|
|
152
152
|
//#endregion
|
|
153
153
|
export { IMMUTABLE_CACHE as n, runSharedBuildSteps as t };
|
|
154
154
|
|
|
155
|
-
//# sourceMappingURL=build-output-helper-
|
|
155
|
+
//# sourceMappingURL=build-output-helper-C8q4Zpf5.js.map
|
package/dist/_chunks/{build-output-helper-Rp9r7gSA.js.map → build-output-helper-C8q4Zpf5.js.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-output-helper-Rp9r7gSA.js","names":[],"sources":["../../src/adapters/shared.ts","../../src/adapters/build-output-helper.ts"],"sourcesContent":["// Shared adapter utilities — leaf module with zero deps on server/, client/,\n// or any Vite-dependent code.\n//\n// Adapters are loaded by Node at Vite startup time, before Vite's module\n// resolver is available. This module uses only Node built-ins and can be\n// imported by both adapters via a relative path.\n//\n// IMPORTANT: Do NOT add imports from ../server/, ../client/, ../plugins/,\n// or any module that transitively depends on Vite. A test in\n// tests/adapters/shared-no-forbidden-imports.test.ts enforces this.\n\n// ─── Static asset cache headers ──────────────────────────────────────────\n\n/** Cache-Control value for hashed (immutable) assets. */\nexport const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';\n\n/** Cache-Control value for unhashed static assets. */\nexport const STATIC_CACHE = 'public, max-age=3600, must-revalidate';\n\n/**\n * Generate a `_headers` file for static asset cache control.\n *\n * The `_headers` file is a platform convention supported by Cloudflare Workers\n * Static Assets, Cloudflare Pages, and Netlify. It maps URL patterns to\n * HTTP response headers.\n *\n * Only hashed (immutable) asset paths are listed here by default. When\n * `unhashedPaths` is provided, per-file rules are appended for unhashed\n * static assets (favicon.ico, robots.txt, etc.) so they get Cache-Control\n * on platforms like Cloudflare Workers Static Assets where the server\n * handler never sees static file requests.\n *\n * A `/*` catch-all is NOT used — it would be additive on Cloudflare,\n * producing duplicate Cache-Control directives on hashed assets\n * (invalid per RFC 9111).\n */\n// Cloudflare Workers Static Assets caps _headers at 100 rules.\n// Reserve 2 for the wildcard hashed-asset rules.\nconst MAX_UNHASHED_RULES = 98;\n\nexport function generateHeadersFile(unhashedPaths?: string[]): string {\n let content = `# Auto-generated by @timber-js/app — static asset cache headers.\n# See design/25-production-deployments.md §\"CDN / Edge Cache\"\n\n/assets/*\n Cache-Control: ${IMMUTABLE_CACHE}\n\n/_timber/fonts/*\n Cache-Control: ${IMMUTABLE_CACHE}\n`;\n\n if (unhashedPaths && unhashedPaths.length > 0) {\n const capped = unhashedPaths.slice(0, MAX_UNHASHED_RULES);\n if (unhashedPaths.length > MAX_UNHASHED_RULES) {\n console.warn(\n `[timber] _headers: ${unhashedPaths.length} unhashed static files found, ` +\n `but Cloudflare limits _headers to 100 rules. Only the first ${MAX_UNHASHED_RULES} ` +\n `unhashed files will get Cache-Control headers.`\n );\n }\n content += `\\n# Unhashed static assets — short-lived cache with revalidation\\n`;\n for (const p of capped) {\n content += `\\n${p}\\n Cache-Control: ${STATIC_CACHE}\\n`;\n }\n }\n\n return content;\n}\n","// Shared build-output helper for adapter buildOutput() methods.\n//\n// Steps 1–5 of every adapter's buildOutput are platform-agnostic:\n// 1. Copy client assets to the public/static directory\n// 2. Write _headers file for static asset cache control\n// 3. Copy RSC + SSR server bundles\n// 4. Write manifest-init module (if present)\n//\n// Only the final steps (entry codegen, platform config) vary per adapter.\n//\n// IMPORTANT: This module must remain a leaf — no imports from ../server/,\n// ../client/, or Vite-dependent code. See adapters/shared.ts header.\n\nimport { writeFile, mkdir, cp, readdir, readFile } from 'node:fs/promises';\nimport { join, basename, relative } from 'node:path';\nimport { generateHeadersFile } from './shared.js';\nimport type { TimberConfig } from './types';\n\n/** Options for the shared build output steps. */\nexport interface BuildOutputBaseOptions {\n /** Resolved timber config. */\n config: TimberConfig;\n /** Root build directory (e.g. `.timber/build`). */\n buildDir: string;\n /** Adapter-specific output directory (e.g. `.timber/build/nitro`). */\n outDir: string;\n /**\n * Name of the public/static directory within outDir.\n * Nitro uses 'public', Cloudflare uses 'static'.\n */\n publicDirName: string;\n}\n\n/**\n * Run the platform-agnostic build output steps shared by all adapters:\n *\n * 1. Create the output directory\n * 2. Copy client assets to the public/static directory\n * 3. Write the `_headers` file for static asset cache control\n * 4. Copy RSC and SSR server bundles into the output directory\n * 5. Write the manifest-init module if present\n *\n * Returns the resolved public directory path for further adapter-specific use.\n */\nexport async function runSharedBuildSteps(opts: BuildOutputBaseOptions): Promise<string> {\n const { config, buildDir, outDir, publicDirName } = opts;\n\n // 1. Create the output directory.\n await mkdir(outDir, { recursive: true });\n\n // 2. Copy client assets to public/static directory.\n // When client JavaScript is disabled, skip .js files — only CSS,\n // fonts, images, and other static assets are needed.\n const clientDir = join(buildDir, 'client');\n const publicDir = join(outDir, publicDirName);\n await mkdir(publicDir, { recursive: true });\n // Find WASM files that are referenced by client JS (legitimate client WASM)\n // vs orphaned server-only WASM (e.g. @takumi-rs for OG image generation).\n // Only strip orphaned WASM to avoid breaking client-side WASM usage.\n const referencedWasm = await findReferencedWasm(clientDir);\n\n await cp(clientDir, publicDir, {\n recursive: true,\n filter: (src: string) => {\n if (src.endsWith('.wasm') && !referencedWasm.has(basename(src))) {\n return false;\n }\n if (config.clientJavascriptDisabled && src.endsWith('.js')) return false;\n return true;\n },\n }).catch((e: NodeJS.ErrnoException) => {\n // Client dir may not exist when client JavaScript is disabled\n if (e.code !== 'ENOENT') throw e;\n });\n\n // 3. Write _headers file for static asset cache control.\n // Scan for unhashed files so they get per-file Cache-Control rules in the\n // _headers file. On platforms like Cloudflare Workers Static Assets, the\n // worker never sees static file requests — _headers is the only mechanism.\n const unhashedPaths = await findUnhashedStaticFiles(publicDir);\n await writeFile(join(publicDir, '_headers'), generateHeadersFile(unhashedPaths));\n\n // 4. Copy RSC + SSR server bundles into the output directory.\n await cp(join(buildDir, 'rsc'), join(outDir, 'rsc'), { recursive: true });\n await cp(join(buildDir, 'ssr'), join(outDir, 'ssr'), { recursive: true }).catch(\n (e: NodeJS.ErrnoException) => {\n // SSR dir may not exist for static-only builds\n if (e.code !== 'ENOENT') throw e;\n }\n );\n\n // 5. Write manifest-init module if present.\n if (config.manifestInit) {\n await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);\n }\n\n return publicDir;\n}\n\n/**\n * Find unhashed static files in the public directory that need per-file\n * Cache-Control rules in the `_headers` file.\n *\n * Excludes:\n * - Files under `assets/` and `_timber/` (covered by wildcard rules)\n * - Underscore-prefixed files (`_headers`, `_worker.js`, etc.)\n *\n * @internal Exported for testing.\n */\n// Well-known metadata files that should be prioritized when capping\n// _headers rules, so they're never dropped in favor of less important files.\nconst PRIORITY_FILES = new Set([\n '/favicon.ico',\n '/robots.txt',\n '/sitemap.xml',\n '/manifest.json',\n '/manifest.webmanifest',\n '/apple-touch-icon.png',\n]);\n\nexport async function findUnhashedStaticFiles(publicDir: string): Promise<string[]> {\n let files: string[];\n try {\n files = await collectFiles(publicDir);\n } catch {\n return [];\n }\n\n const priority: string[] = [];\n const rest: string[] = [];\n for (const file of files) {\n const rel = relative(publicDir, file).replace(/\\\\/g, '/');\n if (rel.startsWith('assets/') || rel.startsWith('_timber/')) continue;\n if (basename(rel).startsWith('_')) continue;\n const urlPath = '/' + rel;\n if (PRIORITY_FILES.has(urlPath)) {\n priority.push(urlPath);\n } else {\n rest.push(urlPath);\n }\n }\n\n return [...priority.sort(), ...rest.sort()];\n}\n\n/**\n * Scan client JS files for references to .wasm filenames.\n * Returns the set of WASM basenames that are actually imported/referenced\n * by client code and must be preserved in the static output.\n */\nasync function findReferencedWasm(clientDir: string): Promise<Set<string>> {\n const referenced = new Set<string>();\n\n let files: string[];\n try {\n files = await collectFiles(clientDir);\n } catch {\n return referenced;\n }\n\n const jsFiles = files.filter((f) => f.endsWith('.js'));\n const wasmFiles = files.filter((f) => f.endsWith('.wasm')).map((f) => basename(f));\n if (wasmFiles.length === 0) return referenced;\n\n for (const jsFile of jsFiles) {\n const content = await readFile(jsFile, 'utf-8');\n for (const wasmName of wasmFiles) {\n if (content.includes(wasmName)) {\n referenced.add(wasmName);\n }\n }\n }\n\n return referenced;\n}\n\nasync function collectFiles(dir: string): Promise<string[]> {\n const result: string[] = [];\n const entries = await readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n if (entry.isDirectory()) {\n result.push(...(await collectFiles(fullPath)));\n } else {\n result.push(fullPath);\n }\n }\n return result;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"build-output-helper-C8q4Zpf5.js","names":[],"sources":["../../src/adapters/shared.ts","../../src/adapters/build-output-helper.ts"],"sourcesContent":["// Shared adapter utilities — leaf module with zero deps on server/, client/,\n// or any Vite-dependent code.\n//\n// Adapters are loaded by Node at Vite startup time, before Vite's module\n// resolver is available. This module uses only Node built-ins and can be\n// imported by both adapters via a relative path.\n//\n// IMPORTANT: Do NOT add imports from ../server/, ../client/, ../plugins/,\n// or any module that transitively depends on Vite. A test in\n// tests/adapters/shared-no-forbidden-imports.test.ts enforces this.\n\n// ─── Static asset cache headers ──────────────────────────────────────────\n\n/** Cache-Control value for hashed (immutable) assets. */\nexport const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';\n\n/** Cache-Control value for unhashed static assets. */\nexport const STATIC_CACHE = 'public, max-age=3600, must-revalidate';\n\n/**\n * Generate a `_headers` file for static asset cache control.\n *\n * The `_headers` file is a platform convention supported by Cloudflare Workers\n * Static Assets, Cloudflare Pages, and Netlify. It maps URL patterns to\n * HTTP response headers.\n *\n * Only hashed (immutable) asset paths are listed here by default. When\n * `unhashedPaths` is provided, per-file rules are appended for unhashed\n * static assets (favicon.ico, robots.txt, etc.) so they get Cache-Control\n * on platforms like Cloudflare Workers Static Assets where the server\n * handler never sees static file requests.\n *\n * A `/*` catch-all is NOT used — it would be additive on Cloudflare,\n * producing duplicate Cache-Control directives on hashed assets\n * (invalid per RFC 9111).\n */\n// Cloudflare Workers Static Assets caps _headers at 100 rules.\n// Reserve 2 for the wildcard hashed-asset rules.\nconst MAX_UNHASHED_RULES = 98;\n\nexport function generateHeadersFile(unhashedPaths?: string[]): string {\n let content = `# Auto-generated by @timber-js/app — static asset cache headers.\n# See design/25-production-deployments.md §\"CDN / Edge Cache\"\n\n/assets/*\n Cache-Control: ${IMMUTABLE_CACHE}\n\n/_timber/fonts/*\n Cache-Control: ${IMMUTABLE_CACHE}\n`;\n\n if (unhashedPaths && unhashedPaths.length > 0) {\n const capped = unhashedPaths.slice(0, MAX_UNHASHED_RULES);\n if (unhashedPaths.length > MAX_UNHASHED_RULES) {\n console.warn(\n `[timber] _headers: ${unhashedPaths.length} unhashed static files found, ` +\n `but Cloudflare limits _headers to 100 rules. Only the first ${MAX_UNHASHED_RULES} ` +\n `unhashed files will get Cache-Control headers.`\n );\n }\n content += `\\n# Unhashed static assets — short-lived cache with revalidation\\n`;\n for (const p of capped) {\n content += `\\n${p}\\n Cache-Control: ${STATIC_CACHE}\\n`;\n }\n }\n\n return content;\n}\n","// Shared build-output helper for adapter buildOutput() methods.\n//\n// Steps 1–5 of every adapter's buildOutput are platform-agnostic:\n// 1. Copy client assets to the public/static directory\n// 2. Write _headers file for static asset cache control\n// 3. Copy RSC + SSR server bundles\n// 4. Write manifest-init module (if present)\n//\n// Only the final steps (entry codegen, platform config) vary per adapter.\n//\n// IMPORTANT: This module must remain a leaf — no imports from ../server/,\n// ../client/, or Vite-dependent code. See adapters/shared.ts header.\n\nimport { writeFile, mkdir, cp, readdir, readFile } from 'node:fs/promises';\nimport { join, basename, relative } from 'node:path';\nimport { generateHeadersFile } from './shared.js';\nimport type { TimberConfig } from './types';\n\n/** Options for the shared build output steps. */\nexport interface BuildOutputBaseOptions {\n /** Resolved timber config. */\n config: TimberConfig;\n /** Root build directory (e.g. `.timber/build`). */\n buildDir: string;\n /** Adapter-specific output directory (e.g. `.timber/build/nitro`). */\n outDir: string;\n /**\n * Name of the public/static directory within outDir.\n * Nitro uses 'public', Cloudflare uses 'static'.\n */\n publicDirName: string;\n}\n\n/**\n * Run the platform-agnostic build output steps shared by all adapters:\n *\n * 1. Create the output directory\n * 2. Copy client assets to the public/static directory\n * 3. Write the `_headers` file for static asset cache control\n * 4. Copy RSC and SSR server bundles into the output directory\n * 5. Write the manifest-init module if present\n *\n * Returns the resolved public directory path for further adapter-specific use.\n */\nexport async function runSharedBuildSteps(opts: BuildOutputBaseOptions): Promise<string> {\n const { config, buildDir, outDir, publicDirName } = opts;\n\n // 1. Create the output directory.\n await mkdir(outDir, { recursive: true });\n\n // 2. Copy client assets to public/static directory.\n // When client JavaScript is disabled, skip .js files — only CSS,\n // fonts, images, and other static assets are needed.\n const clientDir = join(buildDir, 'client');\n const publicDir = join(outDir, publicDirName);\n await mkdir(publicDir, { recursive: true });\n // Find WASM files that are referenced by client JS (legitimate client WASM)\n // vs orphaned server-only WASM (e.g. @takumi-rs for OG image generation).\n // Only strip orphaned WASM to avoid breaking client-side WASM usage.\n const referencedWasm = await findReferencedWasm(clientDir);\n\n await cp(clientDir, publicDir, {\n recursive: true,\n filter: (src: string) => {\n if (src.endsWith('.wasm') && !referencedWasm.has(basename(src))) {\n return false;\n }\n if (config.clientJavascriptDisabled && src.endsWith('.js')) return false;\n return true;\n },\n }).catch((e: NodeJS.ErrnoException) => {\n // Client dir may not exist when client JavaScript is disabled\n if (e.code !== 'ENOENT') throw e;\n });\n\n // 3. Write _headers file for static asset cache control.\n // Scan for unhashed files so they get per-file Cache-Control rules in the\n // _headers file. On platforms like Cloudflare Workers Static Assets, the\n // worker never sees static file requests — _headers is the only mechanism.\n const unhashedPaths = await findUnhashedStaticFiles(publicDir);\n await writeFile(join(publicDir, '_headers'), generateHeadersFile(unhashedPaths));\n\n // 4. Copy RSC + SSR server bundles into the output directory.\n await cp(join(buildDir, 'rsc'), join(outDir, 'rsc'), { recursive: true });\n await cp(join(buildDir, 'ssr'), join(outDir, 'ssr'), { recursive: true }).catch(\n (e: NodeJS.ErrnoException) => {\n // SSR dir may not exist for static-only builds\n if (e.code !== 'ENOENT') throw e;\n }\n );\n\n // 5. Write manifest-init module if present.\n if (config.manifestInit) {\n await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);\n }\n\n return publicDir;\n}\n\n/**\n * Find unhashed static files in the public directory that need per-file\n * Cache-Control rules in the `_headers` file.\n *\n * Excludes:\n * - Files under `assets/` and `_timber/` (covered by wildcard rules)\n * - Underscore-prefixed files (`_headers`, `_worker.js`, etc.)\n *\n * @internal Exported for testing.\n */\n// Well-known metadata files that should be prioritized when capping\n// _headers rules, so they're never dropped in favor of less important files.\nconst PRIORITY_FILES = new Set([\n '/favicon.ico',\n '/robots.txt',\n '/sitemap.xml',\n '/manifest.json',\n '/manifest.webmanifest',\n '/apple-touch-icon.png',\n]);\n\nexport async function findUnhashedStaticFiles(publicDir: string): Promise<string[]> {\n let files: string[];\n try {\n files = await collectFiles(publicDir);\n } catch {\n return [];\n }\n\n const priority: string[] = [];\n const rest: string[] = [];\n for (const file of files) {\n const rel = relative(publicDir, file).replace(/\\\\/g, '/');\n if (rel.startsWith('assets/') || rel.startsWith('_timber/')) continue;\n if (basename(rel).startsWith('_')) continue;\n const urlPath = '/' + rel;\n if (PRIORITY_FILES.has(urlPath)) {\n priority.push(urlPath);\n } else {\n rest.push(urlPath);\n }\n }\n\n return [...priority.sort(), ...rest.sort()];\n}\n\n/**\n * Scan client JS files for references to .wasm filenames.\n * Returns the set of WASM basenames that are actually imported/referenced\n * by client code and must be preserved in the static output.\n */\nasync function findReferencedWasm(clientDir: string): Promise<Set<string>> {\n const referenced = new Set<string>();\n\n let files: string[];\n try {\n files = await collectFiles(clientDir);\n } catch {\n return referenced;\n }\n\n const jsFiles = files.filter((f) => f.endsWith('.js'));\n const wasmFiles = files.filter((f) => f.endsWith('.wasm')).map((f) => basename(f));\n if (wasmFiles.length === 0) return referenced;\n\n for (const jsFile of jsFiles) {\n const content = await readFile(jsFile, 'utf-8');\n for (const wasmName of wasmFiles) {\n if (content.includes(wasmName)) {\n referenced.add(wasmName);\n }\n }\n }\n\n return referenced;\n}\n\nasync function collectFiles(dir: string): Promise<string[]> {\n const result: string[] = [];\n const entries = await readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n if (entry.isDirectory()) {\n result.push(...(await collectFiles(fullPath)));\n } else {\n result.push(fullPath);\n }\n }\n return result;\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { L as getWaitUntil, V as addSpanEventSync, d as logSwrRefetchFailed } from "./logger-
|
|
1
|
+
import { L as getWaitUntil, V as addSpanEventSync, d as logSwrRefetchFailed } from "./logger-C5v1IMuh.js";
|
|
2
2
|
import { t as fnv1aHash } from "./fast-hash-D6hIVt1Y.js";
|
|
3
3
|
//#region src/cache/stable-stringify.ts
|
|
4
4
|
/**
|
|
@@ -398,4 +398,4 @@ cache.invalidate = async function invalidate(opts) {
|
|
|
398
398
|
//#endregion
|
|
399
399
|
export { setDefaultSingleflightTimeout as i, getCacheHandler as n, setCacheHandler as r, cache as t };
|
|
400
400
|
|
|
401
|
-
//# sourceMappingURL=cache-api-
|
|
401
|
+
//# sourceMappingURL=cache-api-Cbyh3RW4.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache-api-e1E4i3Vy.js","names":[],"sources":["../../src/cache/stable-stringify.ts","../../src/cache/singleflight.ts","../../src/cache/invalidation-epoch.ts","../../src/cache/timber-cache.ts","../../src/cache/handler-store.ts","../../src/cache/cache-api.ts"],"sourcesContent":["/**\n * Deterministic JSON serialization with sorted object keys.\n * Used for cache key generation — ensures { a: 1, b: 2 } and { b: 2, a: 1 }\n * produce the same string.\n *\n * Matches JSON.stringify semantics for toJSON: Date serializes to its ISO\n * string, URL to its href, and class instances with a custom toJSON use it.\n * Map and Set serialize their entries explicitly (insertion-order\n * independent). Values that would serialize lossily — functions, symbols,\n * and non-plain objects with no enumerable own state (RegExp, class\n * instances holding data behind getters or internal slots) — throw instead:\n * every such value would collapse to the same cache key, silently returning\n * one argument's cached data for another (TIM-1020).\n */\nexport function stableStringify(value: unknown): string {\n if (value === null || value === undefined) return String(value);\n\n const type = typeof value;\n if (type === 'function' || type === 'symbol') {\n throw new TypeError(\n `stableStringify: cannot serialize a ${type} into a stable cache key — ` +\n 'all such values would collide. Provide an explicit `key` option to cache().'\n );\n }\n if (type !== 'object') return JSON.stringify(value);\n\n if (Array.isArray(value)) {\n return '[' + value.map((item) => stableStringify(item)).join(',') + ']';\n }\n\n // Honor toJSON like JSON.stringify does (Date → ISO string, URL → href,\n // custom class serialization). JSON.stringify does not re-invoke toJSON\n // on the value it returns, so a toJSON that returns `this` falls through\n // to plain own-property serialization instead of recursing forever.\n const withToJSON = value as { toJSON?: () => unknown };\n if (typeof withToJSON.toJSON === 'function') {\n const json = withToJSON.toJSON();\n if (json !== value) return stableStringify(json);\n }\n\n // Map/Set have no enumerable own properties — serialize entries explicitly.\n // Entries are sorted after serialization so insertion order doesn't change\n // the key. The Map(/Set( prefixes can't collide with object ('{'), array\n // ('['), or string ('\"') output.\n if (value instanceof Map) {\n const entries = [...value.entries()].map(\n ([k, v]) => stableStringify(k) + '=>' + stableStringify(v)\n );\n return 'Map(' + entries.sort().join(',') + ')';\n }\n if (value instanceof Set) {\n const members = [...value].map((member) => stableStringify(member));\n return 'Set(' + members.sort().join(',') + ')';\n }\n\n const obj = value as Record<string, unknown>;\n const keys = Object.keys(obj).sort();\n const pairs: string[] = [];\n for (const key of keys) {\n if (obj[key] === undefined) continue;\n // A callable own toJSON only reaches this loop via the returns-`this`\n // fall-through above. It's the serialization protocol, not data —\n // JSON.stringify omits it too.\n if (key === 'toJSON' && typeof obj[key] === 'function') continue;\n pairs.push(JSON.stringify(key) + ':' + stableStringify(obj[key]));\n }\n\n // A non-plain object that serializes to '{}' carries its state behind\n // getters or internal slots (RegExp, sessions, ORM entities). Serializing\n // it would give every instance the same cache key.\n if (pairs.length === 0) {\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) {\n const name = (value as object).constructor?.name || 'Object';\n throw new TypeError(\n `stableStringify: ${name} instance has no serializable own state — ` +\n 'all instances would share one cache key. Provide an explicit `key` ' +\n 'option to cache(), or add a toJSON() method.'\n );\n }\n }\n\n return '{' + pairs.join(',') + '}';\n}\n","/**\n * Singleflight coalesces concurrent calls with the same key into a single\n * execution. All callers receive the same result (or error).\n *\n * Per-process, in-memory. Each process coalesces independently.\n *\n * An optional `timeoutMs` prevents hung `fn()` calls from permanently\n * blocking all future callers for that key. When set, `fn()` is raced\n * against a timeout — if the timeout fires first, the promise rejects\n * with `SingleflightTimeoutError`, `finally` cleans up the key, and\n * subsequent callers can retry. See TIM-518.\n */\n\nexport interface SingleflightOptions {\n /** Maximum time (ms) a coalesced call may run before being rejected. */\n timeoutMs?: number;\n}\n\nexport interface Singleflight {\n do<T>(key: string, fn: () => Promise<T>): Promise<T>;\n}\n\n/**\n * Error thrown when a singleflight call exceeds `timeoutMs`.\n * Exported so callers can distinguish timeout from other errors.\n */\nexport class SingleflightTimeoutError extends Error {\n constructor(key: string, timeoutMs: number) {\n super(`Singleflight timeout: key \"${key}\" exceeded ${timeoutMs}ms`);\n this.name = 'SingleflightTimeoutError';\n }\n}\n\nexport function createSingleflight(opts?: SingleflightOptions): Singleflight {\n const inflight = new Map<string, Promise<unknown>>();\n const timeoutMs = opts?.timeoutMs;\n\n return {\n do<T>(key: string, fn: () => Promise<T>): Promise<T> {\n const existing = inflight.get(key);\n if (existing) return existing as Promise<T>;\n\n let promise: Promise<T>;\n\n if (timeoutMs != null && timeoutMs > 0) {\n // Race fn() against a timeout to prevent hung calls from\n // permanently blocking the key. See TIM-518.\n promise = new Promise<T>((resolve, reject) => {\n const timer = setTimeout(\n () => reject(new SingleflightTimeoutError(key, timeoutMs)),\n timeoutMs\n );\n // Wrap in try/catch so a synchronous throw from fn()\n // (e.g. argument validation) still clears the timer.\n // Without this, the timer leaks until expiry.\n try {\n fn().then(\n (value) => {\n clearTimeout(timer);\n resolve(value);\n },\n (err) => {\n clearTimeout(timer);\n reject(err);\n }\n );\n } catch (err) {\n clearTimeout(timer);\n reject(err);\n }\n });\n } else {\n promise = fn();\n }\n\n const tracked = promise.finally(() => {\n inflight.delete(key);\n });\n\n inflight.set(key, tracked);\n return tracked as Promise<T>;\n },\n };\n}\n","/**\n * Per-process invalidation epoch tracking for timber.cache (TIM-1028).\n *\n * handler.set for a cache miss runs after `fn()` resolves. An explicit\n * `cache.invalidate({ tag })` / `revalidateTag` that lands while fn is in\n * flight would otherwise be silently undone — the just-invalidated value\n * re-stored as fresh for the full TTL, resurrecting pre-mutation data.\n *\n * Every invalidation bumps a monotonic epoch and records it against the\n * invalidated key/tag. Before storing a result, the cache wrapper checks\n * whether its key or any of its tags were invalidated after fn started and\n * skips the write if so. Skipping is always safe: the only cost is one\n * extra cache miss on the next call.\n *\n * Scope: per-process, like the singleflight map. An invalidation issued by\n * another instance against a shared handler (Redis/KV) during fn flight is\n * not observable here — closing that window fully would require store-level\n * versioning, which no CacheHandler backend offers.\n */\n\nlet epoch = 0;\n\n/**\n * Last-invalidated epoch per `key:<k>` / `tag:<t>` entry. Insertion order is\n * epoch order (entries are re-inserted on update), so the oldest entry is\n * always first — pruning evicts lowest epochs.\n */\nconst lastInvalidated = new Map<string, number>();\n\n/**\n * Bound on tracked entries so high-cardinality tag invalidation (e.g.\n * per-user tags) cannot grow this map without limit.\n */\nconst MAX_TRACKED_ENTRIES = 10_000;\n\n/**\n * Highest epoch evicted from the map. Any execution that started at or\n * before this epoch conservatively counts as invalidated — history was\n * truncated, so we can no longer prove it wasn't.\n */\nlet evictedEpochFloor = 0;\n\n/** The current epoch. Capture before running fn(), pass to wasInvalidatedSince. */\nexport function currentInvalidationEpoch(): number {\n return epoch;\n}\n\n/**\n * Record an invalidation. Call BEFORE handler.invalidate() so an execution\n * whose set-check races the invalidation errs toward skipping the write.\n */\nexport function recordInvalidation(opts: { key?: string; tag?: string }): void {\n epoch++;\n if (opts.key) track(`key:${opts.key}`);\n if (opts.tag) track(`tag:${opts.tag}`);\n}\n\nfunction track(entry: string): void {\n lastInvalidated.delete(entry);\n lastInvalidated.set(entry, epoch);\n while (lastInvalidated.size > MAX_TRACKED_ENTRIES) {\n const oldest = lastInvalidated.entries().next().value;\n if (oldest === undefined) break;\n evictedEpochFloor = Math.max(evictedEpochFloor, oldest[1]);\n lastInvalidated.delete(oldest[0]);\n }\n}\n\n/**\n * Whether the key or any tag was invalidated after `startEpoch` (the epoch\n * captured when fn started executing).\n */\nexport function wasInvalidatedSince(startEpoch: number, key: string, tags: string[]): boolean {\n return lastInvalidationEpochFor(key, tags) > startEpoch;\n}\n\n/**\n * The highest epoch at which the key or any of the tags was invalidated\n * (0 if never). Used to scope the singleflight key: callers arriving after\n * an invalidation must not coalesce onto a flight that started before it —\n * they would receive the pre-invalidation result the invalidation was\n * issued to discard.\n */\nexport function lastInvalidationEpochFor(key: string, tags: string[]): number {\n let last = evictedEpochFloor;\n const keyEpoch = lastInvalidated.get(`key:${key}`);\n if (keyEpoch !== undefined && keyEpoch > last) last = keyEpoch;\n for (const tag of tags) {\n const tagEpoch = lastInvalidated.get(`tag:${tag}`);\n if (tagEpoch !== undefined && tagEpoch > last) last = tagEpoch;\n }\n return last;\n}\n","import type { CacheHandler, CacheOptions } from './index';\nimport { stableStringify } from './stable-stringify';\nimport { createSingleflight } from './singleflight';\nimport {\n currentInvalidationEpoch,\n lastInvalidationEpochFor,\n wasInvalidatedSince,\n} from './invalidation-epoch';\nimport { addSpanEventSync } from '../server/tracing.js';\nimport { logSwrRefetchFailed } from '../server/logger.js';\nimport { getWaitUntil } from '../server/waituntil-bridge.js';\nimport { fnv1aHash } from './fast-hash.js';\n\nlet defaultSingleflight = createSingleflight();\n\n/**\n * Set the timeout for the module-level default singleflight.\n * Called at framework boot with renderTimeoutMs from timber.config.ts so that\n * cache calls without a per-call timeoutMs still get deadlock protection.\n * See design/06-caching.md §\"Singleflight Timeout — Deadlock Risk\".\n */\nexport function setDefaultSingleflightTimeout(timeoutMs: number): void {\n defaultSingleflight = createSingleflight({ timeoutMs });\n}\n\n/**\n * Generate a cache key from function identity and serialized args.\n *\n * Uses FNV-1a (fast non-crypto hash) instead of SHA-256. Cache keys don't\n * need collision resistance — they need speed. The fnId prefix provides\n * namespace isolation; the hash covers the args.\n *\n * See TIM-370 for perf motivation.\n */\nfunction defaultKeyGenerator(fnId: string, args: unknown[]): string {\n const raw = fnId + ':' + stableStringify(args);\n return fnId + ':' + fnv1aHash(raw);\n}\n\n/**\n * Resolve tags from the options — supports static array or function form.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction resolveTags<Fn extends (...args: any[]) => any>(\n opts: CacheOptions<Fn>,\n args: Parameters<Fn>\n): string[] {\n if (!opts.tags) return [];\n if (Array.isArray(opts.tags)) return opts.tags;\n return opts.tags(...args);\n}\n\n/** Per-process count of byte-identical fn sources seen by deriveFallbackFnId. */\nconst fallbackIdOccurrences = new Map<string, number>();\nlet hasWarnedFallbackId = false;\n\n/**\n * Derive a fallback fnId for callsites the timber-cache-transform Vite plugin\n * could not statically detect (aliased bindings, user re-export wrappers).\n *\n * The base is fnv1a(fn.toString()) — deterministic across instances and cold\n * starts of the same build, so two *different* functions can never swap fnIds\n * by module-evaluation order (the TIM-997 poisoning scenario that the old\n * process-local counter allowed; see TIM-1054). A per-process occurrence\n * suffix disambiguates byte-identical duplicate sources. That residue is the\n * one remaining hazard: closures produced by the same factory share source\n * text but capture different values, and their occurrence order may differ\n * across instances — such callsites need an explicit `key` option.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction deriveFallbackFnId(fn: (...args: any[]) => any): string {\n const sourceHash = fnv1aHash(fn.toString());\n const occurrence = fallbackIdOccurrences.get(sourceHash) ?? 0;\n fallbackIdOccurrences.set(sourceHash, occurrence + 1);\n\n if (!hasWarnedFallbackId && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') {\n hasWarnedFallbackId = true;\n console.warn(\n '[timber] cache() callsite has no build-time stable ID and no explicit `key` option — ' +\n 'the call is not statically visible to the timber Vite plugin (e.g. an aliased ' +\n 'binding like `const c = cache` or a re-export wrapper module). Falling back to a ' +\n 'function-source hash for the cache key. With a shared CacheHandler (Redis/KV) this ' +\n 'is safe for distinct functions, but byte-identical closures from a factory can swap ' +\n 'cache entries across instances. Provide an explicit `key` option to make the cache ' +\n 'key contract stable and auditable.'\n );\n }\n\n return `timber-cache:fn:${sourceHash}:${occurrence}`;\n}\n\n/**\n * Creates a cached wrapper around an async function.\n *\n * - FNV-1a default keys with normalized JSON args\n * - Singleflight: concurrent misses → single execution AND single\n * handler.set — the write happens inside the coalesced callback (TIM-1028)\n * - Writes are skipped when the key/tags were invalidated while fn was in\n * flight, so explicit invalidation is never silently undone (TIM-1028)\n * - SWR: serve stale immediately, background refetch\n * - Tags as string[] or function of args\n * - The handler is resolved per invocation via `getHandler`, never captured\n * at wrapper-creation time — module-scope cache() wrappers created before\n * the framework's boot wiring calls setCacheHandler() still use the\n * configured handler once it arrives (TIM-1029)\n * - No ALS dependency for correctness — cache() works outside a request\n * context. The waitUntil ALS bridge is read optionally so the SWR\n * background refetch survives handler return on Workers/Lambda.\n *\n * Cache hits/misses are recorded as OTEL span events on the enclosing\n * span (not child spans). The DevSpanProcessor reads these for dev log output.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function createCache<Fn extends (...args: any[]) => Promise<any>>(\n fn: Fn,\n opts: CacheOptions<Fn>,\n getHandler: () => CacheHandler,\n stableId?: string\n): Fn {\n // With an explicit `key` option the fnId never reaches key generation, so\n // no fallback derivation (or warning) is needed in that case.\n const fnId = stableId ?? (opts.key ? undefined : deriveFallbackFnId(fn));\n\n // Per-call timeoutMs gets a dedicated singleflight instance. Otherwise\n // resolve defaultSingleflight per invocation (not captured at creation\n // time) so wrappers created before boot pick up the timeout once\n // setDefaultSingleflightTimeout is called (TIM-1032, same rationale as\n // getCacheHandler for TIM-1029).\n const perCallSf =\n opts.timeoutMs !== undefined ? createSingleflight({ timeoutMs: opts.timeoutMs }) : undefined;\n\n // Cast to Fn to preserve the original function's generic call signature.\n // Without this, generic type parameters (e.g. <T> in apiFetch<T>) are\n // erased and callers lose type safety on the return type.\n return (async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {\n const sf = perCallSf ?? defaultSingleflight;\n const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId as string, args);\n\n const cacheStart = performance.now();\n const cached = await getHandler().get(key);\n\n if (cached && !cached.stale) {\n // Record as OTEL span event on enclosing span (not a child span).\n // Fire-and-forget — no microtask overhead on the cache hot path.\n addSpanEventSync('timber.cache.hit', {\n key,\n duration_ms: Math.round(performance.now() - cacheStart),\n });\n return cached.value as Awaited<ReturnType<Fn>>;\n }\n\n // From here fn() will execute (SWR refetch or miss). Scope the\n // singleflight key by the last invalidation epoch affecting this\n // key/tags: a caller arriving after an invalidation must not coalesce\n // onto a flight that started before it — it would receive the\n // pre-invalidation result the invalidation was issued to discard.\n // NUL separator: cannot collide with user-provided key strings.\n const tags = resolveTags(opts, args);\n const flightKey = `${key}\\u0000${lastInvalidationEpochFor(key, tags)}`;\n\n /**\n * Run fn and store the result — exactly once per coalesced execution,\n * inside the singleflight callback. The write is skipped when the key or\n * any resolved tag was invalidated after fn started: storing would\n * resurrect just-invalidated data as fresh for the full TTL.\n */\n const executeAndStore = async (): Promise<Awaited<ReturnType<Fn>>> => {\n const startEpoch = currentInvalidationEpoch();\n const result = await fn(...args);\n if (!wasInvalidatedSince(startEpoch, key, tags)) {\n await getHandler().set(key, result, { ttl: opts.ttl, tags });\n }\n return result;\n };\n\n if (cached && cached.stale && opts.staleWhileRevalidate) {\n // Record stale cache hit as OTEL span event (fire-and-forget).\n addSpanEventSync('timber.cache.hit', {\n key,\n duration_ms: Math.round(performance.now() - cacheStart),\n stale: true,\n });\n // Serve stale immediately, trigger background refetch\n const refetch = sf\n .do(`swr:${flightKey}`, async () => {\n try {\n await executeAndStore();\n } catch (err) {\n // Failed refetch — stale entry continues to be served.\n logSwrRefetchFailed({ cacheKey: key, error: err });\n }\n })\n .catch(() => {\n // Singleflight promise rejection handled — stale continues.\n });\n // Platforms that cancel work when the handler returns (Cloudflare\n // Workers, Lambda) would otherwise kill the refetch mid-flight —\n // stale data re-served forever. Register the (never-rejecting)\n // refetch with the request's waitUntil; undefined outside a request\n // context or on platforms without lifecycle extension (Node servers\n // keep floating promises alive, so behavior there is unchanged).\n getWaitUntil()?.(refetch);\n return cached.value as Awaited<ReturnType<Fn>>;\n }\n\n // Cache miss (or stale without SWR) — execute with singleflight.\n // The handler.set lives inside the coalesced callback, so N concurrent\n // misses produce exactly one write (TIM-1028).\n const result = await sf.do(flightKey, executeAndStore);\n\n // Record cache miss as OTEL span event (fire-and-forget).\n addSpanEventSync('timber.cache.miss', {\n key,\n duration_ms: Math.round(performance.now() - cacheStart),\n });\n\n return result;\n }) as unknown as Fn;\n}\n","/**\n * Module-level cache handler singleton.\n *\n * Lazily initialized to MemoryCacheHandler on first access. The framework\n * replaces this at boot from timber.config.ts via setCacheHandler().\n *\n * This module avoids importing from ./index to prevent circular dependencies.\n */\n\n// Inline the interface to avoid circular import with index.ts\ninterface CacheHandlerLike {\n get(key: string): Promise<{ value: unknown; stale: boolean } | null>;\n set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void>;\n invalidate(opts: { key?: string; tag?: string }): Promise<void>;\n}\n\nlet handler: CacheHandlerLike | null = null;\n\n/** Replace the active cache handler. Called by the framework at boot. */\nexport function setCacheHandler(h: CacheHandlerLike): void {\n handler = h;\n}\n\n/**\n * Get the active cache handler. Creates a default MemoryCacheHandler on\n * first access if none has been set via setCacheHandler().\n */\nexport function getCacheHandler(): CacheHandlerLike {\n if (!handler) {\n // Inline a minimal LRU cache to avoid circular dep with index.ts.\n // In production, the framework always calls setCacheHandler() at boot.\n handler = createDefaultHandler();\n }\n return handler;\n}\n\nfunction createDefaultHandler(): CacheHandlerLike {\n const store = new Map<string, { value: unknown; expiresAt: number; tags: string[] }>();\n const maxEntries = 1000;\n\n return {\n async get(key) {\n const entry = store.get(key);\n if (!entry) return null;\n store.delete(key);\n store.set(key, entry);\n const stale = Date.now() > entry.expiresAt;\n return { value: entry.value, stale };\n },\n async set(key, value, opts) {\n if (store.has(key)) store.delete(key);\n while (store.size >= maxEntries) {\n const oldest = store.keys().next().value;\n if (oldest !== undefined) store.delete(oldest);\n else break;\n }\n store.set(key, { value, expiresAt: Date.now() + opts.ttl * 1000, tags: opts.tags });\n },\n async invalidate(opts) {\n if (opts.key) store.delete(opts.key);\n if (opts.tag) {\n for (const [key, entry] of store) {\n if (entry.tags.includes(opts.tag)) store.delete(key);\n }\n }\n },\n };\n}\n","import type { CacheOptions } from './index';\nimport { createCache } from './timber-cache';\nimport { getCacheHandler } from './handler-store';\nimport { recordInvalidation } from './invalidation-epoch';\n\n/**\n * Public caching API: `cache(fn, opts)`.\n *\n * Wraps an async function with cross-request caching. Uses the configured\n * cache handler (defaults to MemoryCacheHandler, overridable via timber.config.ts).\n *\n * ```ts\n * import { cache } from '@timber-js/app/cache';\n *\n * const getUser = cache(\n * async (id: string) => db.users.findUnique({ where: { id } }),\n * { ttl: 60, tags: (id) => [`user:${id}`] }\n * );\n * ```\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function cache<Fn extends (...args: any[]) => Promise<any>>(\n fn: Fn,\n opts: CacheOptions<Fn>,\n stableId?: string\n): Fn {\n // Pass the getter, not a resolved handler: the wrapper re-resolves it on\n // every call, so module-scope wrappers created before the framework's boot\n // wiring calls setCacheHandler() still pick up the configured handler\n // instead of permanently binding the in-memory fallback (TIM-1029).\n return createCache(fn, opts, getCacheHandler, stableId);\n}\n\n/**\n * Invalidate cache entries by tag or key.\n *\n * ```ts\n * cache.invalidate({ tag: 'products' });\n * cache.invalidate({ key: 'user:abc' });\n * ```\n */\ncache.invalidate = async function invalidate(opts: { key?: string; tag?: string }): Promise<void> {\n // Record before the handler delete so a cached fn in flight skips its\n // post-resolve set instead of resurrecting the invalidated value (TIM-1028).\n recordInvalidation(opts);\n await getCacheHandler().invalidate(opts);\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"cache-api-Cbyh3RW4.js","names":[],"sources":["../../src/cache/stable-stringify.ts","../../src/cache/singleflight.ts","../../src/cache/invalidation-epoch.ts","../../src/cache/timber-cache.ts","../../src/cache/handler-store.ts","../../src/cache/cache-api.ts"],"sourcesContent":["/**\n * Deterministic JSON serialization with sorted object keys.\n * Used for cache key generation — ensures { a: 1, b: 2 } and { b: 2, a: 1 }\n * produce the same string.\n *\n * Matches JSON.stringify semantics for toJSON: Date serializes to its ISO\n * string, URL to its href, and class instances with a custom toJSON use it.\n * Map and Set serialize their entries explicitly (insertion-order\n * independent). Values that would serialize lossily — functions, symbols,\n * and non-plain objects with no enumerable own state (RegExp, class\n * instances holding data behind getters or internal slots) — throw instead:\n * every such value would collapse to the same cache key, silently returning\n * one argument's cached data for another (TIM-1020).\n */\nexport function stableStringify(value: unknown): string {\n if (value === null || value === undefined) return String(value);\n\n const type = typeof value;\n if (type === 'function' || type === 'symbol') {\n throw new TypeError(\n `stableStringify: cannot serialize a ${type} into a stable cache key — ` +\n 'all such values would collide. Provide an explicit `key` option to cache().'\n );\n }\n if (type !== 'object') return JSON.stringify(value);\n\n if (Array.isArray(value)) {\n return '[' + value.map((item) => stableStringify(item)).join(',') + ']';\n }\n\n // Honor toJSON like JSON.stringify does (Date → ISO string, URL → href,\n // custom class serialization). JSON.stringify does not re-invoke toJSON\n // on the value it returns, so a toJSON that returns `this` falls through\n // to plain own-property serialization instead of recursing forever.\n const withToJSON = value as { toJSON?: () => unknown };\n if (typeof withToJSON.toJSON === 'function') {\n const json = withToJSON.toJSON();\n if (json !== value) return stableStringify(json);\n }\n\n // Map/Set have no enumerable own properties — serialize entries explicitly.\n // Entries are sorted after serialization so insertion order doesn't change\n // the key. The Map(/Set( prefixes can't collide with object ('{'), array\n // ('['), or string ('\"') output.\n if (value instanceof Map) {\n const entries = [...value.entries()].map(\n ([k, v]) => stableStringify(k) + '=>' + stableStringify(v)\n );\n return 'Map(' + entries.sort().join(',') + ')';\n }\n if (value instanceof Set) {\n const members = [...value].map((member) => stableStringify(member));\n return 'Set(' + members.sort().join(',') + ')';\n }\n\n const obj = value as Record<string, unknown>;\n const keys = Object.keys(obj).sort();\n const pairs: string[] = [];\n for (const key of keys) {\n if (obj[key] === undefined) continue;\n // A callable own toJSON only reaches this loop via the returns-`this`\n // fall-through above. It's the serialization protocol, not data —\n // JSON.stringify omits it too.\n if (key === 'toJSON' && typeof obj[key] === 'function') continue;\n pairs.push(JSON.stringify(key) + ':' + stableStringify(obj[key]));\n }\n\n // A non-plain object that serializes to '{}' carries its state behind\n // getters or internal slots (RegExp, sessions, ORM entities). Serializing\n // it would give every instance the same cache key.\n if (pairs.length === 0) {\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) {\n const name = (value as object).constructor?.name || 'Object';\n throw new TypeError(\n `stableStringify: ${name} instance has no serializable own state — ` +\n 'all instances would share one cache key. Provide an explicit `key` ' +\n 'option to cache(), or add a toJSON() method.'\n );\n }\n }\n\n return '{' + pairs.join(',') + '}';\n}\n","/**\n * Singleflight coalesces concurrent calls with the same key into a single\n * execution. All callers receive the same result (or error).\n *\n * Per-process, in-memory. Each process coalesces independently.\n *\n * An optional `timeoutMs` prevents hung `fn()` calls from permanently\n * blocking all future callers for that key. When set, `fn()` is raced\n * against a timeout — if the timeout fires first, the promise rejects\n * with `SingleflightTimeoutError`, `finally` cleans up the key, and\n * subsequent callers can retry. See TIM-518.\n */\n\nexport interface SingleflightOptions {\n /** Maximum time (ms) a coalesced call may run before being rejected. */\n timeoutMs?: number;\n}\n\nexport interface Singleflight {\n do<T>(key: string, fn: () => Promise<T>): Promise<T>;\n}\n\n/**\n * Error thrown when a singleflight call exceeds `timeoutMs`.\n * Exported so callers can distinguish timeout from other errors.\n */\nexport class SingleflightTimeoutError extends Error {\n constructor(key: string, timeoutMs: number) {\n super(`Singleflight timeout: key \"${key}\" exceeded ${timeoutMs}ms`);\n this.name = 'SingleflightTimeoutError';\n }\n}\n\nexport function createSingleflight(opts?: SingleflightOptions): Singleflight {\n const inflight = new Map<string, Promise<unknown>>();\n const timeoutMs = opts?.timeoutMs;\n\n return {\n do<T>(key: string, fn: () => Promise<T>): Promise<T> {\n const existing = inflight.get(key);\n if (existing) return existing as Promise<T>;\n\n let promise: Promise<T>;\n\n if (timeoutMs != null && timeoutMs > 0) {\n // Race fn() against a timeout to prevent hung calls from\n // permanently blocking the key. See TIM-518.\n promise = new Promise<T>((resolve, reject) => {\n const timer = setTimeout(\n () => reject(new SingleflightTimeoutError(key, timeoutMs)),\n timeoutMs\n );\n // Wrap in try/catch so a synchronous throw from fn()\n // (e.g. argument validation) still clears the timer.\n // Without this, the timer leaks until expiry.\n try {\n fn().then(\n (value) => {\n clearTimeout(timer);\n resolve(value);\n },\n (err) => {\n clearTimeout(timer);\n reject(err);\n }\n );\n } catch (err) {\n clearTimeout(timer);\n reject(err);\n }\n });\n } else {\n promise = fn();\n }\n\n const tracked = promise.finally(() => {\n inflight.delete(key);\n });\n\n inflight.set(key, tracked);\n return tracked as Promise<T>;\n },\n };\n}\n","/**\n * Per-process invalidation epoch tracking for timber.cache (TIM-1028).\n *\n * handler.set for a cache miss runs after `fn()` resolves. An explicit\n * `cache.invalidate({ tag })` / `revalidateTag` that lands while fn is in\n * flight would otherwise be silently undone — the just-invalidated value\n * re-stored as fresh for the full TTL, resurrecting pre-mutation data.\n *\n * Every invalidation bumps a monotonic epoch and records it against the\n * invalidated key/tag. Before storing a result, the cache wrapper checks\n * whether its key or any of its tags were invalidated after fn started and\n * skips the write if so. Skipping is always safe: the only cost is one\n * extra cache miss on the next call.\n *\n * Scope: per-process, like the singleflight map. An invalidation issued by\n * another instance against a shared handler (Redis/KV) during fn flight is\n * not observable here — closing that window fully would require store-level\n * versioning, which no CacheHandler backend offers.\n */\n\nlet epoch = 0;\n\n/**\n * Last-invalidated epoch per `key:<k>` / `tag:<t>` entry. Insertion order is\n * epoch order (entries are re-inserted on update), so the oldest entry is\n * always first — pruning evicts lowest epochs.\n */\nconst lastInvalidated = new Map<string, number>();\n\n/**\n * Bound on tracked entries so high-cardinality tag invalidation (e.g.\n * per-user tags) cannot grow this map without limit.\n */\nconst MAX_TRACKED_ENTRIES = 10_000;\n\n/**\n * Highest epoch evicted from the map. Any execution that started at or\n * before this epoch conservatively counts as invalidated — history was\n * truncated, so we can no longer prove it wasn't.\n */\nlet evictedEpochFloor = 0;\n\n/** The current epoch. Capture before running fn(), pass to wasInvalidatedSince. */\nexport function currentInvalidationEpoch(): number {\n return epoch;\n}\n\n/**\n * Record an invalidation. Call BEFORE handler.invalidate() so an execution\n * whose set-check races the invalidation errs toward skipping the write.\n */\nexport function recordInvalidation(opts: { key?: string; tag?: string }): void {\n epoch++;\n if (opts.key) track(`key:${opts.key}`);\n if (opts.tag) track(`tag:${opts.tag}`);\n}\n\nfunction track(entry: string): void {\n lastInvalidated.delete(entry);\n lastInvalidated.set(entry, epoch);\n while (lastInvalidated.size > MAX_TRACKED_ENTRIES) {\n const oldest = lastInvalidated.entries().next().value;\n if (oldest === undefined) break;\n evictedEpochFloor = Math.max(evictedEpochFloor, oldest[1]);\n lastInvalidated.delete(oldest[0]);\n }\n}\n\n/**\n * Whether the key or any tag was invalidated after `startEpoch` (the epoch\n * captured when fn started executing).\n */\nexport function wasInvalidatedSince(startEpoch: number, key: string, tags: string[]): boolean {\n return lastInvalidationEpochFor(key, tags) > startEpoch;\n}\n\n/**\n * The highest epoch at which the key or any of the tags was invalidated\n * (0 if never). Used to scope the singleflight key: callers arriving after\n * an invalidation must not coalesce onto a flight that started before it —\n * they would receive the pre-invalidation result the invalidation was\n * issued to discard.\n */\nexport function lastInvalidationEpochFor(key: string, tags: string[]): number {\n let last = evictedEpochFloor;\n const keyEpoch = lastInvalidated.get(`key:${key}`);\n if (keyEpoch !== undefined && keyEpoch > last) last = keyEpoch;\n for (const tag of tags) {\n const tagEpoch = lastInvalidated.get(`tag:${tag}`);\n if (tagEpoch !== undefined && tagEpoch > last) last = tagEpoch;\n }\n return last;\n}\n","import type { CacheHandler, CacheOptions } from './index';\nimport { stableStringify } from './stable-stringify';\nimport { createSingleflight } from './singleflight';\nimport {\n currentInvalidationEpoch,\n lastInvalidationEpochFor,\n wasInvalidatedSince,\n} from './invalidation-epoch';\nimport { addSpanEventSync } from '../server/tracing.js';\nimport { logSwrRefetchFailed } from '../server/logger.js';\nimport { getWaitUntil } from '../server/waituntil-bridge.js';\nimport { fnv1aHash } from './fast-hash.js';\n\nlet defaultSingleflight = createSingleflight();\n\n/**\n * Set the timeout for the module-level default singleflight.\n * Called at framework boot with renderTimeoutMs from timber.config.ts so that\n * cache calls without a per-call timeoutMs still get deadlock protection.\n * See design/06-caching.md §\"Singleflight Timeout — Deadlock Risk\".\n */\nexport function setDefaultSingleflightTimeout(timeoutMs: number): void {\n defaultSingleflight = createSingleflight({ timeoutMs });\n}\n\n/**\n * Generate a cache key from function identity and serialized args.\n *\n * Uses FNV-1a (fast non-crypto hash) instead of SHA-256. Cache keys don't\n * need collision resistance — they need speed. The fnId prefix provides\n * namespace isolation; the hash covers the args.\n *\n * See TIM-370 for perf motivation.\n */\nfunction defaultKeyGenerator(fnId: string, args: unknown[]): string {\n const raw = fnId + ':' + stableStringify(args);\n return fnId + ':' + fnv1aHash(raw);\n}\n\n/**\n * Resolve tags from the options — supports static array or function form.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction resolveTags<Fn extends (...args: any[]) => any>(\n opts: CacheOptions<Fn>,\n args: Parameters<Fn>\n): string[] {\n if (!opts.tags) return [];\n if (Array.isArray(opts.tags)) return opts.tags;\n return opts.tags(...args);\n}\n\n/** Per-process count of byte-identical fn sources seen by deriveFallbackFnId. */\nconst fallbackIdOccurrences = new Map<string, number>();\nlet hasWarnedFallbackId = false;\n\n/**\n * Derive a fallback fnId for callsites the timber-cache-transform Vite plugin\n * could not statically detect (aliased bindings, user re-export wrappers).\n *\n * The base is fnv1a(fn.toString()) — deterministic across instances and cold\n * starts of the same build, so two *different* functions can never swap fnIds\n * by module-evaluation order (the TIM-997 poisoning scenario that the old\n * process-local counter allowed; see TIM-1054). A per-process occurrence\n * suffix disambiguates byte-identical duplicate sources. That residue is the\n * one remaining hazard: closures produced by the same factory share source\n * text but capture different values, and their occurrence order may differ\n * across instances — such callsites need an explicit `key` option.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction deriveFallbackFnId(fn: (...args: any[]) => any): string {\n const sourceHash = fnv1aHash(fn.toString());\n const occurrence = fallbackIdOccurrences.get(sourceHash) ?? 0;\n fallbackIdOccurrences.set(sourceHash, occurrence + 1);\n\n if (!hasWarnedFallbackId && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') {\n hasWarnedFallbackId = true;\n console.warn(\n '[timber] cache() callsite has no build-time stable ID and no explicit `key` option — ' +\n 'the call is not statically visible to the timber Vite plugin (e.g. an aliased ' +\n 'binding like `const c = cache` or a re-export wrapper module). Falling back to a ' +\n 'function-source hash for the cache key. With a shared CacheHandler (Redis/KV) this ' +\n 'is safe for distinct functions, but byte-identical closures from a factory can swap ' +\n 'cache entries across instances. Provide an explicit `key` option to make the cache ' +\n 'key contract stable and auditable.'\n );\n }\n\n return `timber-cache:fn:${sourceHash}:${occurrence}`;\n}\n\n/**\n * Creates a cached wrapper around an async function.\n *\n * - FNV-1a default keys with normalized JSON args\n * - Singleflight: concurrent misses → single execution AND single\n * handler.set — the write happens inside the coalesced callback (TIM-1028)\n * - Writes are skipped when the key/tags were invalidated while fn was in\n * flight, so explicit invalidation is never silently undone (TIM-1028)\n * - SWR: serve stale immediately, background refetch\n * - Tags as string[] or function of args\n * - The handler is resolved per invocation via `getHandler`, never captured\n * at wrapper-creation time — module-scope cache() wrappers created before\n * the framework's boot wiring calls setCacheHandler() still use the\n * configured handler once it arrives (TIM-1029)\n * - No ALS dependency for correctness — cache() works outside a request\n * context. The waitUntil ALS bridge is read optionally so the SWR\n * background refetch survives handler return on Workers/Lambda.\n *\n * Cache hits/misses are recorded as OTEL span events on the enclosing\n * span (not child spans). The DevSpanProcessor reads these for dev log output.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function createCache<Fn extends (...args: any[]) => Promise<any>>(\n fn: Fn,\n opts: CacheOptions<Fn>,\n getHandler: () => CacheHandler,\n stableId?: string\n): Fn {\n // With an explicit `key` option the fnId never reaches key generation, so\n // no fallback derivation (or warning) is needed in that case.\n const fnId = stableId ?? (opts.key ? undefined : deriveFallbackFnId(fn));\n\n // Per-call timeoutMs gets a dedicated singleflight instance. Otherwise\n // resolve defaultSingleflight per invocation (not captured at creation\n // time) so wrappers created before boot pick up the timeout once\n // setDefaultSingleflightTimeout is called (TIM-1032, same rationale as\n // getCacheHandler for TIM-1029).\n const perCallSf =\n opts.timeoutMs !== undefined ? createSingleflight({ timeoutMs: opts.timeoutMs }) : undefined;\n\n // Cast to Fn to preserve the original function's generic call signature.\n // Without this, generic type parameters (e.g. <T> in apiFetch<T>) are\n // erased and callers lose type safety on the return type.\n return (async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {\n const sf = perCallSf ?? defaultSingleflight;\n const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId as string, args);\n\n const cacheStart = performance.now();\n const cached = await getHandler().get(key);\n\n if (cached && !cached.stale) {\n // Record as OTEL span event on enclosing span (not a child span).\n // Fire-and-forget — no microtask overhead on the cache hot path.\n addSpanEventSync('timber.cache.hit', {\n key,\n duration_ms: Math.round(performance.now() - cacheStart),\n });\n return cached.value as Awaited<ReturnType<Fn>>;\n }\n\n // From here fn() will execute (SWR refetch or miss). Scope the\n // singleflight key by the last invalidation epoch affecting this\n // key/tags: a caller arriving after an invalidation must not coalesce\n // onto a flight that started before it — it would receive the\n // pre-invalidation result the invalidation was issued to discard.\n // NUL separator: cannot collide with user-provided key strings.\n const tags = resolveTags(opts, args);\n const flightKey = `${key}\\u0000${lastInvalidationEpochFor(key, tags)}`;\n\n /**\n * Run fn and store the result — exactly once per coalesced execution,\n * inside the singleflight callback. The write is skipped when the key or\n * any resolved tag was invalidated after fn started: storing would\n * resurrect just-invalidated data as fresh for the full TTL.\n */\n const executeAndStore = async (): Promise<Awaited<ReturnType<Fn>>> => {\n const startEpoch = currentInvalidationEpoch();\n const result = await fn(...args);\n if (!wasInvalidatedSince(startEpoch, key, tags)) {\n await getHandler().set(key, result, { ttl: opts.ttl, tags });\n }\n return result;\n };\n\n if (cached && cached.stale && opts.staleWhileRevalidate) {\n // Record stale cache hit as OTEL span event (fire-and-forget).\n addSpanEventSync('timber.cache.hit', {\n key,\n duration_ms: Math.round(performance.now() - cacheStart),\n stale: true,\n });\n // Serve stale immediately, trigger background refetch\n const refetch = sf\n .do(`swr:${flightKey}`, async () => {\n try {\n await executeAndStore();\n } catch (err) {\n // Failed refetch — stale entry continues to be served.\n logSwrRefetchFailed({ cacheKey: key, error: err });\n }\n })\n .catch(() => {\n // Singleflight promise rejection handled — stale continues.\n });\n // Platforms that cancel work when the handler returns (Cloudflare\n // Workers, Lambda) would otherwise kill the refetch mid-flight —\n // stale data re-served forever. Register the (never-rejecting)\n // refetch with the request's waitUntil; undefined outside a request\n // context or on platforms without lifecycle extension (Node servers\n // keep floating promises alive, so behavior there is unchanged).\n getWaitUntil()?.(refetch);\n return cached.value as Awaited<ReturnType<Fn>>;\n }\n\n // Cache miss (or stale without SWR) — execute with singleflight.\n // The handler.set lives inside the coalesced callback, so N concurrent\n // misses produce exactly one write (TIM-1028).\n const result = await sf.do(flightKey, executeAndStore);\n\n // Record cache miss as OTEL span event (fire-and-forget).\n addSpanEventSync('timber.cache.miss', {\n key,\n duration_ms: Math.round(performance.now() - cacheStart),\n });\n\n return result;\n }) as unknown as Fn;\n}\n","/**\n * Module-level cache handler singleton.\n *\n * Lazily initialized to MemoryCacheHandler on first access. The framework\n * replaces this at boot from timber.config.ts via setCacheHandler().\n *\n * This module avoids importing from ./index to prevent circular dependencies.\n */\n\n// Inline the interface to avoid circular import with index.ts\ninterface CacheHandlerLike {\n get(key: string): Promise<{ value: unknown; stale: boolean } | null>;\n set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void>;\n invalidate(opts: { key?: string; tag?: string }): Promise<void>;\n}\n\nlet handler: CacheHandlerLike | null = null;\n\n/** Replace the active cache handler. Called by the framework at boot. */\nexport function setCacheHandler(h: CacheHandlerLike): void {\n handler = h;\n}\n\n/**\n * Get the active cache handler. Creates a default MemoryCacheHandler on\n * first access if none has been set via setCacheHandler().\n */\nexport function getCacheHandler(): CacheHandlerLike {\n if (!handler) {\n // Inline a minimal LRU cache to avoid circular dep with index.ts.\n // In production, the framework always calls setCacheHandler() at boot.\n handler = createDefaultHandler();\n }\n return handler;\n}\n\nfunction createDefaultHandler(): CacheHandlerLike {\n const store = new Map<string, { value: unknown; expiresAt: number; tags: string[] }>();\n const maxEntries = 1000;\n\n return {\n async get(key) {\n const entry = store.get(key);\n if (!entry) return null;\n store.delete(key);\n store.set(key, entry);\n const stale = Date.now() > entry.expiresAt;\n return { value: entry.value, stale };\n },\n async set(key, value, opts) {\n if (store.has(key)) store.delete(key);\n while (store.size >= maxEntries) {\n const oldest = store.keys().next().value;\n if (oldest !== undefined) store.delete(oldest);\n else break;\n }\n store.set(key, { value, expiresAt: Date.now() + opts.ttl * 1000, tags: opts.tags });\n },\n async invalidate(opts) {\n if (opts.key) store.delete(opts.key);\n if (opts.tag) {\n for (const [key, entry] of store) {\n if (entry.tags.includes(opts.tag)) store.delete(key);\n }\n }\n },\n };\n}\n","import type { CacheOptions } from './index';\nimport { createCache } from './timber-cache';\nimport { getCacheHandler } from './handler-store';\nimport { recordInvalidation } from './invalidation-epoch';\n\n/**\n * Public caching API: `cache(fn, opts)`.\n *\n * Wraps an async function with cross-request caching. Uses the configured\n * cache handler (defaults to MemoryCacheHandler, overridable via timber.config.ts).\n *\n * ```ts\n * import { cache } from '@timber-js/app/cache';\n *\n * const getUser = cache(\n * async (id: string) => db.users.findUnique({ where: { id } }),\n * { ttl: 60, tags: (id) => [`user:${id}`] }\n * );\n * ```\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function cache<Fn extends (...args: any[]) => Promise<any>>(\n fn: Fn,\n opts: CacheOptions<Fn>,\n stableId?: string\n): Fn {\n // Pass the getter, not a resolved handler: the wrapper re-resolves it on\n // every call, so module-scope wrappers created before the framework's boot\n // wiring calls setCacheHandler() still pick up the configured handler\n // instead of permanently binding the in-memory fallback (TIM-1029).\n return createCache(fn, opts, getCacheHandler, stableId);\n}\n\n/**\n * Invalidate cache entries by tag or key.\n *\n * ```ts\n * cache.invalidate({ tag: 'products' });\n * cache.invalidate({ key: 'user:abc' });\n * ```\n */\ncache.invalidate = async function invalidate(opts: { key?: string; tag?: string }): Promise<void> {\n // Record before the handler delete so a cached fn in flight skips its\n // post-resolve set instead of resurrecting the invalidated value (TIM-1028).\n recordInvalidation(opts);\n await getCacheHandler().invalidate(opts);\n};\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"canonicalize-P41GR6tY.js","names":[],"sources":["../../src/server/metadata-routes.ts","../../src/server/canonicalize.ts"],"sourcesContent":["/**\n * Metadata route classification for timber.js.\n *\n * Metadata routes are file-based endpoints that generate well-known URLs for\n * crawlers and browsers (sitemap.xml, robots.txt, OG images, etc.).\n *\n * These routes run through proxy.ts but NOT through middleware.ts or access.ts —\n * they are public endpoints by nature.\n *\n * See design/16-metadata.md §\"Metadata Routes\"\n */\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** Classification of a metadata route file. */\nexport interface MetadataRouteInfo {\n /** The metadata route type. */\n type: MetadataRouteType;\n /** The content type to serve this route with. */\n contentType: string;\n /** Whether this route can appear in nested segments (not just app root). */\n nestable: boolean;\n}\n\nexport type MetadataRouteType =\n | 'sitemap'\n | 'robots'\n | 'manifest'\n | 'favicon'\n | 'icon'\n | 'opengraph-image'\n | 'apple-icon';\n\n// ─── Convention Table ────────────────────────────────────────────────────────\n\n/**\n * All recognized metadata route file conventions.\n *\n * Each entry maps a base file name (without extension) to its route info.\n * The extensions determine whether the file is static or dynamic.\n *\n * Static extensions: .xml, .txt, .json, .png, .jpg, .ico, .svg\n * Dynamic extensions: .ts, .tsx\n */\nexport const METADATA_ROUTE_CONVENTIONS: Record<\n string,\n {\n type: MetadataRouteType;\n contentType: string;\n nestable: boolean;\n staticExtensions: string[];\n dynamicExtensions: string[];\n /** The URL path this file serves at (relative to segment). */\n servePath: string;\n }\n> = {\n 'sitemap': {\n type: 'sitemap',\n contentType: 'application/xml',\n nestable: true,\n staticExtensions: ['xml'],\n dynamicExtensions: ['ts'],\n servePath: 'sitemap.xml',\n },\n 'robots': {\n type: 'robots',\n contentType: 'text/plain',\n nestable: false,\n staticExtensions: ['txt'],\n dynamicExtensions: ['ts'],\n servePath: 'robots.txt',\n },\n 'manifest': {\n type: 'manifest',\n contentType: 'application/manifest+json',\n nestable: false,\n staticExtensions: ['json'],\n dynamicExtensions: ['ts'],\n servePath: 'manifest.webmanifest',\n },\n 'favicon': {\n type: 'favicon',\n contentType: 'image/x-icon',\n nestable: false,\n staticExtensions: ['ico'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'favicon.ico',\n },\n 'icon': {\n type: 'icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg', 'svg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'icon',\n },\n 'opengraph-image': {\n type: 'opengraph-image',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'opengraph-image',\n },\n\n 'apple-icon': {\n type: 'apple-icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'apple-icon',\n },\n};\n\n// ─── MIME Type Resolution ─────────────────────────────────────────────────────\n\n/**\n * Map of file extensions to MIME types for static metadata route files.\n * Used to resolve the generic `image/*` content type for static image files.\n */\nconst EXTENSION_MIME_TYPES: Record<string, string> = {\n xml: 'application/xml',\n txt: 'text/plain',\n json: 'application/json',\n ico: 'image/x-icon',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n svg: 'image/svg+xml',\n webp: 'image/webp',\n};\n\n/**\n * Resolve the concrete MIME type for a static metadata route file.\n *\n * For generic content types like `image/*`, this resolves to the actual\n * MIME type based on the file extension (e.g. `image/png` for `.png`).\n *\n * @param conventionContentType - The content type from the convention table (may be generic like `image/*`)\n * @param extension - The file extension without leading dot (e.g. \"png\", \"xml\")\n * @returns The resolved MIME type\n */\nexport function resolveStaticContentType(conventionContentType: string, extension: string): string {\n if (conventionContentType.includes('*')) {\n return EXTENSION_MIME_TYPES[extension] ?? 'application/octet-stream';\n }\n return conventionContentType;\n}\n\n/**\n * Check if a file extension represents a static (non-code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"xml\", \"png\", \"ts\")\n * @returns true if this is a static file, false if dynamic or unrecognized\n */\nexport function isStaticMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.staticExtensions.includes(extension);\n}\n\n/**\n * Check if a file extension represents a dynamic (code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"ts\", \"tsx\")\n * @returns true if this is a dynamic file, false if static or unrecognized\n */\nexport function isDynamicMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.dynamicExtensions.includes(extension);\n}\n\n// ─── Classification ──────────────────────────────────────────────────────────\n\n/**\n * Classify a file name as a metadata route, or return null if it's not one.\n *\n * @param fileName - The full file name including extension (e.g. \"sitemap.xml\", \"icon.tsx\")\n * @returns Classification info, or null if not a metadata route\n */\nexport function classifyMetadataRoute(fileName: string): MetadataRouteInfo | null {\n const dotIndex = fileName.lastIndexOf('.');\n if (dotIndex === -1) return null;\n\n const baseName = fileName.slice(0, dotIndex);\n const ext = fileName.slice(dotIndex + 1);\n\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return null;\n\n const isStatic = convention.staticExtensions.includes(ext);\n const isDynamic = convention.dynamicExtensions.includes(ext);\n\n if (!isStatic && !isDynamic) return null;\n\n return {\n type: convention.type,\n contentType: convention.contentType,\n nestable: convention.nestable,\n };\n}\n\n/**\n * Get the serve path for a metadata route type.\n *\n * @param type - The metadata route type\n * @returns The URL path fragment this route serves at\n */\nexport function getMetadataRouteServePath(type: MetadataRouteType): string {\n for (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {\n if (convention.type === type) return convention.servePath;\n }\n throw new Error(`[timber] Unknown metadata route type: ${type}`);\n}\n\nconst METADATA_SERVE_PATHS = new Set(\n Object.values(METADATA_ROUTE_CONVENTIONS).map((c) => c.servePath)\n);\n\nexport function isMetadataRouteServePath(pathname: string): boolean {\n const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);\n return METADATA_SERVE_PATHS.has(lastSegment);\n}\n\n/** A <link> auto-link tag. */\nexport interface AutoLinkLink {\n tag: 'link';\n rel: string;\n href: string;\n type?: string;\n}\n\n/** A <meta> auto-link tag. */\nexport interface AutoLinkMeta {\n tag: 'meta';\n property?: string;\n name?: string;\n content: string;\n}\n\nexport type AutoLinkTag = AutoLinkLink | AutoLinkMeta;\n\n/**\n * Get the auto-link tags to inject into <head> for metadata route files\n * discovered in a segment.\n *\n * Returns link tags for icon/apple-icon/manifest, and meta tags for\n * opengraph-image (emits both og:image and twitter:image). Returns null\n * for types that don't auto-link (favicon, sitemap, robots).\n *\n * @param type - The metadata route type\n * @param href - The resolved URL path to the metadata route\n * @returns Tag descriptor(s) for the <head>, or null if no auto-link\n */\nexport function getMetadataRouteAutoLink(type: MetadataRouteType, href: string): AutoLinkTag[] {\n switch (type) {\n case 'icon':\n return [{ tag: 'link', rel: 'icon', href }];\n case 'apple-icon':\n return [{ tag: 'link', rel: 'apple-touch-icon', href }];\n case 'manifest':\n return [{ tag: 'link', rel: 'manifest', href }];\n case 'opengraph-image':\n return [\n { tag: 'meta', property: 'og:image', content: href },\n { tag: 'meta', name: 'twitter:image', content: href },\n ];\n default:\n return [];\n }\n}\n","/**\n * URL canonicalization — runs once at the request boundary.\n *\n * Every layer (proxy.ts, middleware.ts, access.ts, components) sees the same\n * canonical path. No re-decoding occurs at any later stage.\n *\n * See design/07-routing.md §\"URL Canonicalization & Security\"\n */\n\n/** Result of canonicalization — either a clean path or a rejection. */\nexport type CanonicalizeResult = { ok: true; pathname: string } | { ok: false; status: 400 };\n\n/**\n * Encoded separators that produce a 400 rejection.\n * %2f (/) and %5c (\\) cause path-confusion attacks.\n *\n * Shared between the runtime canonicalizer and the build-time route scanner\n * to ensure both enforce identical security rules. See design/13-security.md.\n */\nexport const ENCODED_SEPARATOR_RE = /%2f|%5c/i;\n\n/** Null byte — rejected. Shared with the route scanner. */\nexport const NULL_BYTE_RE = /%00/i;\n\n/**\n * Canonicalize a URL pathname.\n *\n * 1. Reject encoded separators (%2f, %5c) and null bytes (%00)\n * 2. Single percent-decode\n * 3. Collapse // → /\n * 4. Resolve .. segments (reject if escaping root)\n * 5. Strip trailing slash (except root \"/\")\n *\n * @param rawPathname - The raw pathname from the request URL (percent-encoded)\n * @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.\n */\nexport function canonicalize(rawPathname: string, stripTrailingSlash = true): CanonicalizeResult {\n // Step 1: Reject dangerous encoded sequences BEFORE decoding.\n // This must happen on the raw input so %252f doesn't bypass after a single decode.\n if (ENCODED_SEPARATOR_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n if (NULL_BYTE_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n\n // Step 2: Single percent-decode.\n // Double-encoded input (%2561 → %61) stays as %61 — not decoded again.\n let decoded: string;\n try {\n decoded = decodeURIComponent(rawPathname);\n } catch {\n // Malformed percent-encoding → 400\n return { ok: false, status: 400 };\n }\n\n // Reject null bytes that appeared after decoding (from valid %00-like sequences\n // that weren't caught above — belt and suspenders).\n if (decoded.includes('\\0')) {\n return { ok: false, status: 400 };\n }\n\n // Backslash is NOT a path separator — keep as literal character.\n // But reject if it would create // after normalization (e.g., /\\evil.com).\n // We do NOT convert \\ to / — it stays as a literal.\n\n // Step 3: Collapse consecutive slashes.\n let pathname = decoded.replace(/\\/\\/+/g, '/');\n\n // Step 4: Resolve .. and . segments.\n const segments = pathname.split('/');\n const resolved: string[] = [];\n for (const seg of segments) {\n if (seg === '..') {\n if (resolved.length <= 1) {\n // Trying to escape root — 400\n return { ok: false, status: 400 };\n }\n resolved.pop();\n } else if (seg !== '.') {\n resolved.push(seg);\n }\n }\n\n pathname = resolved.join('/') || '/';\n\n // Step 5: Strip trailing slash (except root \"/\").\n if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {\n pathname = pathname.slice(0, -1);\n }\n\n return { ok: true, pathname };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"canonicalize-P41GR6tY.js","names":[],"sources":["../../src/server/metadata-routes.ts","../../src/server/canonicalize.ts"],"sourcesContent":["/**\n * Metadata route classification for timber.js.\n *\n * Metadata routes are file-based endpoints that generate well-known URLs for\n * crawlers and browsers (sitemap.xml, robots.txt, OG images, etc.).\n *\n * These routes run through proxy.ts but NOT through middleware.ts or access.ts —\n * they are public endpoints by nature.\n *\n * See design/16-metadata.md §\"Metadata Routes\"\n */\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** Classification of a metadata route file. */\nexport interface MetadataRouteInfo {\n /** The metadata route type. */\n type: MetadataRouteType;\n /** The content type to serve this route with. */\n contentType: string;\n /** Whether this route can appear in nested segments (not just app root). */\n nestable: boolean;\n}\n\nexport type MetadataRouteType =\n | 'sitemap'\n | 'robots'\n | 'manifest'\n | 'favicon'\n | 'icon'\n | 'opengraph-image'\n | 'apple-icon';\n\n// ─── Convention Table ────────────────────────────────────────────────────────\n\n/**\n * All recognized metadata route file conventions.\n *\n * Each entry maps a base file name (without extension) to its route info.\n * The extensions determine whether the file is static or dynamic.\n *\n * Static extensions: .xml, .txt, .json, .png, .jpg, .ico, .svg\n * Dynamic extensions: .ts, .tsx\n */\nexport const METADATA_ROUTE_CONVENTIONS: Record<\n string,\n {\n type: MetadataRouteType;\n contentType: string;\n nestable: boolean;\n staticExtensions: string[];\n dynamicExtensions: string[];\n /** The URL path this file serves at (relative to segment). */\n servePath: string;\n }\n> = {\n 'sitemap': {\n type: 'sitemap',\n contentType: 'application/xml',\n nestable: true,\n staticExtensions: ['xml'],\n dynamicExtensions: ['ts'],\n servePath: 'sitemap.xml',\n },\n 'robots': {\n type: 'robots',\n contentType: 'text/plain',\n nestable: false,\n staticExtensions: ['txt'],\n dynamicExtensions: ['ts'],\n servePath: 'robots.txt',\n },\n 'manifest': {\n type: 'manifest',\n contentType: 'application/manifest+json',\n nestable: false,\n staticExtensions: ['json'],\n dynamicExtensions: ['ts'],\n servePath: 'manifest.webmanifest',\n },\n 'favicon': {\n type: 'favicon',\n contentType: 'image/x-icon',\n nestable: false,\n staticExtensions: ['ico'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'favicon.ico',\n },\n 'icon': {\n type: 'icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg', 'svg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'icon',\n },\n 'opengraph-image': {\n type: 'opengraph-image',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'opengraph-image',\n },\n\n 'apple-icon': {\n type: 'apple-icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'apple-icon',\n },\n};\n\n// ─── MIME Type Resolution ─────────────────────────────────────────────────────\n\n/**\n * Map of file extensions to MIME types for static metadata route files.\n * Used to resolve the generic `image/*` content type for static image files.\n */\nconst EXTENSION_MIME_TYPES: Record<string, string> = {\n xml: 'application/xml',\n txt: 'text/plain',\n json: 'application/json',\n ico: 'image/x-icon',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n svg: 'image/svg+xml',\n webp: 'image/webp',\n};\n\n/**\n * Resolve the concrete MIME type for a static metadata route file.\n *\n * For generic content types like `image/*`, this resolves to the actual\n * MIME type based on the file extension (e.g. `image/png` for `.png`).\n *\n * @param conventionContentType - The content type from the convention table (may be generic like `image/*`)\n * @param extension - The file extension without leading dot (e.g. \"png\", \"xml\")\n * @returns The resolved MIME type\n */\nexport function resolveStaticContentType(conventionContentType: string, extension: string): string {\n if (conventionContentType.includes('*')) {\n return EXTENSION_MIME_TYPES[extension] ?? 'application/octet-stream';\n }\n return conventionContentType;\n}\n\n/**\n * Check if a file extension represents a static (non-code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"xml\", \"png\", \"ts\")\n * @returns true if this is a static file, false if dynamic or unrecognized\n */\nexport function isStaticMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.staticExtensions.includes(extension);\n}\n\n/**\n * Check if a file extension represents a dynamic (code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"ts\", \"tsx\")\n * @returns true if this is a dynamic file, false if static or unrecognized\n */\nexport function isDynamicMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.dynamicExtensions.includes(extension);\n}\n\n// ─── Classification ──────────────────────────────────────────────────────────\n\n/**\n * Classify a file name as a metadata route, or return null if it's not one.\n *\n * @param fileName - The full file name including extension (e.g. \"sitemap.xml\", \"icon.tsx\")\n * @returns Classification info, or null if not a metadata route\n */\nexport function classifyMetadataRoute(fileName: string): MetadataRouteInfo | null {\n const dotIndex = fileName.lastIndexOf('.');\n if (dotIndex === -1) return null;\n\n const baseName = fileName.slice(0, dotIndex);\n const ext = fileName.slice(dotIndex + 1);\n\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return null;\n\n const isStatic = convention.staticExtensions.includes(ext);\n const isDynamic = convention.dynamicExtensions.includes(ext);\n\n if (!isStatic && !isDynamic) return null;\n\n return {\n type: convention.type,\n contentType: convention.contentType,\n nestable: convention.nestable,\n };\n}\n\n/**\n * Get the serve path for a metadata route type.\n *\n * @param type - The metadata route type\n * @returns The URL path fragment this route serves at\n */\nexport function getMetadataRouteServePath(type: MetadataRouteType): string {\n for (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {\n if (convention.type === type) return convention.servePath;\n }\n throw new Error(`[timber] Unknown metadata route type: ${type}`);\n}\n\nconst METADATA_SERVE_PATHS = new Set(\n Object.values(METADATA_ROUTE_CONVENTIONS).map((c) => c.servePath)\n);\n\nexport function isMetadataRouteServePath(pathname: string): boolean {\n const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);\n return METADATA_SERVE_PATHS.has(lastSegment);\n}\n\n/** A <link> auto-link tag. */\nexport interface AutoLinkLink {\n tag: 'link';\n rel: string;\n href: string;\n type?: string;\n}\n\n/** A <meta> auto-link tag. */\nexport interface AutoLinkMeta {\n tag: 'meta';\n property?: string;\n name?: string;\n content: string;\n}\n\nexport type AutoLinkTag = AutoLinkLink | AutoLinkMeta;\n\n/**\n * Get the auto-link tags to inject into <head> for metadata route files\n * discovered in a segment.\n *\n * Returns link tags for icon/apple-icon/manifest, and meta tags for\n * opengraph-image (emits both og:image and twitter:image). Returns null\n * for types that don't auto-link (favicon, sitemap, robots).\n *\n * @param type - The metadata route type\n * @param href - The resolved URL path to the metadata route\n * @returns Tag descriptor(s) for the <head>, or null if no auto-link\n */\nexport function getMetadataRouteAutoLink(type: MetadataRouteType, href: string): AutoLinkTag[] {\n switch (type) {\n case 'icon':\n return [{ tag: 'link', rel: 'icon', href }];\n case 'apple-icon':\n return [{ tag: 'link', rel: 'apple-touch-icon', href }];\n case 'manifest':\n return [{ tag: 'link', rel: 'manifest', href }];\n case 'opengraph-image':\n return [\n { tag: 'meta', property: 'og:image', content: href },\n { tag: 'meta', name: 'twitter:image', content: href },\n ];\n default:\n return [];\n }\n}\n","/**\n * URL canonicalization — runs once at the request boundary.\n *\n * Every layer (proxy.ts, middleware.ts, access.ts, components) sees the same\n * canonical path. No re-decoding occurs at any later stage.\n *\n * See design/07-routing.md §\"URL Canonicalization & Security\"\n */\n\n/** Result of canonicalization — either a clean path or a rejection. */\nexport type CanonicalizeResult = { ok: true; pathname: string } | { ok: false; status: 400 };\n\n/**\n * Encoded separators that produce a 400 rejection.\n * %2f (/) and %5c (\\) cause path-confusion attacks.\n *\n * Shared between the runtime canonicalizer and the build-time route scanner\n * to ensure both enforce identical security rules. See design/13-security.md.\n */\nexport const ENCODED_SEPARATOR_RE = /%2f|%5c/i;\n\n/** Null byte — rejected. Shared with the route scanner. */\nexport const NULL_BYTE_RE = /%00/i;\n\n/**\n * Canonicalize a URL pathname.\n *\n * 1. Reject encoded separators (%2f, %5c) and null bytes (%00)\n * 2. Single percent-decode\n * 3. Collapse // → /\n * 4. Resolve .. segments (reject if escaping root)\n * 5. Strip trailing slash (except root \"/\")\n *\n * @param rawPathname - The raw pathname from the request URL (percent-encoded)\n * @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.\n */\nexport function canonicalize(rawPathname: string, stripTrailingSlash = true): CanonicalizeResult {\n // Step 1: Reject dangerous encoded sequences BEFORE decoding.\n // This must happen on the raw input so %252f doesn't bypass after a single decode.\n if (ENCODED_SEPARATOR_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n if (NULL_BYTE_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n\n // Step 2: Single percent-decode.\n // Double-encoded input (%2561 → %61) stays as %61 — not decoded again.\n let decoded: string;\n try {\n decoded = decodeURIComponent(rawPathname);\n } catch {\n // Malformed percent-encoding → 400\n return { ok: false, status: 400 };\n }\n\n // Reject null bytes that appeared after decoding (from valid %00-like sequences\n // that weren't caught above — belt and suspenders).\n if (decoded.includes('\\0')) {\n return { ok: false, status: 400 };\n }\n\n // Backslash is NOT a path separator — keep as literal character.\n // But reject if it would create // after normalization (e.g., /\\evil.com).\n // We do NOT convert \\ to / — it stays as a literal.\n\n // Step 3: Collapse consecutive slashes.\n let pathname = decoded.replace(/\\/\\/+/g, '/');\n\n // Step 4: Resolve .. and . segments.\n const segments = pathname.split('/');\n const resolved: string[] = [];\n for (const seg of segments) {\n if (seg === '..') {\n if (resolved.length <= 1) {\n // Trying to escape root — 400\n return { ok: false, status: 400 };\n }\n resolved.pop();\n } else if (seg !== '.') {\n resolved.push(seg);\n }\n }\n\n pathname = resolved.join('/') || '/';\n\n // Step 5: Strip trailing slash (except root \"/\").\n if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {\n pathname = pathname.slice(0, -1);\n }\n\n return { ok: true, pathname };\n}\n"],"mappings":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { a as resolveAppDir, u as __exportAll } from "./plugin-context-DUl3dnCF.js";
|
|
1
2
|
import { a as classifyMetadataRoute, c as isDynamicMetadataExtension, n as NULL_BYTE_RE, t as ENCODED_SEPARATOR_RE } from "./canonicalize-P41GR6tY.js";
|
|
2
3
|
import { r as DEFAULT_PAGE_EXTENSIONS, t as classifySegment } from "./segment-classify-CDDRVKs7.js";
|
|
3
|
-
import { h as swallow } from "./logger-
|
|
4
|
-
import { o as resolveAppDir } from "./plugin-context-GfDWsdd2.js";
|
|
4
|
+
import { h as swallow } from "./logger-C5v1IMuh.js";
|
|
5
5
|
import { basename, extname, join } from "node:path";
|
|
6
6
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
7
7
|
//#region src/routing/scanner.ts
|
|
@@ -17,7 +17,7 @@ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "
|
|
|
17
17
|
/**
|
|
18
18
|
* File convention names that use pageExtensions (can be .tsx, .ts, .jsx, .js, .mdx, etc.)
|
|
19
19
|
*/
|
|
20
|
-
var PAGE_EXT_CONVENTIONS = new Set([
|
|
20
|
+
var PAGE_EXT_CONVENTIONS = /* @__PURE__ */ new Set([
|
|
21
21
|
"page",
|
|
22
22
|
"layout",
|
|
23
23
|
"error",
|
|
@@ -37,7 +37,7 @@ var LEGACY_STATUS_FILES = {
|
|
|
37
37
|
/**
|
|
38
38
|
* File convention names that are always .ts/.tsx (never .mdx etc.)
|
|
39
39
|
*/
|
|
40
|
-
var FIXED_CONVENTIONS = new Set([
|
|
40
|
+
var FIXED_CONVENTIONS = /* @__PURE__ */ new Set([
|
|
41
41
|
"middleware",
|
|
42
42
|
"access",
|
|
43
43
|
"route",
|
|
@@ -358,6 +358,14 @@ function findPageExtFile(dirPath, name, extSet) {
|
|
|
358
358
|
*
|
|
359
359
|
* Design doc: design/41-global-params.md §Auto-Scaffolding
|
|
360
360
|
*/
|
|
361
|
+
var cli_schema_sync_exports = /* @__PURE__ */ __exportAll({
|
|
362
|
+
collectDynamicSegments: () => collectDynamicSegments,
|
|
363
|
+
defaultCodecForSegment: () => defaultCodecForSegment,
|
|
364
|
+
findSchemaFile: () => findSchemaFile,
|
|
365
|
+
findSegmentParamsBlock: () => findSegmentParamsBlock,
|
|
366
|
+
parseExistingSchemaKeys: () => parseExistingSchemaKeys,
|
|
367
|
+
runSchemaSync: () => runSchemaSync
|
|
368
|
+
});
|
|
361
369
|
/**
|
|
362
370
|
* Collect all unique bracket-keyed dynamic segment names from the route tree.
|
|
363
371
|
* Returns entries like '[artistSlug]', '[...slug]', '[[...topic]]'.
|
|
@@ -624,6 +632,6 @@ function runSchemaSync(projectRoot) {
|
|
|
624
632
|
};
|
|
625
633
|
}
|
|
626
634
|
//#endregion
|
|
627
|
-
export {
|
|
635
|
+
export { scanRoutes as i, findSchemaFile as n, parseExistingSchemaKeys as r, cli_schema_sync_exports as t };
|
|
628
636
|
|
|
629
|
-
//# sourceMappingURL=cli-schema-sync-
|
|
637
|
+
//# sourceMappingURL=cli-schema-sync-DcmQrzJG.js.map
|