@timber-js/app 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/_chunks/{interception-DIaZN1bF.js → interception-c-a3uODY.js} +8 -8
  2. package/dist/_chunks/interception-c-a3uODY.js.map +1 -0
  3. package/dist/_chunks/{registry-DUIpYD_x.js → registry-BfPM41ri.js} +1 -1
  4. package/dist/_chunks/{registry-DUIpYD_x.js.map → registry-BfPM41ri.js.map} +1 -1
  5. package/dist/_chunks/{request-context-D6XHINkR.js → request-context-BzES06i1.js} +2 -1
  6. package/dist/_chunks/request-context-BzES06i1.js.map +1 -0
  7. package/dist/_chunks/{use-cookie-8ZlA0rr3.js → use-cookie-HcvNlW4L.js} +1 -1
  8. package/dist/_chunks/{use-cookie-8ZlA0rr3.js.map → use-cookie-HcvNlW4L.js.map} +1 -1
  9. package/dist/adapters/cloudflare.d.ts +2 -2
  10. package/dist/adapters/cloudflare.js +4 -4
  11. package/dist/adapters/cloudflare.js.map +1 -1
  12. package/dist/adapters/nitro.d.ts +1 -1
  13. package/dist/adapters/nitro.js +4 -4
  14. package/dist/adapters/nitro.js.map +1 -1
  15. package/dist/cache/index.js.map +1 -1
  16. package/dist/{_chunks/error-boundary-dj-WO5uq.js → client/error-boundary.js} +4 -2
  17. package/dist/client/error-boundary.js.map +1 -0
  18. package/dist/client/form.d.ts +1 -1
  19. package/dist/client/index.js +10 -9
  20. package/dist/client/index.js.map +1 -1
  21. package/dist/client/link.d.ts.map +1 -1
  22. package/dist/client/slot-error-fallback.d.ts +13 -0
  23. package/dist/client/slot-error-fallback.d.ts.map +1 -0
  24. package/dist/client/use-link-status.d.ts +1 -1
  25. package/dist/client/use-navigation-pending.d.ts +1 -1
  26. package/dist/content/index.d.ts +1 -1
  27. package/dist/cookies/define-cookie.d.ts +2 -2
  28. package/dist/cookies/index.d.ts +1 -1
  29. package/dist/cookies/index.d.ts.map +1 -1
  30. package/dist/cookies/index.js +4 -4
  31. package/dist/cookies/index.js.map +1 -1
  32. package/dist/index.js +23 -22
  33. package/dist/index.js.map +1 -1
  34. package/dist/plugins/dev-logs.d.ts +1 -1
  35. package/dist/plugins/dynamic-transform.d.ts +1 -1
  36. package/dist/plugins/shims.d.ts +5 -0
  37. package/dist/plugins/shims.d.ts.map +1 -1
  38. package/dist/routing/codegen.d.ts +1 -1
  39. package/dist/routing/index.js +1 -1
  40. package/dist/search-params/codecs.d.ts +2 -2
  41. package/dist/search-params/create.d.ts +1 -1
  42. package/dist/search-params/index.js +5 -5
  43. package/dist/search-params/index.js.map +1 -1
  44. package/dist/server/action-client.d.ts +1 -1
  45. package/dist/server/dev-fetch-instrumentation.d.ts +22 -0
  46. package/dist/server/dev-fetch-instrumentation.d.ts.map +1 -0
  47. package/dist/server/dev-logger.d.ts.map +1 -1
  48. package/dist/server/form-flash.d.ts +1 -1
  49. package/dist/server/index.js +4 -4
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/primitives.d.ts +15 -0
  52. package/dist/server/primitives.d.ts.map +1 -1
  53. package/dist/server/request-context.d.ts +32 -0
  54. package/dist/server/request-context.d.ts.map +1 -1
  55. package/dist/server/route-element-builder.d.ts.map +1 -1
  56. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  57. package/dist/server/slot-resolver.d.ts.map +1 -1
  58. package/dist/server/ssr-entry.d.ts.map +1 -1
  59. package/dist/shims/headers.d.ts +5 -7
  60. package/dist/shims/headers.d.ts.map +1 -1
  61. package/dist/shims/link.d.ts +1 -1
  62. package/dist/shims/link.d.ts.map +1 -1
  63. package/dist/shims/navigation.d.ts +5 -15
  64. package/dist/shims/navigation.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/adapters/cloudflare.ts +4 -4
  67. package/src/adapters/nitro.ts +4 -4
  68. package/src/cache/index.ts +1 -1
  69. package/src/client/form.tsx +1 -1
  70. package/src/client/index.ts +1 -1
  71. package/src/client/link.tsx +2 -0
  72. package/src/client/slot-error-fallback.tsx +16 -0
  73. package/src/client/use-link-status.ts +1 -1
  74. package/src/client/use-navigation-pending.ts +1 -1
  75. package/src/content/index.ts +1 -1
  76. package/src/cookies/define-cookie.ts +2 -2
  77. package/src/cookies/index.ts +2 -6
  78. package/src/plugins/cache-transform.ts +1 -1
  79. package/src/plugins/dev-logs.ts +2 -2
  80. package/src/plugins/dynamic-transform.ts +2 -2
  81. package/src/plugins/shims.ts +48 -22
  82. package/src/routing/codegen.ts +9 -9
  83. package/src/search-params/codecs.ts +2 -2
  84. package/src/search-params/create.ts +3 -3
  85. package/src/search-params/index.ts +1 -1
  86. package/src/server/action-client.ts +1 -1
  87. package/src/server/asset-headers.ts +1 -1
  88. package/src/server/dev-fetch-instrumentation.ts +96 -0
  89. package/src/server/dev-logger.ts +49 -0
  90. package/src/server/form-flash.ts +1 -1
  91. package/src/server/index.ts +1 -1
  92. package/src/server/primitives.ts +24 -1
  93. package/src/server/request-context.ts +7 -1
  94. package/src/server/route-element-builder.ts +2 -5
  95. package/src/server/rsc-entry/index.ts +33 -1
  96. package/src/server/slot-resolver.ts +20 -1
  97. package/src/server/ssr-entry.ts +21 -5
  98. package/src/shims/headers.ts +5 -7
  99. package/src/shims/link.ts +3 -1
  100. package/src/shims/navigation.ts +7 -17
  101. package/dist/_chunks/error-boundary-dj-WO5uq.js.map +0 -1
  102. package/dist/_chunks/interception-DIaZN1bF.js.map +0 -1
  103. package/dist/_chunks/request-context-D6XHINkR.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"nitro.js","names":[],"sources":["../../src/adapters/nitro.ts"],"sourcesContent":["// Nitro adapter — multi-platform deployment\n//\n// Covers everything except Cloudflare Workers: Node.js, Bun, Vercel,\n// Netlify, AWS Lambda, Deno Deploy, Azure Functions. Nitro handles\n// compression, graceful shutdown, static file serving, and platform quirks.\n// See design/11-platform.md and design/25-production-deployments.md.\n\nimport { writeFile, mkdir, cp } from 'node:fs/promises';\nimport { execFile } from 'node:child_process';\nimport { join, relative } from 'node:path';\nimport type { TimberPlatformAdapter, TimberConfig } from './types';\n// Inlined from server/asset-headers.ts — adapters are loaded by Node at\n// Vite startup time, before Vite's module resolver is available, so cross-\n// directory .ts imports don't resolve.\nconst IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';\nconst STATIC_CACHE = 'public, max-age=3600, must-revalidate';\n\nfunction generateHeadersFile(): string {\n return `# Auto-generated by @timber/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/*\n Cache-Control: ${STATIC_CACHE}\n`;\n}\n\n// ─── Presets ─────────────────────────────────────────────────────────────────\n\n/**\n * Supported Nitro deployment presets.\n *\n * Each preset maps to a Nitro deployment target. The adapter generates\n * the appropriate configuration and entry point for the selected platform.\n */\nexport type NitroPreset =\n | 'vercel'\n | 'vercel-edge'\n | 'netlify'\n | 'netlify-edge'\n | 'aws-lambda'\n | 'deno-deploy'\n | 'azure-functions'\n | 'node-server'\n | 'bun';\n\n/** Preset-specific Nitro configuration. */\ninterface PresetConfig {\n /** Nitro preset name passed to the Nitro build. */\n nitroPreset: string;\n /** Output directory name within the build dir. */\n outputDir: string;\n /** Whether the runtime supports waitUntil. */\n supportsWaitUntil: boolean;\n /** Whether the runtime supports application-level 103 Early Hints. */\n supportsEarlyHints: boolean;\n /** Value for TIMBER_RUNTIME env var. See design/25-production-deployments.md. */\n runtimeName: string;\n /** Additional nitro.config fields for this preset. */\n extraConfig?: Record<string, unknown>;\n}\n\nconst PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {\n 'vercel': {\n nitroPreset: 'vercel',\n outputDir: '.vercel/output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'vercel',\n extraConfig: { vercel: { functions: { maxDuration: 30 } } },\n },\n 'vercel-edge': {\n nitroPreset: 'vercel-edge',\n outputDir: '.vercel/output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'vercel-edge',\n },\n 'netlify': {\n nitroPreset: 'netlify',\n outputDir: '.netlify/functions-internal',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'netlify',\n },\n 'netlify-edge': {\n nitroPreset: 'netlify-edge',\n outputDir: '.netlify/edge-functions',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'netlify-edge',\n },\n 'aws-lambda': {\n nitroPreset: 'aws-lambda',\n outputDir: '.output',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'aws-lambda',\n },\n 'deno-deploy': {\n nitroPreset: 'deno-deploy',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'deno-deploy',\n },\n 'azure-functions': {\n nitroPreset: 'azure-functions',\n outputDir: '.output',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'azure-functions',\n },\n 'node-server': {\n nitroPreset: 'node-server',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: true,\n runtimeName: 'node-server',\n },\n 'bun': {\n nitroPreset: 'bun',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: true,\n runtimeName: 'bun',\n },\n};\n\n// ─── Options ─────────────────────────────────────────────────────────────────\n\n/** Options for the Nitro adapter. */\nexport interface NitroAdapterOptions {\n /**\n * Deployment preset. Determines the target platform.\n * @default 'node-server'\n */\n preset?: NitroPreset;\n\n /**\n * Additional Nitro configuration to merge into the generated config.\n * Overrides default values for the selected preset.\n */\n nitroConfig?: Record<string, unknown>;\n}\n\n// ─── Adapter ─────────────────────────────────────────────────────────────────\n\n/**\n * Create a Nitro-based adapter for multi-platform deployment.\n *\n * Nitro abstracts deployment targets — the same timber.js app can deploy\n * to Vercel, Netlify, AWS, Deno Deploy, or Azure by changing the preset.\n *\n * @example\n * ```ts\n * import { nitro } from '@timber/app/adapters/nitro'\n *\n * export default {\n * output: 'server',\n * adapter: nitro({ preset: 'vercel' }),\n * }\n * ```\n */\nexport function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter {\n const preset = options.preset ?? 'node-server';\n const presetConfig = PRESET_CONFIGS[preset];\n const pendingPromises: Promise<unknown>[] = [];\n\n return {\n name: `nitro-${preset}`,\n\n async buildOutput(config: TimberConfig, buildDir: string) {\n const outDir = join(buildDir, 'nitro');\n await mkdir(outDir, { recursive: true });\n\n // Copy client assets to public 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, 'public');\n await mkdir(publicDir, { recursive: true });\n await cp(clientDir, publicDir, {\n recursive: true,\n filter: config.clientJavascriptDisabled ? (src: string) => !src.endsWith('.js') : undefined,\n }).catch(() => {\n // Client dir may not exist when client JavaScript is disabled\n });\n\n // Write _headers file for platforms that support it (Netlify, etc.).\n // See design/25-production-deployments.md §\"CDN / Edge Cache\"\n await writeFile(join(publicDir, '_headers'), generateHeadersFile());\n\n // Write the build manifest init module (if manifest data was produced).\n if (config.manifestInit) {\n await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);\n }\n\n // Generate the Nitro entry point\n const hasManifestInit = !!config.manifestInit;\n const entry = generateNitroEntry(buildDir, outDir, preset, hasManifestInit);\n await writeFile(join(outDir, 'entry.ts'), entry);\n\n // Generate the Nitro config with static asset cache rules\n const nitroConfig = generateNitroConfig(preset, options.nitroConfig);\n await writeFile(join(outDir, 'nitro.config.ts'), nitroConfig);\n },\n\n // Only presets that produce a locally-runnable server get preview().\n // Serverless presets (vercel, netlify, aws-lambda, etc.) have no\n // local runtime — Vite's built-in preview is the fallback.\n preview: LOCALLY_PREVIEWABLE.has(preset)\n ? async (_config: TimberConfig, buildDir: string) => {\n const cmd = generateNitroPreviewCommand(buildDir, preset);\n if (!cmd) return;\n await spawnNitroPreview(cmd.command, cmd.args, cmd.cwd);\n }\n : undefined,\n\n waitUntil: presetConfig.supportsWaitUntil\n ? (promise: Promise<unknown>) => {\n const tracked = promise.catch((err) => {\n console.error('[timber] waitUntil promise rejected:', err);\n });\n pendingPromises.push(tracked);\n }\n : undefined,\n };\n}\n\n// ─── Entry Generation ────────────────────────────────────────────────────────\n\n/** @internal Exported for testing. */\nexport function generateNitroEntry(\n buildDir: string,\n outDir: string,\n preset: NitroPreset,\n hasManifestInit = false\n): string {\n const serverEntryRelative = relative(outDir, join(buildDir, 'server', 'entry.js'));\n const runtimeName = PRESET_CONFIGS[preset].runtimeName;\n const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;\n\n // Build manifest init must be imported before the handler so that\n // globalThis.__TIMBER_BUILD_MANIFEST__ is set when the virtual module evaluates.\n const manifestImport = hasManifestInit ? \"import './_timber-manifest-init.js'\\n\" : '';\n\n // On node-server and bun, wrap the handler with ALS so the pipeline\n // can send 103 Early Hints via res.writeEarlyHints(). Other presets\n // either don't support 103 or handle it at the CDN level.\n const earlyHintsImport = earlyHints\n ? `import { runWithEarlyHintsSender } from '${serverEntryRelative}'\\n`\n : '';\n\n const handlerCall = earlyHints\n ? ` const nodeRes = event.node?.res\n const earlyHintsSender = (typeof nodeRes?.writeEarlyHints === 'function')\n ? (links) => { try { nodeRes.writeEarlyHints({ link: links }) } catch {} }\n : undefined\n\n const webResponse = earlyHintsSender\n ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))\n : await handler(webRequest)`\n : ` const webResponse = await handler(webRequest)`;\n\n return `// Generated by @timber/app/adapters/nitro\n// Do not edit — this file is regenerated on each build.\n\n${manifestImport}${earlyHintsImport}import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3'\nimport { handler } from '${serverEntryRelative}'\n\n// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.\n// See design/25-production-deployments.md §\"TIMBER_RUNTIME\".\nprocess.env.TIMBER_RUNTIME = '${runtimeName}'\n\nexport default defineEventHandler(async (event) => {\n const webRequest = toWebRequest(event)\n${handlerCall}\n return sendWebResponse(event, webResponse)\n})\n`;\n}\n\n/** @internal Exported for testing. */\nexport function generateNitroConfig(\n preset: NitroPreset,\n userConfig?: Record<string, unknown>\n): string {\n const presetConfig = PRESET_CONFIGS[preset];\n\n const config: Record<string, unknown> = {\n preset: presetConfig.nitroPreset,\n output: { dir: presetConfig.outputDir },\n // Static asset cache headers — hashed assets are immutable, others get 1h.\n // See design/25-production-deployments.md §\"CDN / Edge Cache\"\n routeRules: {\n '/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },\n },\n ...presetConfig.extraConfig,\n ...userConfig,\n };\n\n const configJson = JSON.stringify(config, null, 2);\n\n return `// Generated by @timber/app/adapters/nitro\n// Do not edit — this file is regenerated on each build.\n\nimport { defineNitroConfig } from 'nitropack/config'\n\nexport default defineNitroConfig(${configJson})\n`;\n}\n\n// ─── Preview ─────────────────────────────────────────────────────────────────\n\n/** Presets that produce a locally-runnable server entry. */\nconst LOCALLY_PREVIEWABLE = new Set<NitroPreset>(['node-server', 'bun']);\n\n/** Command descriptor for Nitro preview — testable without spawning. */\nexport interface NitroPreviewCommand {\n command: string;\n args: string[];\n cwd: string;\n}\n\n/** @internal Exported for testing. */\nexport function generateNitroPreviewCommand(\n buildDir: string,\n preset: NitroPreset\n): NitroPreviewCommand | null {\n if (!LOCALLY_PREVIEWABLE.has(preset)) return null;\n\n const nitroDir = join(buildDir, 'nitro');\n const entryPath = join(nitroDir, 'entry.ts');\n\n const command = preset === 'bun' ? 'bun' : 'node';\n return {\n command,\n args: [entryPath],\n cwd: nitroDir,\n };\n}\n\n/** Spawn a Nitro preview process and pipe stdio. */\nfunction spawnNitroPreview(command: string, args: string[], cwd: string): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const child = execFile(command, args, { cwd }, (err) => {\n if (err) reject(err);\n else resolve();\n });\n child.stdout?.pipe(process.stdout);\n child.stderr?.pipe(process.stderr);\n });\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\n/**\n * Get the preset configuration for a given preset name.\n * @internal Exported for testing.\n */\nexport function getPresetConfig(preset: NitroPreset): PresetConfig {\n return PRESET_CONFIGS[preset];\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"nitro.js","names":[],"sources":["../../src/adapters/nitro.ts"],"sourcesContent":["// Nitro adapter — multi-platform deployment\n//\n// Covers everything except Cloudflare Workers: Node.js, Bun, Vercel,\n// Netlify, AWS Lambda, Deno Deploy, Azure Functions. Nitro handles\n// compression, graceful shutdown, static file serving, and platform quirks.\n// See design/11-platform.md and design/25-production-deployments.md.\n\nimport { writeFile, mkdir, cp } from 'node:fs/promises';\nimport { execFile } from 'node:child_process';\nimport { join, relative } from 'node:path';\nimport type { TimberPlatformAdapter, TimberConfig } from './types';\n// Inlined from server/asset-headers.ts — adapters are loaded by Node at\n// Vite startup time, before Vite's module resolver is available, so cross-\n// directory .ts imports don't resolve.\nconst IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';\nconst STATIC_CACHE = 'public, max-age=3600, must-revalidate';\n\nfunction generateHeadersFile(): string {\n return `# 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/*\n Cache-Control: ${STATIC_CACHE}\n`;\n}\n\n// ─── Presets ─────────────────────────────────────────────────────────────────\n\n/**\n * Supported Nitro deployment presets.\n *\n * Each preset maps to a Nitro deployment target. The adapter generates\n * the appropriate configuration and entry point for the selected platform.\n */\nexport type NitroPreset =\n | 'vercel'\n | 'vercel-edge'\n | 'netlify'\n | 'netlify-edge'\n | 'aws-lambda'\n | 'deno-deploy'\n | 'azure-functions'\n | 'node-server'\n | 'bun';\n\n/** Preset-specific Nitro configuration. */\ninterface PresetConfig {\n /** Nitro preset name passed to the Nitro build. */\n nitroPreset: string;\n /** Output directory name within the build dir. */\n outputDir: string;\n /** Whether the runtime supports waitUntil. */\n supportsWaitUntil: boolean;\n /** Whether the runtime supports application-level 103 Early Hints. */\n supportsEarlyHints: boolean;\n /** Value for TIMBER_RUNTIME env var. See design/25-production-deployments.md. */\n runtimeName: string;\n /** Additional nitro.config fields for this preset. */\n extraConfig?: Record<string, unknown>;\n}\n\nconst PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {\n 'vercel': {\n nitroPreset: 'vercel',\n outputDir: '.vercel/output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'vercel',\n extraConfig: { vercel: { functions: { maxDuration: 30 } } },\n },\n 'vercel-edge': {\n nitroPreset: 'vercel-edge',\n outputDir: '.vercel/output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'vercel-edge',\n },\n 'netlify': {\n nitroPreset: 'netlify',\n outputDir: '.netlify/functions-internal',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'netlify',\n },\n 'netlify-edge': {\n nitroPreset: 'netlify-edge',\n outputDir: '.netlify/edge-functions',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'netlify-edge',\n },\n 'aws-lambda': {\n nitroPreset: 'aws-lambda',\n outputDir: '.output',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'aws-lambda',\n },\n 'deno-deploy': {\n nitroPreset: 'deno-deploy',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'deno-deploy',\n },\n 'azure-functions': {\n nitroPreset: 'azure-functions',\n outputDir: '.output',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'azure-functions',\n },\n 'node-server': {\n nitroPreset: 'node-server',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: true,\n runtimeName: 'node-server',\n },\n 'bun': {\n nitroPreset: 'bun',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: true,\n runtimeName: 'bun',\n },\n};\n\n// ─── Options ─────────────────────────────────────────────────────────────────\n\n/** Options for the Nitro adapter. */\nexport interface NitroAdapterOptions {\n /**\n * Deployment preset. Determines the target platform.\n * @default 'node-server'\n */\n preset?: NitroPreset;\n\n /**\n * Additional Nitro configuration to merge into the generated config.\n * Overrides default values for the selected preset.\n */\n nitroConfig?: Record<string, unknown>;\n}\n\n// ─── Adapter ─────────────────────────────────────────────────────────────────\n\n/**\n * Create a Nitro-based adapter for multi-platform deployment.\n *\n * Nitro abstracts deployment targets — the same timber.js app can deploy\n * to Vercel, Netlify, AWS, Deno Deploy, or Azure by changing the preset.\n *\n * @example\n * ```ts\n * import { nitro } from '@timber-js/app/adapters/nitro'\n *\n * export default {\n * output: 'server',\n * adapter: nitro({ preset: 'vercel' }),\n * }\n * ```\n */\nexport function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter {\n const preset = options.preset ?? 'node-server';\n const presetConfig = PRESET_CONFIGS[preset];\n const pendingPromises: Promise<unknown>[] = [];\n\n return {\n name: `nitro-${preset}`,\n\n async buildOutput(config: TimberConfig, buildDir: string) {\n const outDir = join(buildDir, 'nitro');\n await mkdir(outDir, { recursive: true });\n\n // Copy client assets to public 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, 'public');\n await mkdir(publicDir, { recursive: true });\n await cp(clientDir, publicDir, {\n recursive: true,\n filter: config.clientJavascriptDisabled ? (src: string) => !src.endsWith('.js') : undefined,\n }).catch(() => {\n // Client dir may not exist when client JavaScript is disabled\n });\n\n // Write _headers file for platforms that support it (Netlify, etc.).\n // See design/25-production-deployments.md §\"CDN / Edge Cache\"\n await writeFile(join(publicDir, '_headers'), generateHeadersFile());\n\n // Write the build manifest init module (if manifest data was produced).\n if (config.manifestInit) {\n await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);\n }\n\n // Generate the Nitro entry point\n const hasManifestInit = !!config.manifestInit;\n const entry = generateNitroEntry(buildDir, outDir, preset, hasManifestInit);\n await writeFile(join(outDir, 'entry.ts'), entry);\n\n // Generate the Nitro config with static asset cache rules\n const nitroConfig = generateNitroConfig(preset, options.nitroConfig);\n await writeFile(join(outDir, 'nitro.config.ts'), nitroConfig);\n },\n\n // Only presets that produce a locally-runnable server get preview().\n // Serverless presets (vercel, netlify, aws-lambda, etc.) have no\n // local runtime — Vite's built-in preview is the fallback.\n preview: LOCALLY_PREVIEWABLE.has(preset)\n ? async (_config: TimberConfig, buildDir: string) => {\n const cmd = generateNitroPreviewCommand(buildDir, preset);\n if (!cmd) return;\n await spawnNitroPreview(cmd.command, cmd.args, cmd.cwd);\n }\n : undefined,\n\n waitUntil: presetConfig.supportsWaitUntil\n ? (promise: Promise<unknown>) => {\n const tracked = promise.catch((err) => {\n console.error('[timber] waitUntil promise rejected:', err);\n });\n pendingPromises.push(tracked);\n }\n : undefined,\n };\n}\n\n// ─── Entry Generation ────────────────────────────────────────────────────────\n\n/** @internal Exported for testing. */\nexport function generateNitroEntry(\n buildDir: string,\n outDir: string,\n preset: NitroPreset,\n hasManifestInit = false\n): string {\n const serverEntryRelative = relative(outDir, join(buildDir, 'server', 'entry.js'));\n const runtimeName = PRESET_CONFIGS[preset].runtimeName;\n const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;\n\n // Build manifest init must be imported before the handler so that\n // globalThis.__TIMBER_BUILD_MANIFEST__ is set when the virtual module evaluates.\n const manifestImport = hasManifestInit ? \"import './_timber-manifest-init.js'\\n\" : '';\n\n // On node-server and bun, wrap the handler with ALS so the pipeline\n // can send 103 Early Hints via res.writeEarlyHints(). Other presets\n // either don't support 103 or handle it at the CDN level.\n const earlyHintsImport = earlyHints\n ? `import { runWithEarlyHintsSender } from '${serverEntryRelative}'\\n`\n : '';\n\n const handlerCall = earlyHints\n ? ` const nodeRes = event.node?.res\n const earlyHintsSender = (typeof nodeRes?.writeEarlyHints === 'function')\n ? (links) => { try { nodeRes.writeEarlyHints({ link: links }) } catch {} }\n : undefined\n\n const webResponse = earlyHintsSender\n ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))\n : await handler(webRequest)`\n : ` const webResponse = await handler(webRequest)`;\n\n return `// Generated by @timber-js/app/adapters/nitro\n// Do not edit — this file is regenerated on each build.\n\n${manifestImport}${earlyHintsImport}import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3'\nimport { handler } from '${serverEntryRelative}'\n\n// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.\n// See design/25-production-deployments.md §\"TIMBER_RUNTIME\".\nprocess.env.TIMBER_RUNTIME = '${runtimeName}'\n\nexport default defineEventHandler(async (event) => {\n const webRequest = toWebRequest(event)\n${handlerCall}\n return sendWebResponse(event, webResponse)\n})\n`;\n}\n\n/** @internal Exported for testing. */\nexport function generateNitroConfig(\n preset: NitroPreset,\n userConfig?: Record<string, unknown>\n): string {\n const presetConfig = PRESET_CONFIGS[preset];\n\n const config: Record<string, unknown> = {\n preset: presetConfig.nitroPreset,\n output: { dir: presetConfig.outputDir },\n // Static asset cache headers — hashed assets are immutable, others get 1h.\n // See design/25-production-deployments.md §\"CDN / Edge Cache\"\n routeRules: {\n '/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },\n },\n ...presetConfig.extraConfig,\n ...userConfig,\n };\n\n const configJson = JSON.stringify(config, null, 2);\n\n return `// Generated by @timber-js/app/adapters/nitro\n// Do not edit — this file is regenerated on each build.\n\nimport { defineNitroConfig } from 'nitropack/config'\n\nexport default defineNitroConfig(${configJson})\n`;\n}\n\n// ─── Preview ─────────────────────────────────────────────────────────────────\n\n/** Presets that produce a locally-runnable server entry. */\nconst LOCALLY_PREVIEWABLE = new Set<NitroPreset>(['node-server', 'bun']);\n\n/** Command descriptor for Nitro preview — testable without spawning. */\nexport interface NitroPreviewCommand {\n command: string;\n args: string[];\n cwd: string;\n}\n\n/** @internal Exported for testing. */\nexport function generateNitroPreviewCommand(\n buildDir: string,\n preset: NitroPreset\n): NitroPreviewCommand | null {\n if (!LOCALLY_PREVIEWABLE.has(preset)) return null;\n\n const nitroDir = join(buildDir, 'nitro');\n const entryPath = join(nitroDir, 'entry.ts');\n\n const command = preset === 'bun' ? 'bun' : 'node';\n return {\n command,\n args: [entryPath],\n cwd: nitroDir,\n };\n}\n\n/** Spawn a Nitro preview process and pipe stdio. */\nfunction spawnNitroPreview(command: string, args: string[], cwd: string): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const child = execFile(command, args, { cwd }, (err) => {\n if (err) reject(err);\n else resolve();\n });\n child.stdout?.pipe(process.stdout);\n child.stderr?.pipe(process.stderr);\n });\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\n/**\n * Get the preset configuration for a given preset name.\n * @internal Exported for testing.\n */\nexport function getPresetConfig(preset: NitroPreset): PresetConfig {\n return PRESET_CONFIGS[preset];\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/cache/redis-handler.ts","../../src/cache/stable-stringify.ts","../../src/cache/singleflight.ts","../../src/cache/timber-cache.ts","../../src/cache/register-cached-function.ts","../../src/cache/index.ts"],"sourcesContent":["import type { CacheHandler } from './index';\n\n/**\n * Minimal Redis client interface — compatible with ioredis, node-redis, and\n * Cloudflare Workers Redis bindings. We depend on the interface, not the\n * implementation, so users bring their own Redis client.\n */\nexport interface RedisClient {\n get(key: string): Promise<string | null>;\n set(key: string, value: string, ...args: unknown[]): Promise<unknown>;\n del(key: string | string[]): Promise<number>;\n sadd(key: string, ...members: string[]): Promise<number>;\n smembers(key: string): Promise<string[]>;\n}\n\nconst KEY_PREFIX = 'timber:cache:';\nconst TAG_PREFIX = 'timber:tag:';\n\n/**\n * Redis-backed CacheHandler for distributed caching.\n *\n * All instances sharing the same Redis see each other's cache entries and\n * invalidations. Tag-based invalidation uses Redis Sets to track which keys\n * belong to which tags.\n *\n * Bring your own Redis client — any client implementing the RedisClient\n * interface works (ioredis, node-redis, @upstash/redis, etc.).\n */\nexport class RedisCacheHandler implements CacheHandler {\n private client: RedisClient;\n private prefix: string;\n\n constructor(client: RedisClient, opts?: { prefix?: string }) {\n this.client = client;\n this.prefix = opts?.prefix ?? '';\n }\n\n private cacheKey(key: string): string {\n return `${this.prefix}${KEY_PREFIX}${key}`;\n }\n\n private tagKey(tag: string): string {\n return `${this.prefix}${TAG_PREFIX}${tag}`;\n }\n\n async get(key: string): Promise<{ value: unknown; stale: boolean } | null> {\n const raw = await this.client.get(this.cacheKey(key));\n if (raw === null) return null;\n\n const entry = JSON.parse(raw) as { value: unknown; expiresAt: number };\n const stale = Date.now() > entry.expiresAt;\n return { value: entry.value, stale };\n }\n\n async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {\n const ck = this.cacheKey(key);\n const expiresAt = Date.now() + opts.ttl * 1000;\n const payload = JSON.stringify({ value, expiresAt });\n\n // Redis TTL with generous margin beyond the logical TTL to allow SWR reads\n // on stale entries. The logical staleness is determined by expiresAt.\n // We use 2x TTL + 60s as the Redis expiry so stale entries remain\n // available for SWR background refetches.\n const redisTtlSeconds = Math.max(opts.ttl * 2 + 60, 120);\n await this.client.set(ck, payload, 'EX', redisTtlSeconds);\n\n // Track key membership in each tag set\n for (const tag of opts.tags) {\n await this.client.sadd(this.tagKey(tag), key);\n }\n }\n\n async invalidate(opts: { key?: string; tag?: string }): Promise<void> {\n if (opts.key) {\n await this.client.del(this.cacheKey(opts.key));\n }\n\n if (opts.tag) {\n const tk = this.tagKey(opts.tag);\n const keys = await this.client.smembers(tk);\n\n if (keys.length > 0) {\n const cacheKeys = keys.map((k) => this.cacheKey(k));\n await this.client.del(cacheKeys);\n }\n\n // Clean up the tag set itself\n await this.client.del(tk);\n }\n }\n}\n","/**\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 */\nexport function stableStringify(value: unknown): string {\n if (value === null || value === undefined) return JSON.stringify(value);\n if (typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) {\n return '[' + value.map((item) => stableStringify(item)).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 pairs.push(JSON.stringify(key) + ':' + stableStringify(obj[key]));\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 */\nexport interface Singleflight {\n do<T>(key: string, fn: () => Promise<T>): Promise<T>;\n}\n\nexport function createSingleflight(): Singleflight {\n const inflight = new Map<string, Promise<unknown>>();\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 const promise = fn().finally(() => {\n inflight.delete(key);\n });\n inflight.set(key, promise);\n return promise;\n },\n };\n}\n","import { createHash } from 'node:crypto';\nimport type { CacheHandler, CacheOptions } from './index';\nimport { stableStringify } from './stable-stringify';\nimport { createSingleflight } from './singleflight';\nimport { addSpanEvent } from '#/server/tracing.js';\n\nconst singleflight = createSingleflight();\n\n/**\n * Generate a SHA-256 cache key from function identity and serialized args.\n */\nfunction defaultKeyGenerator(fnId: string, args: unknown[]): string {\n const raw = fnId + ':' + stableStringify(args);\n return createHash('sha256').update(raw).digest('hex');\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// Counter for generating unique function IDs when no explicit key is provided.\nlet fnIdCounter = 0;\n\n/**\n * Creates a cached wrapper around an async function.\n *\n * - SHA-256 default keys with normalized JSON args\n * - Singleflight: concurrent misses → single execution\n * - SWR: serve stale immediately, background refetch\n * - Tags as string[] or function of args\n * - No ALS dependency\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 handler: CacheHandler\n): (...args: Parameters<Fn>) => Promise<Awaited<ReturnType<Fn>>> {\n const fnId = `timber-cache:${fnIdCounter++}`;\n\n return async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {\n const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId, args);\n\n const cacheStart = performance.now();\n const cached = await handler.get(key);\n\n if (cached && !cached.stale) {\n // Record as OTEL span event on enclosing span (not a child span)\n await addSpanEvent('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 if (cached && cached.stale && opts.staleWhileRevalidate) {\n // Record stale cache hit as OTEL span event\n await addSpanEvent('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 singleflight\n .do(`swr:${key}`, async () => {\n try {\n const fresh = await fn(...args);\n const tags = resolveTags(opts, args);\n await handler.set(key, fresh, { ttl: opts.ttl, tags });\n } catch {\n // Failed refetch — stale entry continues to be served.\n // Error is swallowed per design doc: \"Error is logged.\"\n }\n })\n .catch(() => {\n // Singleflight promise rejection handled — stale continues.\n });\n return cached.value as Awaited<ReturnType<Fn>>;\n }\n\n // Cache miss (or stale without SWR) — execute with singleflight\n const result = await singleflight.do(key, () => fn(...args));\n const tags = resolveTags(opts, args);\n await handler.set(key, result, { ttl: opts.ttl, tags });\n\n // Record cache miss as OTEL span event\n await addSpanEvent('timber.cache.miss', {\n key,\n duration_ms: Math.round(performance.now() - cacheStart),\n });\n\n return result as Awaited<ReturnType<Fn>>;\n };\n}\n\n/**\n * Invalidate cache entries by tag or key.\n */\ncreateCache.invalidate = async function invalidate(\n handler: CacheHandler,\n opts: { key?: string; tag?: string }\n): Promise<void> {\n await handler.invalidate(opts);\n};\n","import { createHash } from 'node:crypto';\nimport type { CacheHandler } from './index';\nimport { stableStringify } from './stable-stringify';\nimport { createSingleflight } from './singleflight';\n\nconst singleflight = createSingleflight();\n\n// Prop names that suggest request-specific data — triggers dev warning for \"use cache\" components.\nconst REQUEST_SPECIFIC_PROPS = new Set([\n 'cookies',\n 'cookie',\n 'session',\n 'sessionId',\n 'token',\n 'authorization',\n 'auth',\n 'headers',\n]);\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport interface RegisterCachedFunctionOptions<Fn extends (...args: any[]) => any> {\n ttl: number;\n id: string;\n tags?: string[] | ((...args: Parameters<Fn>) => string[]);\n /** True when the cached function is a React component (PascalCase name). */\n isComponent?: boolean;\n}\n\n/**\n * Generate a SHA-256 cache key from a stable function ID and serialized args.\n */\nfunction generateKey(id: string, args: unknown[]): string {\n const raw = id + ':' + stableStringify(args);\n return createHash('sha256').update(raw).digest('hex');\n}\n\n/**\n * Resolve tags from 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: RegisterCachedFunctionOptions<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/**\n * Checks if component props contain request-specific keys and emits a dev warning.\n * Only runs when process.env.NODE_ENV !== 'production'.\n */\nfunction warnRequestSpecificProps(id: string, props: unknown): void {\n if (typeof props !== 'object' || props === null) return;\n const keys = Object.keys(props);\n const suspicious = keys.filter((k) => REQUEST_SPECIFIC_PROPS.has(k.toLowerCase()));\n if (suspicious.length > 0) {\n console.warn(\n `[timber] \"use cache\" component ${id} received request-specific props: ${suspicious.join(', ')}. ` +\n `This may serve one user's cached render to another user. ` +\n `Remove request-specific data from props or remove \"use cache\".`\n );\n }\n}\n\n/**\n * Runtime for the \"use cache\" directive transform. Wraps an async function\n * with caching using the same cache handler as timber.cache.\n *\n * The stable `id` (file path + function name) ensures cache keys are consistent\n * across builds. Args/props are hashed with SHA-256 for the per-call key.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function registerCachedFunction<Fn extends (...args: any[]) => Promise<any>>(\n fn: Fn,\n opts: RegisterCachedFunctionOptions<Fn>,\n handler: CacheHandler\n): (...args: Parameters<Fn>) => Promise<Awaited<ReturnType<Fn>>> {\n return async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {\n // Dev-mode warning for components with request-specific props\n if (opts.isComponent && process.env.NODE_ENV !== 'production' && args.length > 0) {\n warnRequestSpecificProps(opts.id, args[0]);\n }\n\n const key = generateKey(opts.id, args);\n const cached = await handler.get(key);\n\n if (cached && !cached.stale) {\n return cached.value as Awaited<ReturnType<Fn>>;\n }\n\n // Cache miss or stale — execute with singleflight\n const result = await singleflight.do(key, () => fn(...args));\n const tags = resolveTags(opts, args);\n await handler.set(key, result, { ttl: opts.ttl, tags });\n return result as Awaited<ReturnType<Fn>>;\n };\n}\n","// @timber/app/cache — Caching primitives\n\nexport interface CacheHandler {\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\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport interface CacheOptions<Fn extends (...args: any[]) => any> {\n ttl: number;\n key?: (...args: Parameters<Fn>) => string;\n staleWhileRevalidate?: boolean;\n tags?: string[] | ((...args: Parameters<Fn>) => string[]);\n}\n\nexport interface MemoryCacheHandlerOptions {\n /** Maximum number of entries. Oldest accessed entries are evicted first. Default: 1000. */\n maxSize?: number;\n}\n\nexport class MemoryCacheHandler implements CacheHandler {\n private store = new Map<string, { value: unknown; expiresAt: number; tags: string[] }>();\n private maxSize: number;\n\n constructor(opts?: MemoryCacheHandlerOptions) {\n this.maxSize = opts?.maxSize ?? 1000;\n }\n\n async get(key: string) {\n const entry = this.store.get(key);\n if (!entry) return null;\n\n // Move to end of Map (most recently used) for LRU ordering\n this.store.delete(key);\n this.store.set(key, entry);\n\n const stale = Date.now() > entry.expiresAt;\n return { value: entry.value, stale };\n }\n\n async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }) {\n // If key already exists, delete first to refresh insertion order\n if (this.store.has(key)) {\n this.store.delete(key);\n }\n\n // Evict oldest entries (front of Map) if at capacity\n while (this.store.size >= this.maxSize) {\n const oldest = this.store.keys().next().value;\n if (oldest !== undefined) {\n this.store.delete(oldest);\n } else {\n break;\n }\n }\n\n this.store.set(key, {\n value,\n expiresAt: Date.now() + opts.ttl * 1000,\n tags: opts.tags,\n });\n }\n\n async invalidate(opts: { key?: string; tag?: string }) {\n if (opts.key) {\n this.store.delete(opts.key);\n }\n if (opts.tag) {\n for (const [key, entry] of this.store) {\n if (entry.tags.includes(opts.tag)) {\n this.store.delete(key);\n }\n }\n }\n }\n\n /** Number of entries currently in the cache. */\n get size(): number {\n return this.store.size;\n }\n}\n\nexport { RedisCacheHandler } from './redis-handler';\nexport type { RedisClient } from './redis-handler';\nexport { createCache } from './timber-cache';\nexport { registerCachedFunction } from './register-cached-function';\nexport type { RegisterCachedFunctionOptions } from './register-cached-function';\nexport { stableStringify } from './stable-stringify';\nexport { createSingleflight } from './singleflight';\nexport type { Singleflight } from './singleflight';\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/cache/redis-handler.ts","../../src/cache/stable-stringify.ts","../../src/cache/singleflight.ts","../../src/cache/timber-cache.ts","../../src/cache/register-cached-function.ts","../../src/cache/index.ts"],"sourcesContent":["import type { CacheHandler } from './index';\n\n/**\n * Minimal Redis client interface — compatible with ioredis, node-redis, and\n * Cloudflare Workers Redis bindings. We depend on the interface, not the\n * implementation, so users bring their own Redis client.\n */\nexport interface RedisClient {\n get(key: string): Promise<string | null>;\n set(key: string, value: string, ...args: unknown[]): Promise<unknown>;\n del(key: string | string[]): Promise<number>;\n sadd(key: string, ...members: string[]): Promise<number>;\n smembers(key: string): Promise<string[]>;\n}\n\nconst KEY_PREFIX = 'timber:cache:';\nconst TAG_PREFIX = 'timber:tag:';\n\n/**\n * Redis-backed CacheHandler for distributed caching.\n *\n * All instances sharing the same Redis see each other's cache entries and\n * invalidations. Tag-based invalidation uses Redis Sets to track which keys\n * belong to which tags.\n *\n * Bring your own Redis client — any client implementing the RedisClient\n * interface works (ioredis, node-redis, @upstash/redis, etc.).\n */\nexport class RedisCacheHandler implements CacheHandler {\n private client: RedisClient;\n private prefix: string;\n\n constructor(client: RedisClient, opts?: { prefix?: string }) {\n this.client = client;\n this.prefix = opts?.prefix ?? '';\n }\n\n private cacheKey(key: string): string {\n return `${this.prefix}${KEY_PREFIX}${key}`;\n }\n\n private tagKey(tag: string): string {\n return `${this.prefix}${TAG_PREFIX}${tag}`;\n }\n\n async get(key: string): Promise<{ value: unknown; stale: boolean } | null> {\n const raw = await this.client.get(this.cacheKey(key));\n if (raw === null) return null;\n\n const entry = JSON.parse(raw) as { value: unknown; expiresAt: number };\n const stale = Date.now() > entry.expiresAt;\n return { value: entry.value, stale };\n }\n\n async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {\n const ck = this.cacheKey(key);\n const expiresAt = Date.now() + opts.ttl * 1000;\n const payload = JSON.stringify({ value, expiresAt });\n\n // Redis TTL with generous margin beyond the logical TTL to allow SWR reads\n // on stale entries. The logical staleness is determined by expiresAt.\n // We use 2x TTL + 60s as the Redis expiry so stale entries remain\n // available for SWR background refetches.\n const redisTtlSeconds = Math.max(opts.ttl * 2 + 60, 120);\n await this.client.set(ck, payload, 'EX', redisTtlSeconds);\n\n // Track key membership in each tag set\n for (const tag of opts.tags) {\n await this.client.sadd(this.tagKey(tag), key);\n }\n }\n\n async invalidate(opts: { key?: string; tag?: string }): Promise<void> {\n if (opts.key) {\n await this.client.del(this.cacheKey(opts.key));\n }\n\n if (opts.tag) {\n const tk = this.tagKey(opts.tag);\n const keys = await this.client.smembers(tk);\n\n if (keys.length > 0) {\n const cacheKeys = keys.map((k) => this.cacheKey(k));\n await this.client.del(cacheKeys);\n }\n\n // Clean up the tag set itself\n await this.client.del(tk);\n }\n }\n}\n","/**\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 */\nexport function stableStringify(value: unknown): string {\n if (value === null || value === undefined) return JSON.stringify(value);\n if (typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) {\n return '[' + value.map((item) => stableStringify(item)).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 pairs.push(JSON.stringify(key) + ':' + stableStringify(obj[key]));\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 */\nexport interface Singleflight {\n do<T>(key: string, fn: () => Promise<T>): Promise<T>;\n}\n\nexport function createSingleflight(): Singleflight {\n const inflight = new Map<string, Promise<unknown>>();\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 const promise = fn().finally(() => {\n inflight.delete(key);\n });\n inflight.set(key, promise);\n return promise;\n },\n };\n}\n","import { createHash } from 'node:crypto';\nimport type { CacheHandler, CacheOptions } from './index';\nimport { stableStringify } from './stable-stringify';\nimport { createSingleflight } from './singleflight';\nimport { addSpanEvent } from '#/server/tracing.js';\n\nconst singleflight = createSingleflight();\n\n/**\n * Generate a SHA-256 cache key from function identity and serialized args.\n */\nfunction defaultKeyGenerator(fnId: string, args: unknown[]): string {\n const raw = fnId + ':' + stableStringify(args);\n return createHash('sha256').update(raw).digest('hex');\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// Counter for generating unique function IDs when no explicit key is provided.\nlet fnIdCounter = 0;\n\n/**\n * Creates a cached wrapper around an async function.\n *\n * - SHA-256 default keys with normalized JSON args\n * - Singleflight: concurrent misses → single execution\n * - SWR: serve stale immediately, background refetch\n * - Tags as string[] or function of args\n * - No ALS dependency\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 handler: CacheHandler\n): (...args: Parameters<Fn>) => Promise<Awaited<ReturnType<Fn>>> {\n const fnId = `timber-cache:${fnIdCounter++}`;\n\n return async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {\n const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId, args);\n\n const cacheStart = performance.now();\n const cached = await handler.get(key);\n\n if (cached && !cached.stale) {\n // Record as OTEL span event on enclosing span (not a child span)\n await addSpanEvent('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 if (cached && cached.stale && opts.staleWhileRevalidate) {\n // Record stale cache hit as OTEL span event\n await addSpanEvent('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 singleflight\n .do(`swr:${key}`, async () => {\n try {\n const fresh = await fn(...args);\n const tags = resolveTags(opts, args);\n await handler.set(key, fresh, { ttl: opts.ttl, tags });\n } catch {\n // Failed refetch — stale entry continues to be served.\n // Error is swallowed per design doc: \"Error is logged.\"\n }\n })\n .catch(() => {\n // Singleflight promise rejection handled — stale continues.\n });\n return cached.value as Awaited<ReturnType<Fn>>;\n }\n\n // Cache miss (or stale without SWR) — execute with singleflight\n const result = await singleflight.do(key, () => fn(...args));\n const tags = resolveTags(opts, args);\n await handler.set(key, result, { ttl: opts.ttl, tags });\n\n // Record cache miss as OTEL span event\n await addSpanEvent('timber.cache.miss', {\n key,\n duration_ms: Math.round(performance.now() - cacheStart),\n });\n\n return result as Awaited<ReturnType<Fn>>;\n };\n}\n\n/**\n * Invalidate cache entries by tag or key.\n */\ncreateCache.invalidate = async function invalidate(\n handler: CacheHandler,\n opts: { key?: string; tag?: string }\n): Promise<void> {\n await handler.invalidate(opts);\n};\n","import { createHash } from 'node:crypto';\nimport type { CacheHandler } from './index';\nimport { stableStringify } from './stable-stringify';\nimport { createSingleflight } from './singleflight';\n\nconst singleflight = createSingleflight();\n\n// Prop names that suggest request-specific data — triggers dev warning for \"use cache\" components.\nconst REQUEST_SPECIFIC_PROPS = new Set([\n 'cookies',\n 'cookie',\n 'session',\n 'sessionId',\n 'token',\n 'authorization',\n 'auth',\n 'headers',\n]);\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport interface RegisterCachedFunctionOptions<Fn extends (...args: any[]) => any> {\n ttl: number;\n id: string;\n tags?: string[] | ((...args: Parameters<Fn>) => string[]);\n /** True when the cached function is a React component (PascalCase name). */\n isComponent?: boolean;\n}\n\n/**\n * Generate a SHA-256 cache key from a stable function ID and serialized args.\n */\nfunction generateKey(id: string, args: unknown[]): string {\n const raw = id + ':' + stableStringify(args);\n return createHash('sha256').update(raw).digest('hex');\n}\n\n/**\n * Resolve tags from 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: RegisterCachedFunctionOptions<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/**\n * Checks if component props contain request-specific keys and emits a dev warning.\n * Only runs when process.env.NODE_ENV !== 'production'.\n */\nfunction warnRequestSpecificProps(id: string, props: unknown): void {\n if (typeof props !== 'object' || props === null) return;\n const keys = Object.keys(props);\n const suspicious = keys.filter((k) => REQUEST_SPECIFIC_PROPS.has(k.toLowerCase()));\n if (suspicious.length > 0) {\n console.warn(\n `[timber] \"use cache\" component ${id} received request-specific props: ${suspicious.join(', ')}. ` +\n `This may serve one user's cached render to another user. ` +\n `Remove request-specific data from props or remove \"use cache\".`\n );\n }\n}\n\n/**\n * Runtime for the \"use cache\" directive transform. Wraps an async function\n * with caching using the same cache handler as timber.cache.\n *\n * The stable `id` (file path + function name) ensures cache keys are consistent\n * across builds. Args/props are hashed with SHA-256 for the per-call key.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function registerCachedFunction<Fn extends (...args: any[]) => Promise<any>>(\n fn: Fn,\n opts: RegisterCachedFunctionOptions<Fn>,\n handler: CacheHandler\n): (...args: Parameters<Fn>) => Promise<Awaited<ReturnType<Fn>>> {\n return async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {\n // Dev-mode warning for components with request-specific props\n if (opts.isComponent && process.env.NODE_ENV !== 'production' && args.length > 0) {\n warnRequestSpecificProps(opts.id, args[0]);\n }\n\n const key = generateKey(opts.id, args);\n const cached = await handler.get(key);\n\n if (cached && !cached.stale) {\n return cached.value as Awaited<ReturnType<Fn>>;\n }\n\n // Cache miss or stale — execute with singleflight\n const result = await singleflight.do(key, () => fn(...args));\n const tags = resolveTags(opts, args);\n await handler.set(key, result, { ttl: opts.ttl, tags });\n return result as Awaited<ReturnType<Fn>>;\n };\n}\n","// @timber-js/app/cache — Caching primitives\n\nexport interface CacheHandler {\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\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport interface CacheOptions<Fn extends (...args: any[]) => any> {\n ttl: number;\n key?: (...args: Parameters<Fn>) => string;\n staleWhileRevalidate?: boolean;\n tags?: string[] | ((...args: Parameters<Fn>) => string[]);\n}\n\nexport interface MemoryCacheHandlerOptions {\n /** Maximum number of entries. Oldest accessed entries are evicted first. Default: 1000. */\n maxSize?: number;\n}\n\nexport class MemoryCacheHandler implements CacheHandler {\n private store = new Map<string, { value: unknown; expiresAt: number; tags: string[] }>();\n private maxSize: number;\n\n constructor(opts?: MemoryCacheHandlerOptions) {\n this.maxSize = opts?.maxSize ?? 1000;\n }\n\n async get(key: string) {\n const entry = this.store.get(key);\n if (!entry) return null;\n\n // Move to end of Map (most recently used) for LRU ordering\n this.store.delete(key);\n this.store.set(key, entry);\n\n const stale = Date.now() > entry.expiresAt;\n return { value: entry.value, stale };\n }\n\n async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }) {\n // If key already exists, delete first to refresh insertion order\n if (this.store.has(key)) {\n this.store.delete(key);\n }\n\n // Evict oldest entries (front of Map) if at capacity\n while (this.store.size >= this.maxSize) {\n const oldest = this.store.keys().next().value;\n if (oldest !== undefined) {\n this.store.delete(oldest);\n } else {\n break;\n }\n }\n\n this.store.set(key, {\n value,\n expiresAt: Date.now() + opts.ttl * 1000,\n tags: opts.tags,\n });\n }\n\n async invalidate(opts: { key?: string; tag?: string }) {\n if (opts.key) {\n this.store.delete(opts.key);\n }\n if (opts.tag) {\n for (const [key, entry] of this.store) {\n if (entry.tags.includes(opts.tag)) {\n this.store.delete(key);\n }\n }\n }\n }\n\n /** Number of entries currently in the cache. */\n get size(): number {\n return this.store.size;\n }\n}\n\nexport { RedisCacheHandler } from './redis-handler';\nexport type { RedisClient } from './redis-handler';\nexport { createCache } from './timber-cache';\nexport { registerCachedFunction } from './register-cached-function';\nexport type { RegisterCachedFunctionOptions } from './register-cached-function';\nexport { stableStringify } from './stable-stringify';\nexport { createSingleflight } from './singleflight';\nexport type { Singleflight } from './singleflight';\n"],"mappings":"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"}
@@ -1,3 +1,5 @@
1
+ "use client";
2
+ "use client";
1
3
  import { Component, createElement } from "react";
2
4
  //#region src/client/error-boundary.tsx
3
5
  /**
@@ -116,6 +118,6 @@ function statusMatches(boundaryStatus, errorStatus) {
116
118
  return boundaryStatus === errorStatus;
117
119
  }
118
120
  //#endregion
119
- export { TimberErrorBoundary as t };
121
+ export { TimberErrorBoundary };
120
122
 
121
- //# sourceMappingURL=error-boundary-dj-WO5uq.js.map
123
+ //# sourceMappingURL=error-boundary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-boundary.js","names":[],"sources":["../../src/client/error-boundary.tsx"],"sourcesContent":["'use client';\n\n/**\n * Framework-injected React error boundary.\n *\n * Catches errors thrown by children and renders a fallback component\n * with the appropriate props based on error type:\n * - DenySignal (4xx) → { status, dangerouslyPassData }\n * - RenderError (5xx) → { error, digest, reset }\n * - Unhandled error → { error, digest: null, reset }\n *\n * The `status` prop controls which errors this boundary catches:\n * - Specific code (e.g. 403) → only that status\n * - Category (400) → any 4xx\n * - Category (500) → any 5xx\n * - Omitted → catches everything (error.tsx behavior)\n *\n * See design/10-error-handling.md §\"Status-Code Files\"\n */\n\nimport { Component, createElement, type ReactNode } from 'react';\n\n// ─── Page Unload Detection ───────────────────────────────────────────────────\n// Track whether the page is being unloaded (user refreshed or navigated away).\n// When this is true, error boundaries suppress activation — the error is from\n// the aborted connection, not an application error.\nlet _isUnloading = false;\nif (typeof window !== 'undefined') {\n window.addEventListener('beforeunload', () => {\n _isUnloading = true;\n });\n window.addEventListener('pagehide', () => {\n _isUnloading = true;\n });\n}\n\n// ─── Digest Types ────────────────────────────────────────────────────────────\n\n/** Structured digest returned by RSC onError for DenySignal. */\ninterface DenyDigest {\n type: 'deny';\n status: number;\n data: unknown;\n}\n\n/** Structured digest returned by RSC onError for RenderError. */\ninterface RenderErrorDigest {\n type: 'render-error';\n code: string;\n data: unknown;\n status: number;\n}\n\n/** Structured digest returned by RSC onError for RedirectSignal. */\ninterface RedirectDigest {\n type: 'redirect';\n location: string;\n status: number;\n}\n\ntype ParsedDigest = DenyDigest | RenderErrorDigest | RedirectDigest;\n\n// ─── Props & State ───────────────────────────────────────────────────────────\n\nexport interface TimberErrorBoundaryProps {\n /** The component to render when an error is caught. */\n fallbackComponent: (...args: unknown[]) => ReactNode;\n /**\n * Status code filter. If set, only catches errors matching this status.\n * 400 = any 4xx, 500 = any 5xx, specific number = exact match.\n */\n status?: number;\n children: ReactNode;\n}\n\ninterface TimberErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport class TimberErrorBoundary extends Component<\n TimberErrorBoundaryProps,\n TimberErrorBoundaryState\n> {\n constructor(props: TimberErrorBoundaryProps) {\n super(props);\n this.state = { hasError: false, error: null };\n }\n\n static getDerivedStateFromError(error: Error): TimberErrorBoundaryState {\n // Suppress error boundaries during page unload (refresh/navigate away).\n // The aborted connection causes React's streaming hydration to error,\n // but the page is about to be replaced — showing an error boundary\n // would be a jarring flash for the user.\n if (_isUnloading) {\n return { hasError: false, error: null };\n }\n return { hasError: true, error };\n }\n\n componentDidUpdate(prevProps: TimberErrorBoundaryProps): void {\n // Reset error state when children change (e.g. client-side navigation).\n // Without this, navigating from one error page to another keeps the\n // stale error — getDerivedStateFromError doesn't re-fire for new children.\n if (this.state.hasError && prevProps.children !== this.props.children) {\n this.setState({ hasError: false, error: null });\n }\n }\n\n /** Reset the error state so children re-render. */\n private reset = () => {\n this.setState({ hasError: false, error: null });\n };\n\n render(): ReactNode {\n if (!this.state.hasError || !this.state.error) {\n return this.props.children;\n }\n\n const error = this.state.error;\n const parsed = parseDigest(error);\n\n // RedirectSignal errors must propagate through all error boundaries\n // so the SSR shell fails and the pipeline catch block can produce a\n // proper HTTP redirect response. See design/04-authorization.md.\n if (parsed?.type === 'redirect') {\n throw error;\n }\n\n // If this boundary has a status filter, check whether the error matches.\n // Non-matching errors re-throw so an outer boundary can catch them.\n if (this.props.status != null) {\n const errorStatus = getErrorStatus(parsed, error);\n if (errorStatus == null || !statusMatches(this.props.status, errorStatus)) {\n // Re-throw: this boundary doesn't handle this error.\n throw error;\n }\n }\n\n // Render the fallback component with the right props shape.\n if (parsed?.type === 'deny') {\n return createElement(this.props.fallbackComponent as never, {\n status: parsed.status,\n dangerouslyPassData: parsed.data,\n });\n }\n\n // 5xx / RenderError / unhandled error\n const digest =\n parsed?.type === 'render-error' ? { code: parsed.code, data: parsed.data } : null;\n\n return createElement(this.props.fallbackComponent as never, {\n error,\n digest,\n reset: this.reset,\n });\n }\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\n/**\n * Parse the structured digest from the error.\n * React sets `error.digest` from the string returned by RSC's onError.\n */\nfunction parseDigest(error: Error): ParsedDigest | null {\n const raw = (error as { digest?: string }).digest;\n if (typeof raw !== 'string') return null;\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {\n return parsed as ParsedDigest;\n }\n } catch {\n // Not JSON — legacy or unknown digest format\n }\n return null;\n}\n\n/**\n * Extract the HTTP status code from a parsed digest or error message.\n * Falls back to message pattern matching for errors without a digest.\n */\nfunction getErrorStatus(parsed: ParsedDigest | null, error: Error): number | null {\n if (parsed?.type === 'deny') return parsed.status;\n if (parsed?.type === 'render-error') return parsed.status;\n if (parsed?.type === 'redirect') return parsed.status;\n\n // Fallback: parse DenySignal message pattern for errors that lost their digest\n const match = error.message.match(/^Access denied with status (\\d+)$/);\n if (match) return parseInt(match[1], 10);\n\n // Unhandled errors are implicitly 500\n return 500;\n}\n\n/**\n * Check whether an error's status matches the boundary's status filter.\n * Category markers (400, 500) match any status in that range.\n */\nfunction statusMatches(boundaryStatus: number, errorStatus: number): boolean {\n // Category catch-all: 400 matches any 4xx, 500 matches any 5xx\n if (boundaryStatus === 400) return errorStatus >= 400 && errorStatus <= 499;\n if (boundaryStatus === 500) return errorStatus >= 500 && errorStatus <= 599;\n // Exact match\n return boundaryStatus === errorStatus;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA0BA,IAAI,eAAe;AACnB,IAAI,OAAO,WAAW,aAAa;AACjC,QAAO,iBAAiB,sBAAsB;AAC5C,iBAAe;GACf;AACF,QAAO,iBAAiB,kBAAkB;AACxC,iBAAe;GACf;;AAiDJ,IAAa,sBAAb,cAAyC,UAGvC;CACA,YAAY,OAAiC;AAC3C,QAAM,MAAM;AACZ,OAAK,QAAQ;GAAE,UAAU;GAAO,OAAO;GAAM;;CAG/C,OAAO,yBAAyB,OAAwC;AAKtE,MAAI,aACF,QAAO;GAAE,UAAU;GAAO,OAAO;GAAM;AAEzC,SAAO;GAAE,UAAU;GAAM;GAAO;;CAGlC,mBAAmB,WAA2C;AAI5D,MAAI,KAAK,MAAM,YAAY,UAAU,aAAa,KAAK,MAAM,SAC3D,MAAK,SAAS;GAAE,UAAU;GAAO,OAAO;GAAM,CAAC;;;CAKnD,cAAsB;AACpB,OAAK,SAAS;GAAE,UAAU;GAAO,OAAO;GAAM,CAAC;;CAGjD,SAAoB;AAClB,MAAI,CAAC,KAAK,MAAM,YAAY,CAAC,KAAK,MAAM,MACtC,QAAO,KAAK,MAAM;EAGpB,MAAM,QAAQ,KAAK,MAAM;EACzB,MAAM,SAAS,YAAY,MAAM;AAKjC,MAAI,QAAQ,SAAS,WACnB,OAAM;AAKR,MAAI,KAAK,MAAM,UAAU,MAAM;GAC7B,MAAM,cAAc,eAAe,QAAQ,MAAM;AACjD,OAAI,eAAe,QAAQ,CAAC,cAAc,KAAK,MAAM,QAAQ,YAAY,CAEvE,OAAM;;AAKV,MAAI,QAAQ,SAAS,OACnB,QAAO,cAAc,KAAK,MAAM,mBAA4B;GAC1D,QAAQ,OAAO;GACf,qBAAqB,OAAO;GAC7B,CAAC;EAIJ,MAAM,SACJ,QAAQ,SAAS,iBAAiB;GAAE,MAAM,OAAO;GAAM,MAAM,OAAO;GAAM,GAAG;AAE/E,SAAO,cAAc,KAAK,MAAM,mBAA4B;GAC1D;GACA;GACA,OAAO,KAAK;GACb,CAAC;;;;;;;AAUN,SAAS,YAAY,OAAmC;CACtD,MAAM,MAAO,MAA8B;AAC3C,KAAI,OAAO,QAAQ,SAAU,QAAO;AACpC,KAAI;EACF,MAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,MAAI,UAAU,OAAO,WAAW,YAAY,OAAO,OAAO,SAAS,SACjE,QAAO;SAEH;AAGR,QAAO;;;;;;AAOT,SAAS,eAAe,QAA6B,OAA6B;AAChF,KAAI,QAAQ,SAAS,OAAQ,QAAO,OAAO;AAC3C,KAAI,QAAQ,SAAS,eAAgB,QAAO,OAAO;AACnD,KAAI,QAAQ,SAAS,WAAY,QAAO,OAAO;CAG/C,MAAM,QAAQ,MAAM,QAAQ,MAAM,oCAAoC;AACtE,KAAI,MAAO,QAAO,SAAS,MAAM,IAAI,GAAG;AAGxC,QAAO;;;;;;AAOT,SAAS,cAAc,gBAAwB,aAA8B;AAE3E,KAAI,mBAAmB,IAAK,QAAO,eAAe,OAAO,eAAe;AACxE,KAAI,mBAAmB,IAAK,QAAO,eAAe,OAAO,eAAe;AAExE,QAAO,mBAAmB"}
@@ -39,7 +39,7 @@ export type UseActionStateReturn<TData> = [
39
39
  * @example
40
40
  * ```tsx
41
41
  * 'use client'
42
- * import { useActionState } from '@timber/app/client'
42
+ * import { useActionState } from '@timber-js/app/client'
43
43
  * import { createTodo } from './actions'
44
44
  *
45
45
  * export function NewTodoForm({ flash }) {
@@ -1,6 +1,7 @@
1
- import { t as TimberErrorBoundary } from "../_chunks/error-boundary-dj-WO5uq.js";
2
- import { i as setSsrData, n as clearSsrData, r as getSsrData, t as useCookie } from "../_chunks/use-cookie-8ZlA0rr3.js";
3
- import { t as getSearchParams$1 } from "../_chunks/registry-DUIpYD_x.js";
1
+ "use client";
2
+ import { TimberErrorBoundary } from "./error-boundary.js";
3
+ import { i as setSsrData, n as clearSsrData, r as getSsrData, t as useCookie } from "../_chunks/use-cookie-HcvNlW4L.js";
4
+ import { t as getSearchParams$1 } from "../_chunks/registry-BfPM41ri.js";
4
5
  import { createContext, createElement, useActionState as useActionState$1, useContext, useEffect, useMemo, useRef, useSyncExternalStore, useTransition } from "react";
5
6
  import { jsxDEV } from "react/jsx-dev-runtime";
6
7
  import { useQueryStates as useQueryStates$1 } from "nuqs";
@@ -51,7 +52,7 @@ var LinkStatusContext = createContext({ pending: false });
51
52
  *
52
53
  * ```tsx
53
54
  * 'use client'
54
- * import { Link, useLinkStatus } from '@timber/app/client'
55
+ * import { Link, useLinkStatus } from '@timber-js/app/client'
55
56
  *
56
57
  * function Hint() {
57
58
  * const { pending } = useLinkStatus()
@@ -218,7 +219,7 @@ function Link({ href, prefetch, scroll, params, searchParams, onNavigate, childr
218
219
  children
219
220
  }, void 0, false, {
220
221
  fileName: _jsxFileName,
221
- lineNumber: 299,
222
+ lineNumber: 301,
222
223
  columnNumber: 17
223
224
  }, this);
224
225
  return /* @__PURE__ */ jsxDEV("a", {
@@ -229,12 +230,12 @@ function Link({ href, prefetch, scroll, params, searchParams, onNavigate, childr
229
230
  children: inner
230
231
  }, void 0, false, {
231
232
  fileName: _jsxFileName,
232
- lineNumber: 304,
233
+ lineNumber: 306,
233
234
  columnNumber: 9
234
235
  }, this) : inner
235
236
  }, void 0, false, {
236
237
  fileName: _jsxFileName,
237
- lineNumber: 302,
238
+ lineNumber: 304,
238
239
  columnNumber: 5
239
240
  }, this);
240
241
  }
@@ -719,7 +720,7 @@ function createRouter(deps) {
719
720
  *
720
721
  * ```tsx
721
722
  * 'use client'
722
- * import { useNavigationPending } from '@timber/app/client'
723
+ * import { useNavigationPending } from '@timber-js/app/client'
723
724
  *
724
725
  * export function NavBar() {
725
726
  * const isPending = useNavigationPending()
@@ -1041,7 +1042,7 @@ function useSelectedLayoutSegments(parallelRouteKey) {
1041
1042
  * @example
1042
1043
  * ```tsx
1043
1044
  * 'use client'
1044
- * import { useActionState } from '@timber/app/client'
1045
+ * import { useActionState } from '@timber-js/app/client'
1045
1046
  * import { createTodo } from './actions'
1046
1047
  *
1047
1048
  * export function NewTodoForm({ flash }) {