@timber-js/app 0.2.0-alpha.145 → 0.2.0-alpha.147

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 (183) hide show
  1. package/dist/_chunks/{actions-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
  2. package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
  3. package/dist/_chunks/{cli-schema-sync-DH-M8Poy.js → cli-schema-sync-CBiMEtqa.js} +1 -1
  4. package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-CQaxupHv.js} +47 -54
  5. package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +1 -0
  6. package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
  7. package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
  8. package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
  9. package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
  10. package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
  11. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
  12. package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
  13. package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-Ck4Y45XN.js} +49 -8
  14. package/dist/_chunks/walkers-Ck4Y45XN.js.map +1 -0
  15. package/dist/adapters/cloudflare.d.ts +1 -15
  16. package/dist/adapters/cloudflare.d.ts.map +1 -1
  17. package/dist/adapters/cloudflare.js +10 -28
  18. package/dist/adapters/cloudflare.js.map +1 -1
  19. package/dist/adapters/compress-module.d.ts.map +1 -1
  20. package/dist/adapters/nitro.d.ts.map +1 -1
  21. package/dist/adapters/nitro.js +18 -5
  22. package/dist/adapters/nitro.js.map +1 -1
  23. package/dist/cache/index.js +39 -12
  24. package/dist/cache/index.js.map +1 -1
  25. package/dist/cache/redis-handler.d.ts +72 -8
  26. package/dist/cache/redis-handler.d.ts.map +1 -1
  27. package/dist/cache/stable-stringify.d.ts +9 -0
  28. package/dist/cache/stable-stringify.d.ts.map +1 -1
  29. package/dist/cache/timber-cache.d.ts +3 -1
  30. package/dist/cache/timber-cache.d.ts.map +1 -1
  31. package/dist/cli.js +1 -1
  32. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  33. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  34. package/dist/client/browser-entry/scroll.d.ts +9 -0
  35. package/dist/client/browser-entry/scroll.d.ts.map +1 -1
  36. package/dist/client/error-boundary.d.ts.map +1 -1
  37. package/dist/client/error-boundary.js +24 -10
  38. package/dist/client/error-boundary.js.map +1 -1
  39. package/dist/client/error-reconstituter.d.ts +8 -1
  40. package/dist/client/error-reconstituter.d.ts.map +1 -1
  41. package/dist/client/index.js +3 -3
  42. package/dist/client/index.js.map +1 -1
  43. package/dist/client/internal.js +99 -24
  44. package/dist/client/internal.js.map +1 -1
  45. package/dist/client/link.d.ts.map +1 -1
  46. package/dist/client/navigation-api.d.ts.map +1 -1
  47. package/dist/client/navigation-root.d.ts +13 -0
  48. package/dist/client/navigation-root.d.ts.map +1 -1
  49. package/dist/client/router.d.ts +7 -0
  50. package/dist/client/router.d.ts.map +1 -1
  51. package/dist/client/rsc-fetch.d.ts.map +1 -1
  52. package/dist/dev-tools/404-page.d.ts +6 -2
  53. package/dist/dev-tools/404-page.d.ts.map +1 -1
  54. package/dist/dev-tools/dev-page-shell.d.ts +77 -0
  55. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
  56. package/dist/dev-tools/error-page.d.ts +4 -30
  57. package/dist/dev-tools/error-page.d.ts.map +1 -1
  58. package/dist/dev-tools/index.d.ts +2 -1
  59. package/dist/dev-tools/index.d.ts.map +1 -1
  60. package/dist/dev-tools/instrumentation.d.ts +11 -1
  61. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +432 -255
  64. package/dist/index.js.map +1 -1
  65. package/dist/plugins/cache.d.ts +43 -9
  66. package/dist/plugins/cache.d.ts.map +1 -1
  67. package/dist/plugins/cloudflare-wasm.d.ts +4 -1
  68. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
  69. package/dist/plugins/dev-server.d.ts +16 -0
  70. package/dist/plugins/dev-server.d.ts.map +1 -1
  71. package/dist/plugins/server-bundle.d.ts.map +1 -1
  72. package/dist/plugins/shims.d.ts +23 -0
  73. package/dist/plugins/shims.d.ts.map +1 -1
  74. package/dist/routing/codegen-types.d.ts +4 -0
  75. package/dist/routing/codegen-types.d.ts.map +1 -1
  76. package/dist/routing/index.js +2 -2
  77. package/dist/server/action-handler.d.ts.map +1 -1
  78. package/dist/server/actions.d.ts +3 -2
  79. package/dist/server/actions.d.ts.map +1 -1
  80. package/dist/server/cookie-context.d.ts.map +1 -1
  81. package/dist/server/cookie-parsing.d.ts.map +1 -1
  82. package/dist/server/deny-boundary.d.ts +27 -2
  83. package/dist/server/deny-boundary.d.ts.map +1 -1
  84. package/dist/server/deny-renderer.d.ts.map +1 -1
  85. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  86. package/dist/server/fallback-error.d.ts +2 -2
  87. package/dist/server/fallback-error.d.ts.map +1 -1
  88. package/dist/server/index.js +1 -1
  89. package/dist/server/internal.js +227 -179
  90. package/dist/server/internal.js.map +1 -1
  91. package/dist/server/node-stream-transforms.d.ts +24 -6
  92. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  93. package/dist/server/pipeline.d.ts +11 -0
  94. package/dist/server/pipeline.d.ts.map +1 -1
  95. package/dist/server/primitives.d.ts +1 -11
  96. package/dist/server/primitives.d.ts.map +1 -1
  97. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  98. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  99. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  100. package/dist/server/slot-resolver.d.ts +6 -4
  101. package/dist/server/slot-resolver.d.ts.map +1 -1
  102. package/dist/server/ssr-entry.d.ts.map +1 -1
  103. package/dist/server/status-code-resolver.d.ts +6 -0
  104. package/dist/server/status-code-resolver.d.ts.map +1 -1
  105. package/dist/server/tracing.d.ts +2 -1
  106. package/dist/server/tracing.d.ts.map +1 -1
  107. package/dist/server/utils/mdx-file.d.ts +15 -0
  108. package/dist/server/utils/mdx-file.d.ts.map +1 -0
  109. package/dist/shared/head-header.d.ts +54 -0
  110. package/dist/shared/head-header.d.ts.map +1 -0
  111. package/dist/shared/href-validation.d.ts +11 -0
  112. package/dist/shared/href-validation.d.ts.map +1 -1
  113. package/dist/shared/redirect-type.d.ts +17 -0
  114. package/dist/shared/redirect-type.d.ts.map +1 -0
  115. package/dist/shims/headers.d.ts +52 -1
  116. package/dist/shims/headers.d.ts.map +1 -1
  117. package/dist/shims/navigation-client.d.ts +33 -11
  118. package/dist/shims/navigation-client.d.ts.map +1 -1
  119. package/package.json +11 -10
  120. package/src/adapters/cloudflare.ts +13 -47
  121. package/src/adapters/compress-module.ts +9 -3
  122. package/src/adapters/nitro.ts +9 -2
  123. package/src/cache/redis-handler.ts +77 -13
  124. package/src/cache/stable-stringify.ts +65 -2
  125. package/src/cache/timber-cache.ts +25 -13
  126. package/src/cli.ts +0 -0
  127. package/src/client/browser-entry/action-dispatch.ts +7 -2
  128. package/src/client/browser-entry/router-init.ts +2 -1
  129. package/src/client/browser-entry/scroll.ts +23 -0
  130. package/src/client/error-boundary.tsx +33 -5
  131. package/src/client/error-reconstituter.tsx +15 -1
  132. package/src/client/link.tsx +4 -1
  133. package/src/client/navigation-api.ts +5 -1
  134. package/src/client/navigation-root.tsx +16 -0
  135. package/src/client/router.ts +107 -29
  136. package/src/client/rsc-fetch.ts +6 -6
  137. package/src/dev-tools/404-page.ts +22 -123
  138. package/src/dev-tools/dev-page-shell.ts +256 -0
  139. package/src/dev-tools/error-page.ts +27 -203
  140. package/src/dev-tools/index.ts +7 -1
  141. package/src/dev-tools/instrumentation.ts +39 -2
  142. package/src/index.ts +1 -0
  143. package/src/plugins/cache.ts +79 -21
  144. package/src/plugins/cloudflare-wasm.ts +48 -10
  145. package/src/plugins/dev-server.ts +32 -8
  146. package/src/plugins/server-bundle.ts +25 -9
  147. package/src/plugins/shims.ts +94 -52
  148. package/src/routing/codegen-types.ts +4 -0
  149. package/src/routing/codegen.ts +63 -6
  150. package/src/routing/scanner.ts +80 -63
  151. package/src/server/action-handler.ts +10 -2
  152. package/src/server/actions.ts +3 -2
  153. package/src/server/compress.ts +11 -2
  154. package/src/server/cookie-context.ts +11 -0
  155. package/src/server/cookie-parsing.ts +16 -4
  156. package/src/server/deny-boundary.ts +106 -13
  157. package/src/server/deny-renderer.ts +40 -10
  158. package/src/server/error-boundary-wrapper.ts +1 -14
  159. package/src/server/fallback-error.ts +9 -4
  160. package/src/server/node-stream-transforms.ts +61 -24
  161. package/src/server/pipeline.ts +12 -0
  162. package/src/server/primitives.ts +10 -31
  163. package/src/server/route-matcher.ts +22 -6
  164. package/src/server/rsc-entry/error-renderer.ts +1 -12
  165. package/src/server/rsc-entry/index.ts +7 -2
  166. package/src/server/rsc-entry/rsc-payload.ts +4 -3
  167. package/src/server/slot-resolver.ts +27 -14
  168. package/src/server/ssr-entry.ts +17 -28
  169. package/src/server/status-code-resolver.ts +3 -2
  170. package/src/server/tracing.ts +17 -1
  171. package/src/server/tree-match.ts +15 -5
  172. package/src/server/utils/mdx-file.ts +22 -0
  173. package/src/shared/head-header.ts +69 -0
  174. package/src/shared/href-validation.ts +24 -0
  175. package/src/shared/redirect-type.ts +17 -0
  176. package/src/shims/headers.ts +119 -2
  177. package/src/shims/navigation-client.ts +48 -27
  178. package/LICENSE +0 -8
  179. package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
  180. package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
  181. package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
  182. package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
  183. package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
