@timber-js/app 0.2.0-alpha.1 → 0.2.0-alpha.11

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 (142) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
  3. package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
  4. package/dist/_chunks/debug-gwlJkDuf.js +108 -0
  5. package/dist/_chunks/debug-gwlJkDuf.js.map +1 -0
  6. package/dist/_chunks/{format-DNt20Kt8.js → format-DviM89f0.js} +3 -2
  7. package/dist/_chunks/format-DviM89f0.js.map +1 -0
  8. package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
  9. package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
  10. package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
  11. package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
  12. package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-DIkVh_jG.js} +5 -4
  13. package/dist/_chunks/request-context-DIkVh_jG.js.map +1 -0
  14. package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
  15. package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
  16. package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
  17. package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
  18. package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
  19. package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
  20. package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
  21. package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
  22. package/dist/adapters/nitro.d.ts.map +1 -1
  23. package/dist/adapters/nitro.js.map +1 -1
  24. package/dist/cache/fast-hash.d.ts +22 -0
  25. package/dist/cache/fast-hash.d.ts.map +1 -0
  26. package/dist/cache/index.js +52 -10
  27. package/dist/cache/index.js.map +1 -1
  28. package/dist/cache/register-cached-function.d.ts.map +1 -1
  29. package/dist/cache/timber-cache.d.ts.map +1 -1
  30. package/dist/client/browser-entry.d.ts +1 -2
  31. package/dist/client/browser-entry.d.ts.map +1 -1
  32. package/dist/client/error-boundary.js +1 -1
  33. package/dist/client/index.d.ts +1 -1
  34. package/dist/client/index.d.ts.map +1 -1
  35. package/dist/client/index.js +136 -94
  36. package/dist/client/index.js.map +1 -1
  37. package/dist/client/link.d.ts +12 -9
  38. package/dist/client/link.d.ts.map +1 -1
  39. package/dist/client/navigation-context.d.ts +9 -3
  40. package/dist/client/navigation-context.d.ts.map +1 -1
  41. package/dist/client/router.d.ts.map +1 -1
  42. package/dist/client/segment-context.d.ts +1 -1
  43. package/dist/client/segment-context.d.ts.map +1 -1
  44. package/dist/client/segment-merger.d.ts.map +1 -1
  45. package/dist/client/stale-reload.d.ts.map +1 -1
  46. package/dist/client/top-loader.d.ts.map +1 -1
  47. package/dist/client/transition-root.d.ts +1 -1
  48. package/dist/client/transition-root.d.ts.map +1 -1
  49. package/dist/cookies/index.js +4 -4
  50. package/dist/fonts/local.d.ts +4 -2
  51. package/dist/fonts/local.d.ts.map +1 -1
  52. package/dist/index.d.ts +38 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +255 -21
  55. package/dist/index.js.map +1 -1
  56. package/dist/plugins/build-report.d.ts +11 -1
  57. package/dist/plugins/build-report.d.ts.map +1 -1
  58. package/dist/plugins/chunks.d.ts +9 -1
  59. package/dist/plugins/chunks.d.ts.map +1 -1
  60. package/dist/plugins/entries.d.ts +7 -0
  61. package/dist/plugins/entries.d.ts.map +1 -1
  62. package/dist/plugins/fonts.d.ts +9 -1
  63. package/dist/plugins/fonts.d.ts.map +1 -1
  64. package/dist/plugins/mdx.d.ts +6 -0
  65. package/dist/plugins/mdx.d.ts.map +1 -1
  66. package/dist/plugins/server-bundle.d.ts.map +1 -1
  67. package/dist/routing/index.js +1 -1
  68. package/dist/search-params/index.js +1 -1
  69. package/dist/server/access-gate.d.ts.map +1 -1
  70. package/dist/server/action-client.d.ts.map +1 -1
  71. package/dist/server/debug.d.ts +82 -0
  72. package/dist/server/debug.d.ts.map +1 -0
  73. package/dist/server/deny-renderer.d.ts.map +1 -1
  74. package/dist/server/dev-warnings.d.ts.map +1 -1
  75. package/dist/server/index.js +22 -13
  76. package/dist/server/index.js.map +1 -1
  77. package/dist/server/logger.d.ts.map +1 -1
  78. package/dist/server/pipeline.d.ts.map +1 -1
  79. package/dist/server/primitives.d.ts.map +1 -1
  80. package/dist/server/request-context.d.ts.map +1 -1
  81. package/dist/server/response-cache.d.ts +54 -0
  82. package/dist/server/response-cache.d.ts.map +1 -0
  83. package/dist/server/route-element-builder.d.ts.map +1 -1
  84. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  85. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  86. package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
  87. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  88. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  89. package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
  90. package/dist/server/tracing.d.ts +10 -0
  91. package/dist/server/tracing.d.ts.map +1 -1
  92. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  93. package/package.json +6 -7
  94. package/src/adapters/nitro.ts +6 -1
  95. package/src/cache/fast-hash.ts +34 -0
  96. package/src/cache/register-cached-function.ts +7 -3
  97. package/src/cache/timber-cache.ts +17 -10
  98. package/src/cli.ts +0 -0
  99. package/src/client/browser-entry.ts +16 -24
  100. package/src/client/index.ts +1 -1
  101. package/src/client/link.tsx +97 -34
  102. package/src/client/navigation-context.ts +54 -14
  103. package/src/client/router.ts +4 -6
  104. package/src/client/segment-context.ts +6 -1
  105. package/src/client/segment-merger.ts +2 -8
  106. package/src/client/stale-reload.ts +5 -7
  107. package/src/client/top-loader.tsx +23 -19
  108. package/src/client/transition-root.tsx +7 -1
  109. package/src/fonts/local.ts +7 -3
  110. package/src/index.ts +40 -0
  111. package/src/plugins/build-report.ts +23 -3
  112. package/src/plugins/chunks.ts +9 -1
  113. package/src/plugins/entries.ts +9 -4
  114. package/src/plugins/fonts.ts +171 -19
  115. package/src/plugins/mdx.ts +9 -5
  116. package/src/plugins/server-bundle.ts +4 -0
  117. package/src/server/access-gate.tsx +3 -2
  118. package/src/server/action-client.ts +15 -5
  119. package/src/server/debug.ts +137 -0
  120. package/src/server/deny-renderer.ts +3 -2
  121. package/src/server/dev-warnings.ts +2 -1
  122. package/src/server/logger.ts +4 -3
  123. package/src/server/pipeline.ts +7 -0
  124. package/src/server/primitives.ts +2 -1
  125. package/src/server/request-context.ts +3 -2
  126. package/src/server/response-cache.ts +410 -0
  127. package/src/server/route-element-builder.ts +1 -6
  128. package/src/server/rsc-entry/index.ts +61 -14
  129. package/src/server/rsc-entry/rsc-payload.ts +42 -7
  130. package/src/server/rsc-entry/rsc-stream.ts +10 -5
  131. package/src/server/rsc-entry/ssr-renderer.ts +12 -5
  132. package/src/server/rsc-prop-warnings.ts +3 -1
  133. package/src/server/tracing.ts +23 -0
  134. package/src/server/waituntil-bridge.ts +4 -1
  135. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  136. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
  137. package/dist/client/browser-links.d.ts +0 -32
  138. package/dist/client/browser-links.d.ts.map +0 -1
  139. package/dist/client/link-navigate-interceptor.d.ts +0 -28
  140. package/dist/client/link-navigate-interceptor.d.ts.map +0 -1
  141. package/src/client/browser-links.ts +0 -90
  142. package/src/client/link-navigate-interceptor.tsx +0 -62