@@ -94,6 +94,16 @@ export function getTraceStore(): TraceStore | undefined {
94
94
 
95
95
  // ─── Dev-Mode OTEL Auto-Init ─────────────────────────────────────────────
96
96
 
97
+ /**
98
+ * Well-known key marking dev tracing as initialized.
99
+ *
100
+ * The RSC entry module re-evaluates on every HMR invalidation and calls
101
+ * initDevTracing() again — without this guard, each call would register a
102
+ * fresh provider/processor and duplicate every span's output. Symbol.for()
103
+ * survives module re-evaluation. See TIM-1067, B49.
104
+ */
105
+ const DEV_TRACING_INIT_KEY = Symbol.for('timber.dev.tracing-initialized');
106
+
97
107
  /**
98
108
  * Initialize a minimal OTEL SDK in dev mode so spans are recorded and
99
109
  * fed to the DevSpanProcessor for dev log output.
@@ -102,11 +112,15 @@ export function getTraceStore(): TraceStore | undefined {
102
112
  * our DevSpanProcessor alongside theirs. If no SDK is configured,
103
113
  * we create a BasicTracerProvider with our processor.
104
114
  *
105
- * Only called in dev mode — zero overhead in production.
115
+ * Idempotent across module re-evaluations (HMR) — only the first call
116
+ * registers. Only called in dev mode — zero overhead in production.
106
117
  */
107
118
  export async function initDevTracing(
108
119
  config: import('../dev-tools/logger.js').DevLoggerConfig
109
120
  ): Promise<void> {
121
+ const globals = globalThis as Record<symbol, unknown>;
122
+ if (globals[DEV_TRACING_INIT_KEY]) return;
123
+
110
124
  const api = await getOtelApi();
111
125
  if (!api) return;
112
126
 
@@ -145,6 +159,8 @@ export async function initDevTracing(
145
159
 
146
160
  // Reset cached tracer so next getTracer() picks up the new provider
147
161
  _tracer = undefined;
162
+
163
+ globals[DEV_TRACING_INIT_KEY] = true;
148
164
  }
149
165
 
150
166
  // ─── OTEL Span Helpers ───────────────────────────────────────────────────
@@ -142,7 +142,7 @@ function matchNode<TFile>(
142
142
  // with no path segments.
143
143
  const effective = collectEffectiveChildren(node);
144
144
  for (const { child, groupChain } of effective) {
145
- if (child.segmentType === 'optional-catch-all' && isTerminal(child)) {
145
+ if (child.segmentType === 'optional-catch-all' && (!requireTerminal || isTerminal(child))) {
146
146
  chain.push(...groupChain, child);
147
147
  return true;
148
148
  }
@@ -187,18 +187,28 @@ function matchNode<TFile>(
187
187
  }
188
188
  }
189
189
 
190
- // 3. Catch-all segments ([...param]) — must be terminal
190
+ // 3. Catch-all segments ([...param]) — must be terminal unless the
191
+ // caller passed requireTerminal:false (metadata route matching needs to
192
+ // find the owning segment even when it has no page).
191
193
  for (const { child, groupChain } of effective) {
192
- if (child.segmentType === 'catch-all' && child.paramName && isTerminal(child)) {
194
+ if (
195
+ child.segmentType === 'catch-all' &&
196
+ child.paramName &&
197
+ (!requireTerminal || isTerminal(child))
198
+ ) {
193
199
  chain.push(...groupChain, child);
194
200
  params[child.paramName] = parts.slice(index);
195
201
  return true;
196
202
  }
197
203
  }
198
204
 
199
- // 4. Optional catch-all segments ([[...param]]) — must be terminal
205
+ // 4. Optional catch-all segments ([[...param]]) — same terminal rule
200
206
  for (const { child, groupChain } of effective) {
201
- if (child.segmentType === 'optional-catch-all' && child.paramName && isTerminal(child)) {
207
+ if (
208
+ child.segmentType === 'optional-catch-all' &&
209
+ child.paramName &&
210
+ (!requireTerminal || isTerminal(child))
211
+ ) {
202
212
  chain.push(...groupChain, child);
203
213
  params[child.paramName] = parts.slice(index);
204
214
  return true;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * MDX file-path detection shared by error/deny rendering paths.
3
+ *
4
+ * MDX status and error files are server components — they cannot be passed
5
+ * as function props across the RSC→client boundary (e.g. to
6
+ * TimberErrorBoundary or ErrorReconstituter) and must be pre-rendered as
7
+ * elements with plain props instead.
8
+ *
9
+ * See design/10-error-handling.md §"Status-Code File Variants".
10
+ */
11
+
12
+ /** MDX/markdown extensions — server components by default. */
13
+ const MDX_EXTENSIONS = new Set(['.mdx', '.md']);
14
+
15
+ /**
16
+ * Check if a file path ends with an MDX/markdown extension.
17
+ */
18
+ export function isMdxFilePath(filePath: string): boolean {
19
+ const dotIndex = filePath.lastIndexOf('.');
20
+ if (dotIndex === -1) return false;
21
+ return MDX_EXTENSIONS.has(filePath.slice(dotIndex));
22
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared encode/decode for the X-Timber-Head response header.
3
+ *
4
+ * Head elements (title, meta, link) are forwarded to the client as a JSON
5
+ * payload in a response header on both the navigation path (RSC payload
6
+ * responses) and the action-revalidation path. HTTP header values must be
7
+ * ByteStrings — any character above U+00FF (em dash, curly quotes, emoji,
8
+ * CJK) makes `new Response(...)` throw. Percent-encoding via
9
+ * encodeURIComponent keeps the value ASCII-safe.
10
+ *
11
+ * This module is the single source of truth for the wire format. Both
12
+ * server emit sites (rsc-entry/rsc-payload.ts, action-handler.ts) and both
13
+ * client parse sites (rsc-fetch.ts, browser-entry/action-dispatch.ts) MUST
14
+ * use it — changing one side alone breaks the other. See TIM-1058.
15
+ *
16
+ * Isomorphic: no server or client imports allowed here.
17
+ *
18
+ * See design/16-metadata.md and design/08-forms-and-actions.md.
19
+ */
20
+
21
+ /** Header name for resolved head elements. */
22
+ export const HEAD_HEADER = 'X-Timber-Head';
23
+
24
+ /**
25
+ * Maximum encoded payload size in characters (ASCII after encoding, so
26
+ * characters === bytes). Headers aren't meant for unbounded payloads;
27
+ * oversized head metadata is skipped rather than truncated — the client
28
+ * keeps its current head, which degrades gracefully.
29
+ */
30
+ export const HEAD_HEADER_MAX_LENGTH = 4096;
31
+
32
+ /**
33
+ * A rendered head element descriptor — the structural shape on the wire.
34
+ * Matches the server's `HeadElement` (route-element-builder.ts); the client
35
+ * narrows `tag` to 'title' | 'meta' | 'link' when applying to the DOM.
36
+ */
37
+ export interface HeadElementPayload {
38
+ tag: string;
39
+ content?: string;
40
+ attrs?: Record<string, string | null>;
41
+ }
42
+
43
+ /**
44
+ * Encode head elements for the X-Timber-Head header.
45
+ *
46
+ * Returns the percent-encoded JSON string, or null when the encoded
47
+ * payload exceeds HEAD_HEADER_MAX_LENGTH (caller skips the header).
48
+ */
49
+ export function encodeHeadHeader(headElements: HeadElementPayload[]): string | null {
50
+ const encoded = encodeURIComponent(JSON.stringify(headElements));
51
+ return encoded.length <= HEAD_HEADER_MAX_LENGTH ? encoded : null;
52
+ }
53
+
54
+ /**
55
+ * Decode an X-Timber-Head header value back into head elements.
56
+ *
57
+ * Returns null when the value is missing or malformed (corrupted
58
+ * percent-encoding or invalid JSON) — callers treat null as "no head
59
+ * update" rather than failing the navigation or action.
60
+ */
61
+ export function decodeHeadHeader(header: string | null): HeadElementPayload[] | null {
62
+ if (!header) return null;
63
+ try {
64
+ const parsed = JSON.parse(decodeURIComponent(header));
65
+ return Array.isArray(parsed) ? parsed : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
@@ -80,6 +80,30 @@ export function isInternalHref(href: string): boolean {
80
80
  return true;
81
81
  }
82
82
 
83
+ /** Pattern matching absolute URLs: any scheme (http:, javascript:, …) or protocol-relative // */
84
+ const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
85
+
86
+ /**
87
+ * Assert that a redirect target is a relative path — rejects absolute and
88
+ * protocol-relative URLs. Used by both the server `redirect()` primitive and
89
+ * the client next/navigation shim so they enforce identical semantics.
90
+ *
91
+ * Normalizes for validation only: strips C0 controls, trims, then replaces
92
+ * backslashes with forward slashes. Browsers normalize \ → / during URL
93
+ * resolution, so "/\evil.com" becomes "//evil.com" (protocol-relative open
94
+ * redirect). See design/13-security.md tests #8, #8a–#8c.
95
+ */
96
+ export function assertRelativeRedirectPath(path: string): void {
97
+ const cleaned = path.replace(CONTROL_CHARS_RE, '').trimStart().replaceAll('\\', '/');
98
+
99
+ if (ABSOLUTE_URL_RE.test(cleaned)) {
100
+ throw new Error(
101
+ `redirect() only accepts relative URLs. Got absolute URL: "${path}". ` +
102
+ 'Use redirectExternal(url, allowList) for external redirects.'
103
+ );
104
+ }
105
+ }
106
+
83
107
  const SAFE_REDIRECT_SCHEMES = new Set(['http:', 'https:']);
84
108
 
85
109
  /**
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Next.js redirect type discriminator.
3
+ *
4
+ * Provided for API compatibility with libraries that import `RedirectType`
5
+ * from `next/navigation`. In timber, `redirect()` always uses `replace`
6
+ * semantics (no history entry for the redirect itself).
7
+ *
8
+ * Lives in shared/ (isomorphic) so both the server primitives and the
9
+ * client-only next/navigation shim export the same definition without the
10
+ * client shim pulling in server code.
11
+ */
12
+ export const RedirectType = {
13
+ push: 'push',
14
+ replace: 'replace',
15
+ } as const;
16
+
17
+ export type RedirectTypeValue = (typeof RedirectType)[keyof typeof RedirectType];
@@ -4,10 +4,127 @@
4
4
  * Imports from @timber-js/app/server which Vite resolves to dist/server/index.js
5
5
  * via native package.json exports. This ensures the same ALS singleton as the
6
6
  * pipeline (both import from the same shared request-context chunk in dist/).
7
+ *
8
+ * `cookies()` is NOT a bare alias of `getCookieJar()`: Next.js's
9
+ * RequestCookies returns `{ name, value }` RequestCookie objects from
10
+ * `.get()`/`.getAll()`, while timber's native jar returns bare strings from
11
+ * `.get()`. Ecosystem libraries written against the real API use
12
+ * `(await cookies()).get('session')?.value` — with the bare-string shape
13
+ * that silently reads `undefined` off a string. The adapter below presents
14
+ * the Next shape while delegating every operation to the timber jar, so the
15
+ * encoding contract, cookie-octet validation, and mutable-context checks
16
+ * (design/29-cookies.md, ONGOING_SECURITY.md H-3) apply unchanged.
17
+ * See TIM-1042 and design/14-ecosystem.md §next/headers.
7
18
  */
8
19
 
20
+ import { getCookieJar } from '@timber-js/app/server';
21
+ import type { CookieOptions, SetCookieOptions } from '@timber-js/app/server';
22
+
9
23
  // Re-export timber's primary API
10
24
  export { getHeaders, getCookieJar } from '@timber-js/app/server';
11
25
 
12
- // Next.js compat aliases — libraries importing from 'next/headers' expect these names.
13
- export { getHeaders as headers, getCookieJar as cookies } from '@timber-js/app/server';
26
+ // Next.js compat alias — libraries importing from 'next/headers' expect this name.
27
+ export { getHeaders as headers } from '@timber-js/app/server';
28
+
29
+ /** Next.js's request cookie shape: `cookies().get(name)` → `{ name, value }`. */
30
+ export interface RequestCookie {
31
+ name: string;
32
+ value: string;
33
+ }
34
+
35
+ /** Next.js's `cookies().set()` options — `expires` may be a ms timestamp. */
36
+ export interface NextCookieOptions extends Omit<SetCookieOptions, 'expires'> {
37
+ expires?: Date | number;
38
+ }
39
+
40
+ /** Next.js's object overload for `cookies().set({ name, value, ...options })`. */
41
+ export interface NextCookieListItem extends NextCookieOptions {
42
+ name: string;
43
+ value: string;
44
+ }
45
+
46
+ /** Next.js's RequestCookies surface, backed by timber's cookie jar. */
47
+ export interface NextRequestCookies {
48
+ get(name: string): RequestCookie | undefined;
49
+ get(cookie: RequestCookie): RequestCookie | undefined;
50
+ getAll(name?: string): RequestCookie[];
51
+ getAll(cookie: RequestCookie): RequestCookie[];
52
+ has(name: string): boolean;
53
+ readonly size: number;
54
+ set(name: string, value: string, options?: NextCookieOptions): void;
55
+ set(cookie: NextCookieListItem): void;
56
+ delete(name: string): void;
57
+ delete(cookie: { name: string } & Pick<CookieOptions, 'path' | 'domain'>): void;
58
+ [Symbol.iterator](): IterableIterator<[string, RequestCookie]>;
59
+ toString(): string;
60
+ }
61
+
62
+ /** Normalize Next's `expires?: Date | number` (ms timestamp) to timber's `Date`. */
63
+ function normalizeOptions(options?: NextCookieOptions): SetCookieOptions | undefined {
64
+ if (!options) return undefined;
65
+ const { expires, ...rest } = options;
66
+ if (typeof expires === 'number') return { ...rest, expires: new Date(expires) };
67
+ return options as SetCookieOptions;
68
+ }
69
+
70
+ /**
71
+ * Next.js compat: `cookies()` returning the RequestCookies shape.
72
+ *
73
+ * Next.js 15 makes `cookies()` async; awaiting this sync return value works
74
+ * for both the `(await cookies()).get()` and legacy `cookies().get()` call
75
+ * styles, so libraries targeting either Next version resolve correctly.
76
+ */
77
+ export function cookies(): NextRequestCookies {
78
+ const jar = getCookieJar();
79
+
80
+ const toCookie = (name: string): RequestCookie | undefined => {
81
+ const value = jar.get(name);
82
+ return value === undefined ? undefined : { name, value };
83
+ };
84
+
85
+ return {
86
+ get(nameOrCookie: string | RequestCookie): RequestCookie | undefined {
87
+ return toCookie(typeof nameOrCookie === 'string' ? nameOrCookie : nameOrCookie.name);
88
+ },
89
+ getAll(nameOrCookie?: string | RequestCookie): RequestCookie[] {
90
+ const all = jar.getAll();
91
+ if (nameOrCookie === undefined) return all;
92
+ const name = typeof nameOrCookie === 'string' ? nameOrCookie : nameOrCookie.name;
93
+ return all.filter((cookie) => cookie.name === name);
94
+ },
95
+ has(name: string): boolean {
96
+ return jar.has(name);
97
+ },
98
+ get size(): number {
99
+ return jar.size;
100
+ },
101
+ set(
102
+ nameOrCookie: string | NextCookieListItem,
103
+ value?: string,
104
+ options?: NextCookieOptions
105
+ ): void {
106
+ if (typeof nameOrCookie === 'string') {
107
+ jar.set(nameOrCookie, value as string, normalizeOptions(options));
108
+ } else {
109
+ const { name, value: cookieValue, ...objectOptions } = nameOrCookie;
110
+ jar.set(name, cookieValue, normalizeOptions(objectOptions));
111
+ }
112
+ },
113
+ delete(nameOrCookie: string | ({ name: string } & Pick<CookieOptions, 'path' | 'domain'>)) {
114
+ if (typeof nameOrCookie === 'string') {
115
+ jar.delete(nameOrCookie);
116
+ } else {
117
+ const { name, ...options } = nameOrCookie;
118
+ jar.delete(name, options);
119
+ }
120
+ },
121
+ *[Symbol.iterator](): IterableIterator<[string, RequestCookie]> {
122
+ for (const cookie of jar.getAll()) {
123
+ yield [cookie.name, cookie];
124
+ }
125
+ },
126
+ toString(): string {
127
+ return jar.toString();
128
+ },
129
+ };
130
+ }
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * Shim: next/navigation (client environment only)
3
3
  *
4
- * Re-exports only the client-side hooks from timber's client API.
5
- * Server-only functions (redirect, notFound, etc.) are excluded to prevent
6
- * server/primitives.ts from being pulled into the browser bundle.
4
+ * Re-exports the client-side hooks from timber's client API, and implements
5
+ * client-render `redirect()` / `permanentRedirect()` / `notFound()` without
6
+ * pulling server/primitives.ts into the browser bundle.
7
+ *
8
+ * Next.js supports calling redirect()/notFound() during client component
9
+ * render. timber matches that by throwing a plain Error carrying the same
10
+ * JSON digest the server pipeline attaches to RedirectSignal/DenySignal.
11
+ * TimberErrorBoundary recognizes the digest and converts it to an SPA
12
+ * navigation (redirect) or a status-code fallback (notFound), and
13
+ * browser-dev's isFrameworkSignalError() keeps it out of the dev overlay.
7
14
  *
8
15
  * The full shim (navigation.ts) is still used in the RSC and SSR environments
9
- * where both client hooks and server functions are needed.
16
+ * where the real server primitives produce proper HTTP responses.
10
17
  *
11
18
  * Imports use @timber-js/app/client (not #/ subpath imports) so they resolve
12
19
  * to the same module instances as user code in Vite dev — preventing module
@@ -16,9 +23,12 @@
16
23
  * See design/14-ecosystem.md §"next/navigation" for the full shim audit.
17
24
  */
18
25
 
26
+ import { assertRelativeRedirectPath } from '../shared/href-validation.js';
27
+ import type { RedirectTypeValue } from '../shared/redirect-type.js';
28
+
19
29
  // Hooks — useSegmentParams and useSearchParams import from direct modules
20
30
  // since they're no longer in @timber-js/app/client public exports.
21
- export { useSegmentParams } from '../client/use-segment-params.js';
31
+ export { useSegmentParams, useSegmentParams as useParams } from '../client/use-segment-params.js';
22
32
  export { useSearchParams } from '../client/use-search-params.js';
23
33
  export {
24
34
  usePathname,
@@ -27,32 +37,43 @@ export {
27
37
  useSelectedLayoutSegments,
28
38
  } from '@timber-js/app/client';
29
39
 
30
- // RedirectType enum is safe (no server code dependency) and used by some
31
- // client-side libraries for type checking.
32
- export const RedirectType = {
33
- push: 'push',
34
- replace: 'replace',
35
- } as const;
40
+ // Single source of truth shared with server/primitives.ts.
41
+ export { RedirectType } from '../shared/redirect-type.js';
36
42
 
37
- // Server-only stubs — throw at runtime if called from the client.
38
- // These exist for type compatibility with libraries that import the types
39
- // but should never execute in the browser.
40
- export function redirect(): never {
41
- throw new Error(
42
- 'redirect() is a server-only function and cannot be called from client components. ' +
43
- 'Use useRouter().push() or useRouter().replace() for client-side navigation.'
44
- );
43
+ /** Throw a plain Error carrying a framework signal digest. */
44
+ function throwSignal(message: string, digest: Record<string, unknown>): never {
45
+ const err = new Error(message);
46
+ (err as Error & { digest?: string }).digest = JSON.stringify(digest);
47
+ throw err;
45
48
  }
46
49
 
47
- export function permanentRedirect(): never {
48
- throw new Error(
49
- 'permanentRedirect() is a server-only function and cannot be called from client components. ' +
50
- 'Use useRouter().push() or useRouter().replace() for client-side navigation.'
51
- );
50
+ /**
51
+ * Client-render redirect. Throws a redirect-digest error that the nearest
52
+ * TimberErrorBoundary converts into an SPA navigation (replace semantics).
53
+ *
54
+ * The optional RedirectType argument is accepted for Next.js API
55
+ * compatibility but ignored — timber always uses replace semantics,
56
+ * matching the server-side redirect().
57
+ */
58
+ export function redirect(path: string, _type?: RedirectTypeValue): never {
59
+ assertRelativeRedirectPath(path);
60
+ throwSignal(`Redirect to ${path}`, { type: 'redirect', location: path, status: 302 });
52
61
  }
53
62
 
63
+ /**
64
+ * Client-render permanent redirect. On the client there is no HTTP status —
65
+ * this behaves identically to redirect() apart from the digest's status.
66
+ */
67
+ export function permanentRedirect(path: string, _type?: RedirectTypeValue): never {
68
+ assertRelativeRedirectPath(path);
69
+ throwSignal(`Redirect to ${path}`, { type: 'redirect', location: path, status: 308 });
70
+ }
71
+
72
+ /**
73
+ * Client-render notFound. Throws a deny-digest error (status 404) that the
74
+ * nearest matching TimberErrorBoundary renders as the 404 fallback. The
75
+ * message matches DenySignal's so digest-less fallback parsing still works.
76
+ */
54
77
  export function notFound(): never {
55
- throw new Error(
56
- 'notFound() is a server-only function and cannot be called from client components.'
57
- );
78
+ throwSignal('Access denied with status 404', { type: 'deny', status: 404, data: null });
58
79
  }
package/LICENSE DELETED
@@ -1,8 +0,0 @@
1
- DONTFUCKINGUSE LICENSE
2
-
3
- Copyright (c) 2025 Daniel Saewitz
4
-
5
- This software may not be used, copied, modified, merged, published,
6
- distributed, sublicensed, or sold by anyone other than the copyright holder.
7
-
8
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.