@@ -17,17 +17,24 @@ import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js'
17
17
  import { checkAndWarnRscPropError } from '#/server/rsc-prop-warnings.js';
18
18
 
19
19
  import { createDebugChannelSink, isAbortError } from './helpers.js';
20
+ import { isDebug } from '#/server/debug.js';
20
21
 
21
22
  /**
22
23
  * Mutable signal state captured during RSC rendering.
23
24
  *
24
25
  * Signals fire asynchronously via `onError` during stream consumption.
25
26
  * The first signal of each type wins — subsequent signals are ignored.
27
+ *
28
+ * `onSignal` is an optional callback fired when a DenySignal or
29
+ * RedirectSignal is captured. Consumers use it with Promise.race to
30
+ * react immediately instead of polling with setTimeout/queueMicrotask.
26
31
  */
27
32
  export interface RenderSignals {
28
33
  denySignal: DenySignal | null;
29
34
  redirectSignal: RedirectSignal | null;
30
35
  renderError: { error: unknown; status: number } | null;
36
+ /** Callback fired when a redirect or deny signal is captured in onError. */
37
+ onSignal?: () => void;
31
38
  }
32
39
 
33
40
  export interface RscStreamResult {
@@ -66,11 +73,13 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
66
73
  if (isAbortError(error) || req.signal?.aborted) return;
67
74
  if (error instanceof DenySignal) {
68
75
  signals.denySignal = error;
76
+ signals.onSignal?.();
69
77
  // Return structured digest for client-side error boundaries
70
78
  return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
71
79
  }
72
80
  if (error instanceof RedirectSignal) {
73
81
  signals.redirectSignal = error;
82
+ signals.onSignal?.();
74
83
  return JSON.stringify({
75
84
  type: 'redirect',
76
85
  location: error.location,
@@ -97,11 +106,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
97
106
  // directive isn't at the very top of the file, or the component is
98
107
  // re-exported through a barrel file without 'use client'.
99
108
  // See LOCAL-297.
100
- if (
101
- process.env.NODE_ENV !== 'production' &&
102
- error instanceof Error &&
103
- error.message.includes('Invalid hook call')
104
- ) {
109
+ if (isDebug() && error instanceof Error && error.message.includes('Invalid hook call')) {
105
110
  console.error(
106
111
  '[timber] A React hook was called during RSC rendering. This usually means a ' +
107
112
  "'use client' component is being executed as a server component instead of " +
@@ -156,12 +156,19 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
156
156
  try {
157
157
  const ssrResponse = await callSsr(ssrStream, navContext);
158
158
 
159
- // Signal promotion: yield one tick so async component rejections
160
- // propagate to the RSC onError callback, then check if any signals
161
- // were captured during rendering inside Suspense boundaries.
162
- // The Response hasn't been sent yet — it's an unconsumed stream.
159
+ // Signal promotion: check if any signals were captured during rendering
160
+ // inside Suspense boundaries. If no signals are present yet, yield one
161
+ // microtask so async component rejections propagate to the RSC onError
162
+ // callback before we commit the response.
163
+ //
164
+ // When signals are already captured (onSignal already fired), skip the
165
+ // yield entirely — react immediately. Uses queueMicrotask instead of
166
+ // setTimeout(0) for the fallback to avoid yielding to the full event
167
+ // loop (timers phase).
163
168
  // See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
164
- await new Promise<void>((r) => setTimeout(r, 0));
169
+ if (!signals.redirectSignal && !signals.denySignal && !signals.renderError) {
170
+ await new Promise<void>((r) => queueMicrotask(r));
171
+ }
165
172
 
166
173
  const promoted = checkCapturedSignals(/* skipHandledDeny */ true);
167
174
  if (promoted) {
@@ -16,6 +16,8 @@
16
16
  * Task: TIM-358
17
17
  */
18
18
 
19
+ import { isDebug } from './debug.js';
20
+
19
21
  // ─── Types ────────────────────────────────────────────────────────────────
20
22
 
21
23
  export interface NonSerializableTypeInfo {
@@ -165,7 +167,7 @@ export function formatRscPropWarning(
165
167
  * @returns true if a warning was emitted
166
168
  */
167
169
  export function checkAndWarnRscPropError(error: unknown, requestPath: string): boolean {
168
- if (process.env.NODE_ENV === 'production') return false;
170
+ if (!isDebug()) return false;
169
171
  if (!(error instanceof Error)) return false;
170
172
 
171
173
  const info = detectNonSerializableType(error.message);
@@ -252,6 +252,29 @@ export async function addSpanEvent(
252
252
  }
253
253
  }
254
254
 
255
+ /**
256
+ * Fire-and-forget span event — no await, no microtask overhead.
257
+ *
258
+ * Used on the cache hot path where awaiting addSpanEvent creates an
259
+ * unnecessary microtask per cache operation. If OTEL is not loaded yet,
260
+ * the event is silently dropped (acceptable for diagnostics).
261
+ *
262
+ * See TIM-370 for perf motivation.
263
+ */
264
+ export function addSpanEventSync(
265
+ name: string,
266
+ attributes?: Record<string, string | number | boolean>
267
+ ): void {
268
+ // Fast path: if OTEL API hasn't been loaded yet, skip entirely.
269
+ // _otelApi is undefined (not yet loaded), null (failed to load), or the module.
270
+ if (!_otelApi) return;
271
+
272
+ const activeSpan = _otelApi.trace.getActiveSpan();
273
+ if (activeSpan) {
274
+ activeSpan.addEvent(name, attributes);
275
+ }
276
+ }
277
+
255
278
  /**
256
279
  * Try to extract the OTEL trace ID from the current active span context.
257
280
  * Returns undefined if OTEL is not active or no span exists.
@@ -18,7 +18,10 @@ import { waitUntilAls } from './als-registry.js';
18
18
  * Called by generated entry points (Nitro node-server/bun, Cloudflare)
19
19
  * to bind the platform's lifecycle extension for the request duration.
20
20
  */
21
- export function runWithWaitUntil<T>(waitUntilFn: (promise: Promise<unknown>) => void, fn: () => T): T {
21
+ export function runWithWaitUntil<T>(
22
+ waitUntilFn: (promise: Promise<unknown>) => void,
23
+ fn: () => T
24
+ ): T {
22
25
  return waitUntilAls.run(waitUntilFn, fn);
23
26
  }
24
27
 
@@ -1 +0,0 @@
1
- {"version":3,"file":"format-DNt20Kt8.js","names":[],"sources":["../../src/server/dev-warnings.ts","../../src/utils/format.ts"],"sourcesContent":["/**\n * Dev-mode warnings for common timber.js misuse patterns.\n *\n * These fire in development only and are stripped from production builds.\n * Each warning targets a specific misuse identified during design review.\n *\n * Warnings are deduplicated by warningId:filePath:line so the same warning\n * is only emitted once per dev session (per unique source location).\n *\n * Warnings are written to stderr and, when a Vite dev server is available,\n * forwarded to the browser console via Vite's WebSocket.\n *\n * See design/21-dev-server.md §\"Dev-Mode Warnings\"\n * See design/11-platform.md §\"Dev Mode\"\n */\n\nimport type { ViteDevServer } from 'vite';\n\n// ─── Warning IDs ───────────────────────────────────────────────────────────\n\nexport const WarningId = {\n SUSPENSE_WRAPS_CHILDREN: 'SUSPENSE_WRAPS_CHILDREN',\n DENY_IN_SUSPENSE: 'DENY_IN_SUSPENSE',\n REDIRECT_IN_SUSPENSE: 'REDIRECT_IN_SUSPENSE',\n REDIRECT_IN_ACCESS: 'REDIRECT_IN_ACCESS',\n STATIC_REQUEST_API: 'STATIC_REQUEST_API',\n CACHE_REQUEST_PROPS: 'CACHE_REQUEST_PROPS',\n SLOW_SLOT_NO_SUSPENSE: 'SLOW_SLOT_NO_SUSPENSE',\n} as const;\n\nexport type WarningId = (typeof WarningId)[keyof typeof WarningId];\n\n// ─── Configuration ──────────────────────────────────────────────────────────\n\n/** Configuration for dev warning behavior. */\nexport interface DevWarningConfig {\n /** Threshold in ms for \"slow slot\" warnings. Default: 200. */\n slowSlotThresholdMs?: number;\n}\n\n// ─── Deduplication & Server ─────────────────────────────────────────────────\n\nconst _emitted = new Set<string>();\n\n/** Vite dev server for forwarding warnings to browser console. */\nlet _viteServer: ViteDevServer | null = null;\n\n/**\n * Register the Vite dev server for browser console forwarding.\n * Called by timber-dev-server during configureServer.\n */\nexport function setViteServer(server: ViteDevServer | null): void {\n _viteServer = server;\n}\n\nfunction isDev(): boolean {\n return process.env.NODE_ENV !== 'production';\n}\n\n/**\n * Emit a warning only once per dedup key.\n *\n * Writes to stderr and forwards to browser console via Vite WebSocket.\n * Returns true if emitted (not deduplicated).\n */\nfunction emitOnce(\n warningId: WarningId,\n location: string,\n level: 'warn' | 'error',\n message: string\n): boolean {\n if (!isDev()) return false;\n\n const dedupKey = `${warningId}:${location}`;\n if (_emitted.has(dedupKey)) return false;\n _emitted.add(dedupKey);\n\n // Write to stderr\n const prefix = level === 'error' ? '\\x1b[31m[timber]\\x1b[0m' : '\\x1b[33m[timber]\\x1b[0m';\n process.stderr.write(`${prefix} ${message}\\n`);\n\n // Forward to browser console via Vite WebSocket\n if (_viteServer?.hot) {\n _viteServer.hot.send('timber:dev-warning', {\n warningId,\n level,\n message: `[timber] ${message}`,\n });\n }\n\n return true;\n}\n\n// ─── Warning Functions ──────────────────────────────────────────────────────\n\n/**\n * Warn when a layout wraps {children} in <Suspense>.\n *\n * This defers the page content — the primary resource — behind a fallback.\n * The page's data fetches won't affect the HTTP status code because they\n * resolve after onShellReady. If the page calls deny(404), the status code\n * is already committed as 200.\n *\n * @param layoutFile - Relative path to the layout file (e.g., \"app/(dashboard)/layout.tsx\")\n */\nexport function warnSuspenseWrappingChildren(layoutFile: string): void {\n emitOnce(\n WarningId.SUSPENSE_WRAPS_CHILDREN,\n layoutFile,\n 'warn',\n `Layout at ${layoutFile} wraps {children} in <Suspense>. ` +\n 'This prevents child pages from setting HTTP status codes. ' +\n 'Use useNavigationPending() for loading states instead.'\n );\n}\n\n/**\n * Warn when deny() is called inside a Suspense boundary.\n *\n * After the shell has flushed and the status code is committed, deny()\n * cannot change the HTTP response. The signal will be caught by the nearest\n * error boundary instead of producing a correct status code.\n *\n * @param file - Relative path to the file\n * @param line - Line number where deny() was called\n */\nexport function warnDenyInSuspense(file: string, line?: number): void {\n const location = line ? `${file}:${line}` : file;\n emitOnce(\n WarningId.DENY_IN_SUSPENSE,\n location,\n 'error',\n `deny() called inside <Suspense> at ${location}. ` +\n 'The HTTP status is already committed — this will trigger an error boundary with a 200 status. ' +\n 'Move deny() outside <Suspense> for correct HTTP semantics.'\n );\n}\n\n/**\n * Warn when redirect() is called inside a Suspense boundary.\n *\n * This will perform a client-side navigation instead of an HTTP redirect.\n *\n * @param file - Relative path to the file\n * @param line - Line number where redirect() was called\n */\nexport function warnRedirectInSuspense(file: string, line?: number): void {\n const location = line ? `${file}:${line}` : file;\n emitOnce(\n WarningId.REDIRECT_IN_SUSPENSE,\n location,\n 'error',\n `redirect() called inside <Suspense> at ${location}. ` +\n 'This will perform a client-side navigation instead of an HTTP redirect.'\n );\n}\n\n/**\n * Warn when redirect() is called in a slot's access.ts.\n *\n * Slots use deny() for graceful degradation. Redirecting from a slot would\n * redirect the entire page, breaking the contract that slot failure is\n * isolated to the slot.\n *\n * @param accessFile - Relative path to the access.ts file\n * @param line - Line number where redirect() was called\n */\nexport function warnRedirectInAccess(accessFile: string, line?: number): void {\n const location = line ? `${accessFile}:${line}` : accessFile;\n emitOnce(\n WarningId.REDIRECT_IN_ACCESS,\n location,\n 'error',\n `redirect() called in access.ts at ${location}. ` +\n 'Only deny() is valid in slot access checks. ' +\n 'Use deny() to block access or move redirect() to middleware.ts.'\n );\n}\n\n/**\n * Warn when cookies() or headers() is called during a static build.\n *\n * In output: 'static' mode, there is no per-request context — these APIs\n * read build-time values only. This is almost always a mistake.\n *\n * @param api - The dynamic API name (\"cookies\" or \"headers\")\n * @param file - Relative path to the file calling the API\n */\nexport function warnStaticRequestApi(api: 'cookies' | 'headers', file: string): void {\n emitOnce(\n WarningId.STATIC_REQUEST_API,\n `${api}:${file}`,\n 'error',\n `${api}() called during static generation of ${file}. ` +\n 'Dynamic request APIs are not available during prerendering.'\n );\n}\n\n/**\n * Warn when a \"use cache\" component receives request-specific props.\n *\n * Cached components should not depend on per-request data — a userId or\n * sessionId in the props means the cache will either be ineffective\n * (key per user) or dangerous (serve one user's data to another).\n *\n * @param componentName - Name of the cached component\n * @param propName - Name of the suspicious prop\n * @param file - Relative path to the component file\n * @param line - Line number\n */\nexport function warnCacheRequestProps(\n componentName: string,\n propName: string,\n file: string,\n line?: number\n): void {\n const location = line ? `${file}:${line}` : file;\n emitOnce(\n WarningId.CACHE_REQUEST_PROPS,\n `${componentName}:${propName}:${location}`,\n 'warn',\n `Cached component ${componentName} receives prop \"${propName}\" which appears request-specific. ` +\n 'Cached components should not depend on per-request data.'\n );\n}\n\n/**\n * Warn when a parallel slot resolves slowly without a <Suspense> wrapper.\n *\n * A slow slot without Suspense blocks onShellReady — and therefore the\n * status code commit — for the entire page. Wrapping it in <Suspense>\n * lets the shell flush without waiting for the slot.\n *\n * @param slotName - The slot name (e.g., \"@admin\")\n * @param durationMs - How long the slot took to resolve\n */\nexport function warnSlowSlotWithoutSuspense(slotName: string, durationMs: number): void {\n emitOnce(\n WarningId.SLOW_SLOT_NO_SUSPENSE,\n slotName,\n 'warn',\n `Slot ${slotName} resolved in ${durationMs}ms and is not wrapped in <Suspense>. ` +\n 'Consider wrapping to avoid blocking the flush.'\n );\n}\n\n// ─── Legacy aliases ─────────────────────────────────────────────────────────\n\n/** @deprecated Use warnStaticRequestApi instead */\nexport const warnDynamicApiInStaticBuild = warnStaticRequestApi;\n\n/** @deprecated Use warnRedirectInAccess instead */\nexport function warnRedirectInSlotAccess(slotName: string): void {\n warnRedirectInAccess(`${slotName}/access.ts`);\n}\n\n/** @deprecated Use warnDenyInSuspense / warnRedirectInSuspense instead */\nexport function warnDenyAfterFlush(signal: 'deny' | 'redirect'): void {\n if (signal === 'deny') {\n warnDenyInSuspense('unknown');\n } else {\n warnRedirectInSuspense('unknown');\n }\n}\n\n// ─── Testing ────────────────────────────────────────────────────────────────\n\n/**\n * Reset emitted warnings. For testing only.\n * @internal\n */\nexport function _resetWarnings(): void {\n _emitted.clear();\n}\n\n/**\n * Get the set of emitted dedup keys. For testing only.\n * @internal\n */\nexport function _getEmitted(): ReadonlySet<string> {\n return _emitted;\n}\n","/**\n * Shared formatting utilities.\n */\n\n/** Format a byte count as a human-readable string (e.g. \"1.50 kB\"). */\nexport function formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} kB`;\n return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;\n}\n"],"mappings":";AAoBA,IAAa,YAAY;CACvB,yBAAyB;CACzB,kBAAkB;CAClB,sBAAsB;CACtB,oBAAoB;CACpB,oBAAoB;CACpB,qBAAqB;CACrB,uBAAuB;CACxB;AAcD,IAAM,2BAAW,IAAI,KAAa;;AAGlC,IAAI,cAAoC;;;;;AAMxC,SAAgB,cAAc,QAAoC;AAChE,eAAc;;AAGhB,SAAS,QAAiB;AACxB,QAAA,QAAA,IAAA,aAAgC;;;;;;;;AASlC,SAAS,SACP,WACA,UACA,OACA,SACS;AACT,KAAI,CAAC,OAAO,CAAE,QAAO;CAErB,MAAM,WAAW,GAAG,UAAU,GAAG;AACjC,KAAI,SAAS,IAAI,SAAS,CAAE,QAAO;AACnC,UAAS,IAAI,SAAS;CAGtB,MAAM,SAAS,UAAU,UAAU,4BAA4B;AAC/D,SAAQ,OAAO,MAAM,GAAG,OAAO,GAAG,QAAQ,IAAI;AAG9C,KAAI,aAAa,IACf,aAAY,IAAI,KAAK,sBAAsB;EACzC;EACA;EACA,SAAS,YAAY;EACtB,CAAC;AAGJ,QAAO;;;;;;;;;;;;AAeT,SAAgB,6BAA6B,YAA0B;AACrE,UACE,UAAU,yBACV,YACA,QACA,aAAa,WAAW,mJAGzB;;;;;;;;;;;;AAaH,SAAgB,mBAAmB,MAAc,MAAqB;CACpE,MAAM,WAAW,OAAO,GAAG,KAAK,GAAG,SAAS;AAC5C,UACE,UAAU,kBACV,UACA,SACA,sCAAsC,SAAS,4JAGhD;;;;;;;;;;AAWH,SAAgB,uBAAuB,MAAc,MAAqB;CACxE,MAAM,WAAW,OAAO,GAAG,KAAK,GAAG,SAAS;AAC5C,UACE,UAAU,sBACV,UACA,SACA,0CAA0C,SAAS,2EAEpD;;;;;;;;;;;;AAaH,SAAgB,qBAAqB,YAAoB,MAAqB;CAC5E,MAAM,WAAW,OAAO,GAAG,WAAW,GAAG,SAAS;AAClD,UACE,UAAU,oBACV,UACA,SACA,qCAAqC,SAAS,+GAG/C;;;;;;;;;;;AAYH,SAAgB,qBAAqB,KAA4B,MAAoB;AACnF,UACE,UAAU,oBACV,GAAG,IAAI,GAAG,QACV,SACA,GAAG,IAAI,wCAAwC,KAAK,+DAErD;;;;;;;;;;;;;;AAeH,SAAgB,sBACd,eACA,UACA,MACA,MACM;CACN,MAAM,WAAW,OAAO,GAAG,KAAK,GAAG,SAAS;AAC5C,UACE,UAAU,qBACV,GAAG,cAAc,GAAG,SAAS,GAAG,YAChC,QACA,oBAAoB,cAAc,kBAAkB,SAAS,4FAE9D;;;;;;;;;;;;AAaH,SAAgB,4BAA4B,UAAkB,YAA0B;AACtF,UACE,UAAU,uBACV,UACA,QACA,QAAQ,SAAS,eAAe,WAAW,qFAE5C;;;AAMH,IAAa,8BAA8B;;AAG3C,SAAgB,yBAAyB,UAAwB;AAC/D,sBAAqB,GAAG,SAAS,YAAY;;;AAI/C,SAAgB,mBAAmB,QAAmC;AACpE,KAAI,WAAW,OACb,oBAAmB,UAAU;KAE7B,wBAAuB,UAAU;;;;;;;;AChQrC,SAAgB,WAAW,OAAuB;AAChD,KAAI,QAAQ,KAAM,QAAO,GAAG,MAAM;AAClC,KAAI,QAAQ,OAAO,KAAM,QAAO,IAAI,QAAQ,MAAM,QAAQ,EAAE,CAAC;AAC7D,QAAO,IAAI,SAAS,OAAO,OAAO,QAAQ,EAAE,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"request-context-CRj2Zh1E.js","names":[],"sources":["../../src/server/request-context.ts"],"sourcesContent":["/**\n * Request Context — per-request ALS store for headers() and cookies().\n *\n * Follows the same pattern as tracing.ts: a module-level AsyncLocalStorage\n * instance, public accessor functions that throw outside request scope,\n * and a framework-internal `runWithRequestContext()` to establish scope.\n *\n * See design/04-authorization.md §\"AccessContext does not include cookies or headers\"\n * and design/11-platform.md §\"AsyncLocalStorage\".\n * See design/29-cookies.md for cookie mutation semantics.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\nimport type { Routes } from '#/index.js';\nimport { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';\n\n// Re-export the ALS for framework-internal consumers that need direct access.\nexport { requestContextAls };\n\n// No fallback needed — we use enterWith() instead of run() to ensure\n// the ALS context persists for the entire request lifecycle including\n// async stream consumption by React's renderToReadableStream.\n\n// ─── Cookie Signing Secrets ──────────────────────────────────────────────\n\n/**\n * Module-level cookie signing secrets. Index 0 is the newest (used for signing).\n * All entries are tried for verification (key rotation support).\n *\n * Set by the framework at startup via `setCookieSecrets()`.\n * See design/29-cookies.md §\"Signed Cookies\"\n */\nlet _cookieSecrets: string[] = [];\n\n/**\n * Configure the cookie signing secrets.\n *\n * Called by the framework during server initialization with values from\n * `cookies.secret` or `cookies.secrets` in timber.config.ts.\n *\n * The first secret (index 0) is used for signing new cookies.\n * All secrets are tried for verification (supports key rotation).\n */\nexport function setCookieSecrets(secrets: string[]): void {\n _cookieSecrets = secrets.filter(Boolean);\n}\n\n// ─── Public API ───────────────────────────────────────────────────────────\n\n/**\n * Returns a read-only view of the current request's headers.\n *\n * Available in middleware, access checks, server components, and server actions.\n * Throws if called outside a request context (security principle #2: no global fallback).\n */\nexport function headers(): ReadonlyHeaders {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] headers() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n return store.headers;\n}\n\n/**\n * Returns a cookie accessor for the current request.\n *\n * Available in middleware, access checks, server components, and server actions.\n * Throws if called outside a request context (security principle #2: no global fallback).\n *\n * Read methods (.get, .has, .getAll) are always available and reflect\n * read-your-own-writes from .set() calls in the same request.\n *\n * Mutation methods (.set, .delete, .clear) are only available in mutable\n * contexts (middleware.ts, server actions, route.ts handlers). Calling them\n * in read-only contexts (access.ts, server components) throws.\n *\n * See design/29-cookies.md\n */\nexport function cookies(): RequestCookies {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] cookies() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n\n // Parse cookies lazily on first access\n if (!store.parsedCookies) {\n store.parsedCookies = parseCookieHeader(store.cookieHeader);\n }\n\n const map = store.parsedCookies;\n return {\n get(name: string): string | undefined {\n return map.get(name);\n },\n has(name: string): boolean {\n return map.has(name);\n },\n getAll(): Array<{ name: string; value: string }> {\n return Array.from(map.entries()).map(([name, value]) => ({ name, value }));\n },\n get size(): number {\n return map.size;\n },\n\n getSigned(name: string): string | undefined {\n const raw = map.get(name);\n if (!raw || _cookieSecrets.length === 0) return undefined;\n return verifySignedCookie(raw, _cookieSecrets);\n },\n\n set(name: string, value: string, options?: CookieOptions): void {\n assertMutable(store, 'set');\n if (store.flushed) {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n `[timber] warn: cookies().set('${name}') called after response headers were committed.\\n` +\n ` The cookie will NOT be sent. Move cookie mutations to middleware.ts, a server action,\\n` +\n ` or a route.ts handler.`\n );\n }\n return;\n }\n let storedValue = value;\n if (options?.signed) {\n if (_cookieSecrets.length === 0) {\n throw new Error(\n `[timber] cookies().set('${name}', ..., { signed: true }) requires ` +\n `cookies.secret or cookies.secrets in timber.config.ts.`\n );\n }\n storedValue = signCookieValue(value, _cookieSecrets[0]);\n }\n const opts = { ...DEFAULT_COOKIE_OPTIONS, ...options };\n store.cookieJar.set(name, { name, value: storedValue, options: opts });\n // Read-your-own-writes: update the parsed cookies map with the signed value\n // so getSigned() can verify it in the same request\n map.set(name, storedValue);\n },\n\n delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void {\n assertMutable(store, 'delete');\n if (store.flushed) {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n `[timber] warn: cookies().delete('${name}') called after response headers were committed.\\n` +\n ` The cookie will NOT be deleted. Move cookie mutations to middleware.ts, a server action,\\n` +\n ` or a route.ts handler.`\n );\n }\n return;\n }\n const opts: CookieOptions = {\n ...DEFAULT_COOKIE_OPTIONS,\n ...options,\n maxAge: 0,\n expires: new Date(0),\n };\n store.cookieJar.set(name, { name, value: '', options: opts });\n // Remove from read view\n map.delete(name);\n },\n\n clear(): void {\n assertMutable(store, 'clear');\n if (store.flushed) return;\n // Delete every incoming cookie\n for (const name of Array.from(map.keys())) {\n store.cookieJar.set(name, {\n name,\n value: '',\n options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0, expires: new Date(0) },\n });\n }\n map.clear();\n },\n\n toString(): string {\n return Array.from(map.entries())\n .map(([name, value]) => `${name}=${value}`)\n .join('; ');\n },\n };\n}\n\n/**\n * Returns a Promise resolving to the current request's search params.\n *\n * In `page.tsx`, `middleware.ts`, and `access.ts` the framework pre-parses the\n * route's `search-params.ts` definition and the Promise resolves to the typed\n * object. In all other server component contexts it resolves to raw\n * `URLSearchParams`.\n *\n * Returned as a Promise to match the `params` prop convention and to allow\n * future partial pre-rendering support where param resolution may be deferred.\n *\n * Throws if called outside a request context.\n */\nexport function searchParams<R extends keyof Routes>(): Promise<Routes[R]['searchParams']>;\nexport function searchParams(): Promise<URLSearchParams | Record<string, unknown>>;\nexport function searchParams(): Promise<URLSearchParams | Record<string, unknown>> {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] searchParams() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n return store.searchParamsPromise;\n}\n\n/**\n * Replace the search params Promise for the current request with one that\n * resolves to the typed parsed result from the route's search-params.ts.\n * Called by the framework before rendering the page — not for app code.\n */\nexport function setParsedSearchParams(parsed: Record<string, unknown>): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.searchParamsPromise = Promise.resolve(parsed);\n }\n}\n\n// ─── Types ────────────────────────────────────────────────────────────────\n\n/**\n * Read-only Headers interface. The standard Headers class is mutable;\n * this type narrows it to read-only methods. The underlying object is\n * still a Headers instance, but user code should not mutate it.\n */\nexport type ReadonlyHeaders = Pick<\n Headers,\n 'get' | 'has' | 'entries' | 'keys' | 'values' | 'forEach' | typeof Symbol.iterator\n>;\n\n/** Options for setting a cookie. See design/29-cookies.md. */\nexport interface CookieOptions {\n /** Domain scope. Default: omitted (current domain only). */\n domain?: string;\n /** URL path scope. Default: '/'. */\n path?: string;\n /** Expiration date. Mutually exclusive with maxAge. */\n expires?: Date;\n /** Max age in seconds. Mutually exclusive with expires. */\n maxAge?: number;\n /** Prevent client-side JS access. Default: true. */\n httpOnly?: boolean;\n /** Only send over HTTPS. Default: true. */\n secure?: boolean;\n /** Cross-site request policy. Default: 'lax'. */\n sameSite?: 'strict' | 'lax' | 'none';\n /** Partitioned (CHIPS) — isolate cookie per top-level site. Default: false. */\n partitioned?: boolean;\n /**\n * Sign the cookie value with HMAC-SHA256 for integrity verification.\n * Requires `cookies.secret` or `cookies.secrets` in timber.config.ts.\n * See design/29-cookies.md §\"Signed Cookies\".\n */\n signed?: boolean;\n}\n\nconst DEFAULT_COOKIE_OPTIONS: CookieOptions = {\n path: '/',\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n};\n\n/**\n * Cookie accessor returned by `cookies()`.\n *\n * Read methods are always available. Mutation methods throw in read-only\n * contexts (access.ts, server components).\n */\nexport interface RequestCookies {\n /** Get a cookie value by name. Returns undefined if not present. */\n get(name: string): string | undefined;\n /** Check if a cookie exists. */\n has(name: string): boolean;\n /** Get all cookies as an array of { name, value } pairs. */\n getAll(): Array<{ name: string; value: string }>;\n /** Number of cookies. */\n readonly size: number;\n /**\n * Get a signed cookie value, verifying its HMAC-SHA256 signature.\n * Returns undefined if the cookie is missing, the signature is invalid,\n * or no secrets are configured. Never throws.\n *\n * See design/29-cookies.md §\"Signed Cookies\"\n */\n getSigned(name: string): string | undefined;\n /** Set a cookie. Only available in mutable contexts (middleware, actions, route handlers). */\n set(name: string, value: string, options?: CookieOptions): void;\n /** Delete a cookie. Only available in mutable contexts. */\n delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void;\n /** Delete all cookies. Only available in mutable contexts. */\n clear(): void;\n /** Serialize cookies as a Cookie header string. */\n toString(): string;\n}\n\n// ─── Framework-Internal Helpers ───────────────────────────────────────────\n\n/**\n * Run a callback within a request context. Used by the pipeline to establish\n * per-request ALS scope so that `headers()` and `cookies()` work.\n *\n * @param req - The incoming Request object.\n * @param fn - The function to run within the request context.\n */\nexport function runWithRequestContext<T>(req: Request, fn: () => T): T {\n const originalCopy = new Headers(req.headers);\n const store: RequestContextStore = {\n headers: freezeHeaders(req.headers),\n originalHeaders: originalCopy,\n cookieHeader: req.headers.get('cookie') ?? '',\n searchParamsPromise: Promise.resolve(new URL(req.url).searchParams),\n cookieJar: new Map(),\n flushed: false,\n mutableContext: false,\n };\n return requestContextAls.run(store, fn);\n}\n\n/**\n * Enable cookie mutation for the current context. Called by the framework\n * when entering middleware.ts, server actions, or route.ts handlers.\n *\n * See design/29-cookies.md §\"Context Tracking\"\n */\nexport function setMutableCookieContext(mutable: boolean): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.mutableContext = mutable;\n }\n}\n\n/**\n * Mark the response as flushed (headers committed). After this point,\n * cookie mutations log a warning instead of throwing.\n *\n * See design/29-cookies.md §\"Streaming Constraint: Post-Flush Cookie Warning\"\n */\nexport function markResponseFlushed(): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.flushed = true;\n }\n}\n\n/**\n * Collect all Set-Cookie headers from the cookie jar.\n * Called by the framework at flush time to apply cookies to the response.\n *\n * Returns an array of serialized Set-Cookie header values.\n */\nexport function getSetCookieHeaders(): string[] {\n const store = requestContextAls.getStore();\n if (!store) return [];\n return Array.from(store.cookieJar.values()).map(serializeCookieEntry);\n}\n\n/**\n * Apply middleware-injected request headers to the current request context.\n *\n * Called by the pipeline after middleware.ts runs. Merges overlay headers\n * on top of the original request headers so downstream code (access.ts,\n * server components, server actions) sees them via `headers()`.\n *\n * The original request headers are never mutated — a new frozen Headers\n * object is created with the overlay applied on top.\n *\n * See design/07-routing.md §\"Request Header Injection\"\n */\nexport function applyRequestHeaderOverlay(overlay: Headers): void {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error('[timber] applyRequestHeaderOverlay() called outside of a request context.');\n }\n\n // Check if the overlay has any headers — skip if empty\n let hasOverlay = false;\n overlay.forEach(() => {\n hasOverlay = true;\n });\n if (!hasOverlay) return;\n\n // Merge: start with original headers, overlay on top\n const merged = new Headers(store.originalHeaders);\n overlay.forEach((value, key) => {\n merged.set(key, value);\n });\n store.headers = freezeHeaders(merged);\n}\n\n// ─── Read-Only Headers ────────────────────────────────────────────────────\n\nconst MUTATING_METHODS = new Set(['set', 'append', 'delete']);\n\n/**\n * Wrap a Headers object in a Proxy that throws on mutating methods.\n * Object.freeze doesn't work on Headers (native internal slots), so we\n * intercept property access and reject set/append/delete at runtime.\n *\n * Read methods (get, has, entries, etc.) must be bound to the underlying\n * Headers instance because they access private #headersList slots.\n */\nfunction freezeHeaders(source: Headers): Headers {\n const copy = new Headers(source);\n return new Proxy(copy, {\n get(target, prop) {\n if (typeof prop === 'string' && MUTATING_METHODS.has(prop)) {\n return () => {\n throw new Error(\n `[timber] headers() returns a read-only Headers object. ` +\n `Calling .${prop}() is not allowed. ` +\n `Use ctx.requestHeaders in middleware to inject headers for downstream components.`\n );\n };\n }\n const value = Reflect.get(target, prop);\n // Bind methods to the real Headers instance so private slot access works\n if (typeof value === 'function') {\n return value.bind(target);\n }\n return value;\n },\n });\n}\n\n// ─── Cookie Helpers ───────────────────────────────────────────────────────\n\n/** Throw if cookie mutation is attempted in a read-only context. */\nfunction assertMutable(store: RequestContextStore, method: string): void {\n if (!store.mutableContext) {\n throw new Error(\n `[timber] cookies().${method}() cannot be called in this context.\\n` +\n ` Set cookies in middleware.ts, server actions, or route.ts handlers.`\n );\n }\n}\n\n/**\n * Parse a Cookie header string into a Map of name → value pairs.\n * Follows RFC 6265 §4.2.1: cookies are semicolon-separated key=value pairs.\n */\nfunction parseCookieHeader(header: string): Map<string, string> {\n const map = new Map<string, string>();\n if (!header) return map;\n\n for (const pair of header.split(';')) {\n const eqIndex = pair.indexOf('=');\n if (eqIndex === -1) continue;\n const name = pair.slice(0, eqIndex).trim();\n const value = pair.slice(eqIndex + 1).trim();\n if (name) {\n map.set(name, value);\n }\n }\n\n return map;\n}\n\n// ─── Cookie Signing ──────────────────────────────────────────────────────\n\n/**\n * Sign a cookie value with HMAC-SHA256.\n * Returns `value.hex_signature`.\n */\nfunction signCookieValue(value: string, secret: string): string {\n const signature = createHmac('sha256', secret).update(value).digest('hex');\n return `${value}.${signature}`;\n}\n\n/**\n * Verify a signed cookie value against an array of secrets.\n * Returns the original value if any secret produces a matching signature,\n * or undefined if none match. Uses timing-safe comparison.\n *\n * The signed format is `value.hex_signature` — split at the last `.`.\n */\nfunction verifySignedCookie(raw: string, secrets: string[]): string | undefined {\n const lastDot = raw.lastIndexOf('.');\n if (lastDot <= 0 || lastDot === raw.length - 1) return undefined;\n\n const value = raw.slice(0, lastDot);\n const signature = raw.slice(lastDot + 1);\n\n // Hex-encoded SHA-256 is always 64 chars\n if (signature.length !== 64) return undefined;\n\n const signatureBuffer = Buffer.from(signature, 'hex');\n // If the hex decode produced fewer bytes, the signature was not valid hex\n if (signatureBuffer.length !== 32) return undefined;\n\n for (const secret of secrets) {\n const expected = createHmac('sha256', secret).update(value).digest();\n if (timingSafeEqual(expected, signatureBuffer)) {\n return value;\n }\n }\n return undefined;\n}\n\n/** Serialize a CookieEntry into a Set-Cookie header value. */\nfunction serializeCookieEntry(entry: CookieEntry): string {\n const parts = [`${entry.name}=${entry.value}`];\n const opts = entry.options;\n\n if (opts.domain) parts.push(`Domain=${opts.domain}`);\n if (opts.path) parts.push(`Path=${opts.path}`);\n if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);\n if (opts.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`);\n if (opts.httpOnly) parts.push('HttpOnly');\n if (opts.secure) parts.push('Secure');\n if (opts.sameSite) {\n parts.push(`SameSite=${opts.sameSite.charAt(0).toUpperCase()}${opts.sameSite.slice(1)}`);\n }\n if (opts.partitioned) parts.push('Partitioned');\n\n return parts.join('; ');\n}\n"],"mappings":"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"}
@@ -1,32 +0,0 @@
1
- /**
2
- * Link click interception and hover prefetch for SPA navigation.
3
- *
4
- * Handles click events on <a data-timber-link> and mouseenter events
5
- * on <a data-timber-prefetch> for client-side navigation.
6
- *
7
- * Extracted from browser-entry.ts to keep files under 500 lines.
8
- *
9
- * See design/19-client-navigation.md
10
- */
11
- import type { RouterInstance } from '@timber-js/app/client';
12
- /**
13
- * Handle click events on timber links. Intercepts clicks on <a> elements
14
- * marked with data-timber-link and triggers SPA navigation instead of
15
- * a full page load.
16
- *
17
- * Passes through to default browser behavior when:
18
- * - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
19
- * - The click is not the primary button
20
- * - The link has a target attribute (e.g., target="_blank")
21
- * - The link has a download attribute
22
- */
23
- export declare function handleLinkClick(event: MouseEvent, router: RouterInstance): void;
24
- /**
25
- * Handle mouseenter events on prefetch-enabled links. When the user
26
- * hovers over <a data-timber-prefetch>, the RSC payload is fetched
27
- * and cached for near-instant navigation.
28
- *
29
- * See design/19-client-navigation.md §"Prefetch Cache"
30
- */
31
- export declare function handleLinkHover(event: MouseEvent, router: RouterInstance): void;
32
- //# sourceMappingURL=browser-links.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"browser-links.d.ts","sourceRoot":"","sources":["../../src/client/browser-links.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAK5D;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,cAAc,GAAG,IAAI,CAyC/E;AAID;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,cAAc,GAAG,IAAI,CAU/E"}
@@ -1,28 +0,0 @@
1
- import { type ReactNode } from 'react';
2
- /** Symbol used to store the onNavigate callback on anchor elements. */
3
- export declare const ON_NAVIGATE_KEY: "__timberOnNavigate";
4
- export type OnNavigateEvent = {
5
- preventDefault: () => void;
6
- };
7
- export type OnNavigateHandler = (e: OnNavigateEvent) => void;
8
- /**
9
- * Augment HTMLAnchorElement with the optional onNavigate property.
10
- * Used by browser-entry.ts handleLinkClick to check for the callback.
11
- */
12
- declare global {
13
- interface HTMLAnchorElement {
14
- [ON_NAVIGATE_KEY]?: OnNavigateHandler;
15
- }
16
- }
17
- /**
18
- * Client component rendered inside <Link> that attaches the onNavigate
19
- * callback to the closest <a> ancestor via a DOM property. The callback
20
- * is cleaned up on unmount.
21
- *
22
- * Renders no extra DOM — just a transparent wrapper.
23
- */
24
- export declare function LinkNavigateInterceptor({ onNavigate, children, }: {
25
- onNavigate: OnNavigateHandler;
26
- children: ReactNode;
27
- }): import("react/jsx-runtime").JSX.Element;
28
- //# sourceMappingURL=link-navigate-interceptor.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"link-navigate-interceptor.d.ts","sourceRoot":"","sources":["../../src/client/link-navigate-interceptor.tsx"],"names":[],"mappings":"AAQA,OAAO,EAAqB,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAE1D,uEAAuE;AACvE,eAAO,MAAM,eAAe,EAAG,oBAA6B,CAAC;AAE7D,MAAM,MAAM,eAAe,GAAG;IAC5B,cAAc,EAAE,MAAM,IAAI,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,EAAE,eAAe,KAAK,IAAI,CAAC;AAE7D;;;GAGG;AACH,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,iBAAiB;QACzB,CAAC,eAAe,CAAC,CAAC,EAAE,iBAAiB,CAAC;KACvC;CACF;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,EACtC,UAAU,EACV,QAAQ,GACT,EAAE;IACD,UAAU,EAAE,iBAAiB,CAAC;IAC9B,QAAQ,EAAE,SAAS,CAAC;CACrB,2CAmBA"}
@@ -1,90 +0,0 @@
1
- /**
2
- * Link click interception and hover prefetch for SPA navigation.
3
- *
4
- * Handles click events on <a data-timber-link> and mouseenter events
5
- * on <a data-timber-prefetch> for client-side navigation.
6
- *
7
- * Extracted from browser-entry.ts to keep files under 500 lines.
8
- *
9
- * See design/19-client-navigation.md
10
- */
11
-
12
- import type { RouterInstance } from '@timber-js/app/client';
13
- import { ON_NAVIGATE_KEY } from './link-navigate-interceptor.js';
14
-
15
- // ─── Link Click Interception ─────────────────────────────────────
16
-
17
- /**
18
- * Handle click events on timber links. Intercepts clicks on <a> elements
19
- * marked with data-timber-link and triggers SPA navigation instead of
20
- * a full page load.
21
- *
22
- * Passes through to default browser behavior when:
23
- * - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
24
- * - The click is not the primary button
25
- * - The link has a target attribute (e.g., target="_blank")
26
- * - The link has a download attribute
27
- */
28
- export function handleLinkClick(event: MouseEvent, router: RouterInstance): void {
29
- // Only intercept primary clicks without modifier keys
30
- if (event.button !== 0) return;
31
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
32
- if (event.defaultPrevented) return;
33
-
34
- // Find the closest <a> ancestor with data-timber-link
35
- const anchor = (event.target as Element).closest?.(
36
- 'a[data-timber-link]'
37
- ) as HTMLAnchorElement | null;
38
- if (!anchor) return;
39
-
40
- // Don't intercept links that should open externally
41
- if (anchor.target && anchor.target !== '_self') return;
42
- if (anchor.hasAttribute('download')) return;
43
-
44
- const href = anchor.getAttribute('href');
45
- if (!href) return;
46
-
47
- // Prevent default navigation
48
- event.preventDefault();
49
-
50
- // Call onNavigate if registered on this anchor (via LinkNavigateInterceptor).
51
- // If the handler calls preventDefault(), skip the default SPA navigation —
52
- // the caller is responsible for navigating (e.g. via router.push()).
53
- const onNavigate = anchor[ON_NAVIGATE_KEY];
54
- if (onNavigate) {
55
- let prevented = false;
56
- onNavigate({
57
- preventDefault: () => {
58
- prevented = true;
59
- },
60
- });
61
- if (prevented) return;
62
- }
63
-
64
- // Check scroll preference from data attribute
65
- const scroll = anchor.getAttribute('data-timber-scroll') !== 'false';
66
-
67
- // Trigger SPA navigation
68
- void router.navigate(href, { scroll });
69
- }
70
-
71
- // ─── Prefetch on Hover ───────────────────────────────────────────
72
-
73
- /**
74
- * Handle mouseenter events on prefetch-enabled links. When the user
75
- * hovers over <a data-timber-prefetch>, the RSC payload is fetched
76
- * and cached for near-instant navigation.
77
- *
78
- * See design/19-client-navigation.md §"Prefetch Cache"
79
- */
80
- export function handleLinkHover(event: MouseEvent, router: RouterInstance): void {
81
- const anchor = (event.target as Element).closest?.(
82
- 'a[data-timber-prefetch]'
83
- ) as HTMLAnchorElement | null;
84
- if (!anchor) return;
85
-
86
- const href = anchor.getAttribute('href');
87
- if (!href) return;
88
-
89
- router.prefetch(href);
90
- }
@@ -1,62 +0,0 @@
1
- 'use client';
2
-
3
- // LinkNavigateInterceptor — client component that stores an onNavigate callback
4
- // on the parent <a> element so the delegated click handler in browser-entry.ts
5
- // can invoke it before triggering SPA navigation.
6
- //
7
- // See design/19-client-navigation.md, TIM-167
8
-
9
- import { useRef, useEffect, type ReactNode } from 'react';
10
-
11
- /** Symbol used to store the onNavigate callback on anchor elements. */
12
- export const ON_NAVIGATE_KEY = '__timberOnNavigate' as const;
13
-
14
- export type OnNavigateEvent = {
15
- preventDefault: () => void;
16
- };
17
-
18
- export type OnNavigateHandler = (e: OnNavigateEvent) => void;
19
-
20
- /**
21
- * Augment HTMLAnchorElement with the optional onNavigate property.
22
- * Used by browser-entry.ts handleLinkClick to check for the callback.
23
- */
24
- declare global {
25
- interface HTMLAnchorElement {
26
- [ON_NAVIGATE_KEY]?: OnNavigateHandler;
27
- }
28
- }
29
-
30
- /**
31
- * Client component rendered inside <Link> that attaches the onNavigate
32
- * callback to the closest <a> ancestor via a DOM property. The callback
33
- * is cleaned up on unmount.
34
- *
35
- * Renders no extra DOM — just a transparent wrapper.
36
- */
37
- export function LinkNavigateInterceptor({
38
- onNavigate,
39
- children,
40
- }: {
41
- onNavigate: OnNavigateHandler;
42
- children: ReactNode;
43
- }) {
44
- const ref = useRef<HTMLSpanElement>(null);
45
-
46
- useEffect(() => {
47
- const anchor = ref.current?.closest('a');
48
- if (!anchor) return;
49
- anchor[ON_NAVIGATE_KEY] = onNavigate;
50
- return () => {
51
- delete anchor[ON_NAVIGATE_KEY];
52
- };
53
- }, [onNavigate]);
54
-
55
- // Use a <span> with display:contents to avoid affecting layout.
56
- // The ref lets us walk up to the parent <a> in the effect.
57
- return (
58
- <span ref={ref} style={{ display: 'contents' }}>
59
- {children}
60
- </span>
61
- );
62
- }