@timber-js/app 0.2.0-alpha.144 → 0.2.0-alpha.146

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 (149) 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/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
  4. package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
  5. package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
  6. package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
  7. package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
  8. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
  9. package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
  10. package/dist/adapters/cloudflare.d.ts +1 -15
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +10 -28
  13. package/dist/adapters/cloudflare.js.map +1 -1
  14. package/dist/adapters/compress-module.d.ts.map +1 -1
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +18 -5
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.js +39 -12
  19. package/dist/cache/index.js.map +1 -1
  20. package/dist/cache/redis-handler.d.ts +72 -8
  21. package/dist/cache/redis-handler.d.ts.map +1 -1
  22. package/dist/cache/stable-stringify.d.ts +9 -0
  23. package/dist/cache/stable-stringify.d.ts.map +1 -1
  24. package/dist/cache/timber-cache.d.ts +3 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  27. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  28. package/dist/client/browser-entry/scroll.d.ts +9 -0
  29. package/dist/client/browser-entry/scroll.d.ts.map +1 -1
  30. package/dist/client/error-boundary.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +11 -6
  32. package/dist/client/error-boundary.js.map +1 -1
  33. package/dist/client/index.js +3 -3
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/client/internal.js +99 -24
  36. package/dist/client/internal.js.map +1 -1
  37. package/dist/client/link.d.ts.map +1 -1
  38. package/dist/client/navigation-api.d.ts.map +1 -1
  39. package/dist/client/navigation-root.d.ts +13 -0
  40. package/dist/client/navigation-root.d.ts.map +1 -1
  41. package/dist/client/router.d.ts +7 -0
  42. package/dist/client/router.d.ts.map +1 -1
  43. package/dist/client/rsc-fetch.d.ts.map +1 -1
  44. package/dist/dev-tools/404-page.d.ts +6 -2
  45. package/dist/dev-tools/404-page.d.ts.map +1 -1
  46. package/dist/dev-tools/dev-page-shell.d.ts +77 -0
  47. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
  48. package/dist/dev-tools/error-page.d.ts +4 -30
  49. package/dist/dev-tools/error-page.d.ts.map +1 -1
  50. package/dist/dev-tools/index.d.ts +2 -1
  51. package/dist/dev-tools/index.d.ts.map +1 -1
  52. package/dist/dev-tools/instrumentation.d.ts +11 -1
  53. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +430 -253
  56. package/dist/index.js.map +1 -1
  57. package/dist/plugins/cache.d.ts +43 -9
  58. package/dist/plugins/cache.d.ts.map +1 -1
  59. package/dist/plugins/cloudflare-wasm.d.ts +4 -1
  60. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
  61. package/dist/plugins/dev-server.d.ts +16 -0
  62. package/dist/plugins/dev-server.d.ts.map +1 -1
  63. package/dist/plugins/server-bundle.d.ts.map +1 -1
  64. package/dist/plugins/shims.d.ts +23 -0
  65. package/dist/plugins/shims.d.ts.map +1 -1
  66. package/dist/server/action-handler.d.ts.map +1 -1
  67. package/dist/server/actions.d.ts +3 -2
  68. package/dist/server/actions.d.ts.map +1 -1
  69. package/dist/server/cookie-context.d.ts.map +1 -1
  70. package/dist/server/cookie-parsing.d.ts.map +1 -1
  71. package/dist/server/fallback-error.d.ts +2 -2
  72. package/dist/server/fallback-error.d.ts.map +1 -1
  73. package/dist/server/index.js +1 -1
  74. package/dist/server/internal.js +1 -1
  75. package/dist/server/internal.js.map +1 -1
  76. package/dist/server/node-stream-transforms.d.ts +24 -6
  77. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  78. package/dist/server/pipeline.d.ts +11 -0
  79. package/dist/server/pipeline.d.ts.map +1 -1
  80. package/dist/server/primitives.d.ts +1 -11
  81. package/dist/server/primitives.d.ts.map +1 -1
  82. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  84. package/dist/server/slot-resolver.d.ts +6 -4
  85. package/dist/server/slot-resolver.d.ts.map +1 -1
  86. package/dist/server/ssr-entry.d.ts.map +1 -1
  87. package/dist/server/tracing.d.ts +2 -1
  88. package/dist/server/tracing.d.ts.map +1 -1
  89. package/dist/shared/head-header.d.ts +54 -0
  90. package/dist/shared/head-header.d.ts.map +1 -0
  91. package/dist/shared/href-validation.d.ts +11 -0
  92. package/dist/shared/href-validation.d.ts.map +1 -1
  93. package/dist/shared/redirect-type.d.ts +17 -0
  94. package/dist/shared/redirect-type.d.ts.map +1 -0
  95. package/dist/shims/headers.d.ts +52 -1
  96. package/dist/shims/headers.d.ts.map +1 -1
  97. package/dist/shims/navigation-client.d.ts +33 -11
  98. package/dist/shims/navigation-client.d.ts.map +1 -1
  99. package/package.json +10 -10
  100. package/src/adapters/cloudflare.ts +13 -47
  101. package/src/adapters/compress-module.ts +9 -3
  102. package/src/adapters/nitro.ts +9 -2
  103. package/src/cache/redis-handler.ts +77 -13
  104. package/src/cache/stable-stringify.ts +65 -2
  105. package/src/cache/timber-cache.ts +25 -13
  106. package/src/cli.ts +0 -0
  107. package/src/client/browser-entry/action-dispatch.ts +7 -2
  108. package/src/client/browser-entry/router-init.ts +2 -1
  109. package/src/client/browser-entry/scroll.ts +23 -0
  110. package/src/client/error-boundary.tsx +19 -5
  111. package/src/client/link.tsx +4 -1
  112. package/src/client/navigation-api.ts +5 -1
  113. package/src/client/navigation-root.tsx +16 -0
  114. package/src/client/router.ts +107 -29
  115. package/src/client/rsc-fetch.ts +6 -6
  116. package/src/dev-tools/404-page.ts +22 -123
  117. package/src/dev-tools/dev-page-shell.ts +256 -0
  118. package/src/dev-tools/error-page.ts +27 -203
  119. package/src/dev-tools/index.ts +7 -1
  120. package/src/dev-tools/instrumentation.ts +39 -2
  121. package/src/index.ts +1 -0
  122. package/src/plugins/cache.ts +79 -21
  123. package/src/plugins/cloudflare-wasm.ts +48 -10
  124. package/src/plugins/dev-server.ts +32 -8
  125. package/src/plugins/server-bundle.ts +25 -9
  126. package/src/plugins/shims.ts +94 -52
  127. package/src/server/action-handler.ts +10 -2
  128. package/src/server/actions.ts +3 -2
  129. package/src/server/compress.ts +11 -2
  130. package/src/server/cookie-context.ts +11 -0
  131. package/src/server/cookie-parsing.ts +16 -4
  132. package/src/server/fallback-error.ts +9 -4
  133. package/src/server/node-stream-transforms.ts +61 -24
  134. package/src/server/pipeline.ts +12 -0
  135. package/src/server/primitives.ts +10 -31
  136. package/src/server/rsc-entry/index.ts +7 -2
  137. package/src/server/rsc-entry/rsc-payload.ts +4 -3
  138. package/src/server/slot-resolver.ts +27 -14
  139. package/src/server/ssr-entry.ts +17 -28
  140. package/src/server/tracing.ts +17 -1
  141. package/src/shared/head-header.ts +69 -0
  142. package/src/shared/href-validation.ts +24 -0
  143. package/src/shared/redirect-type.ts +17 -0
  144. package/src/shims/headers.ts +119 -2
  145. package/src/shims/navigation-client.ts +48 -27
  146. package/LICENSE +0 -8
  147. package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
  148. package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
  149. package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
@@ -10,9 +10,36 @@
10
10
  * The plugin transforms:
11
11
  * cache(fn, opts) → cache(fn, opts, "stable-id")
12
12
  *
13
- * where stable-id = fnv1a(relativeModulePath + ":cache:" + callIndex).
13
+ * where stable-id =
14
+ * fnv1a(relPath + ":cache:" + fnv1a(stmtText) + ":" + fnv1a(callSpanText) + ":" + occurrence)
14
15
  *
15
- * See TIM-997, design/06-caching.md §"Cache Key Security".
16
+ * stmtText is the source text of the enclosing *top-level statement* and
17
+ * callSpanText is the source text of the cache(...) call expression itself.
18
+ *
19
+ * The ID is derived from the *content* of the call, not its ordinal position
20
+ * in the file. A position-based index (the original TIM-997 scheme) was stable
21
+ * within one build but shifted across builds: inserting, deleting, or
22
+ * reordering a cache() call changed the index of every call below it, so with
23
+ * a persistent shared CacheHandler (Redis/KV) a new function could inherit
24
+ * another function's old ID and serve its persisted data — cross-deploy cache
25
+ * poisoning (TIM-1053). Content hashing makes IDs immune to reordering and
26
+ * unrelated edits, while a changed call gets a fresh ID — which is exactly
27
+ * when invalidation is wanted.
28
+ *
29
+ * The enclosing-statement text is included so that byte-identical cache()
30
+ * calls in different scopes — e.g. two factory helpers that each `return
31
+ * cache(fn, { ttl: 60 })` — get distinct, insertion-stable IDs: their
32
+ * enclosing declarations differ by name/body even when the call spans are
33
+ * identical. Without it, a source-order occurrence counter alone would shift
34
+ * when an identical span is inserted above, reassigning IDs across deploys.
35
+ * Calls that are byte-identical INCLUDING their enclosing top-level statement
36
+ * are disambiguated by a source-order occurrence index. That residue is safe:
37
+ * identical top-level declarations are a redeclaration error, so fully
38
+ * identical (statement, call) pairs can only be expression statements
39
+ * referencing the same bindings — the same function, where an ID swap is
40
+ * harmless.
41
+ *
42
+ * See TIM-997, TIM-1053, design/06-caching.md §"Cache Key Security".
16
43
  */
17
44
 
18
45
  import { relative } from 'node:path';
@@ -118,16 +145,30 @@ function findCacheCallSites(
118
145
  }
119
146
 
120
147
  /**
121
- * Generate a stable callsite ID from a module path and call index.
148
+ * Generate a stable callsite ID from a module path, the source text of the
149
+ * enclosing top-level statement, the source text of the cache(...) call
150
+ * expression, and an occurrence index among fully byte-identical
151
+ * (statement, call) pairs in the same file.
122
152
  *
123
- * The ID is an FNV-1a hash of the relative module path + call index,
124
- * providing a deterministic identifier that is:
125
- * - Stable across cold starts of the same build
126
- * - Unique across different modules
127
- * - Unique across multiple cache() calls within the same module
153
+ * The ID is deterministic and:
154
+ * - Stable across cold starts AND across builds/deploys for unchanged calls —
155
+ * inserting, deleting, or reordering other cache() calls does not affect it
156
+ * - Unique across different modules (relPath component)
157
+ * - Unique across distinct cache() calls within a module (statement + span
158
+ * hashes), with fully identical duplicates disambiguated by source-order
159
+ * occurrence
160
+ * - Fresh when the wrapped fn, opts, or enclosing statement change, which
161
+ * intentionally invalidates the old persisted entries
128
162
  */
129
- function generateStableId(relativeModulePath: string, callIndex: number): string {
130
- return fnv1aHash(`${relativeModulePath}:cache:${callIndex}`);
163
+ function generateStableId(
164
+ relativeModulePath: string,
165
+ statementText: string,
166
+ callSpanText: string,
167
+ occurrence: number
168
+ ): string {
169
+ return fnv1aHash(
170
+ `${relativeModulePath}:cache:${fnv1aHash(statementText)}:${fnv1aHash(callSpanText)}:${occurrence}`
171
+ );
131
172
  }
132
173
 
133
174
  export function timberCacheTransform(ctx: PluginContext): Plugin {
@@ -149,23 +190,40 @@ export function timberCacheTransform(ctx: PluginContext): Plugin {
149
190
  const cacheBindings = findCacheBindings(program);
150
191
  if (cacheBindings.size === 0) return;
151
192
 
152
- const callSites: CallExpressionNode[] = [];
153
- findCacheCallSites(program, cacheBindings, callSites);
193
+ // Collect call sites per top-level statement so each call carries its
194
+ // enclosing statement's source text into the ID hash.
195
+ const callSites: Array<{ call: CallExpressionNode; stmt: PositionedNode }> = [];
196
+ for (const stmt of program.body) {
197
+ const calls: CallExpressionNode[] = [];
198
+ findCacheCallSites(stmt, cacheBindings, calls);
199
+ for (const call of calls) callSites.push({ call, stmt });
200
+ }
154
201
  if (callSites.length === 0) return;
155
202
 
156
- // Sort by position in file for deterministic call indices
157
- callSites.sort((a, b) => a.start - b.start);
203
+ // Sort by position in file so occurrence indices among byte-identical
204
+ // (statement, call) pairs are assigned in deterministic source order
205
+ callSites.sort((a, b) => a.call.start - b.call.start);
158
206
 
159
207
  const root = ctx.root || process.cwd();
160
208
  const relPath = relative(root, id);
161
209
  const s = new MagicString(code);
162
-
163
- for (let i = 0; i < callSites.length; i++) {
164
- const call = callSites[i];
165
- const stableId = generateStableId(relPath, i);
166
- // Insert the stable ID as the 3rd argument just before the closing ')'
167
- const insertPos = call.end - 1;
168
- s.appendLeft(insertPos, `, "${stableId}"`);
210
+ const occurrences = new Map<string, number>();
211
+
212
+ for (const { call, stmt } of callSites) {
213
+ const stmtText = code.slice(stmt.start, stmt.end);
214
+ const spanText = code.slice(call.start, call.end);
215
+ const dupKey = stmtText + String.fromCharCode(0) + spanText;
216
+ const occurrence = occurrences.get(dupKey) ?? 0;
217
+ occurrences.set(dupKey, occurrence + 1);
218
+ const stableId = generateStableId(relPath, stmtText, spanText, occurrence);
219
+ // Insert the stable ID as the 3rd argument directly after the last
220
+ // argument — NOT before the closing ')'. The call may already have a
221
+ // trailing comma after the options argument (Prettier v3 default for
222
+ // multiline calls); inserting before ')' would emit `,, "id"`, a
223
+ // parse error (TIM-1021). A trailing comma after the inserted
224
+ // argument remains valid JS.
225
+ const lastArg = call.arguments[call.arguments.length - 1];
226
+ s.appendLeft(lastArg.end, `, "${stableId}"`);
169
227
  }
170
228
 
171
229
  return {
@@ -10,11 +10,15 @@
10
10
  * 2. Externalizes `.wasm` imports so Rolldown skips them
11
11
  * 3. Copies the WASM files to the build output so wrangler can find them
12
12
  *
13
- * Only active for Cloudflare adapter production builds.
13
+ * Only active for Cloudflare adapter production builds — every hook bails
14
+ * unless `isCloudflareBuild` was set in configResolved. In dev and non-CF
15
+ * builds, `.wasm` imports are left to Vite's own handling (?init / ?url or
16
+ * a clear build error), never silently externalized.
14
17
  */
15
18
 
16
19
  import { basename, resolve, dirname } from 'node:path';
17
- import { copyFile, mkdir } from 'node:fs/promises';
20
+ import { createHash } from 'node:crypto';
21
+ import { copyFile, mkdir, readFile } from 'node:fs/promises';
18
22
  import type { Plugin, ResolvedConfig } from 'vite';
19
23
  import type { PluginContext } from '../plugin-context.js';
20
24
 
@@ -24,8 +28,13 @@ function isCloudflareAdapter(ctx: PluginContext): boolean {
24
28
  }
25
29
 
26
30
  export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
27
- // Track resolved WASM file paths so we can copy them to the output
31
+ // Resolved WASM files, keyed by absolute source path so two files that
32
+ // share a basename (e.g. lib_bg.wasm from two packages) don't collide.
33
+ // The value is the unique output basename used for the external import
34
+ // specifier and the copy destination.
28
35
  const wasmFiles = new Map<string, string>();
36
+ const usedBasenames = new Set<string>();
37
+ let isCloudflareBuild = false;
29
38
 
30
39
  const plugin: Plugin = {
31
40
  name: 'timber-cloudflare-wasm',
@@ -33,6 +42,7 @@ export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
33
42
  configResolved(resolved: ResolvedConfig) {
34
43
  if (resolved.command !== 'build') return;
35
44
  if (!isCloudflareAdapter(ctx)) return;
45
+ isCloudflareBuild = true;
36
46
 
37
47
  // Prepend `workerd` to resolve conditions for server environments
38
48
  // so @takumi-rs/wasm/auto resolves to the Cloudflare-native entry
@@ -49,9 +59,23 @@ export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
49
59
  }
50
60
  },
51
61
 
62
+ // Strip /* @vite-ignore */ from @takumi-rs/wasm dynamic imports so
63
+ // Rolldown bundles them instead of leaving bare specifiers that fail
64
+ // at runtime on Cloudflare Workers (no node_modules).
65
+ transform(code, id) {
66
+ if (!isCloudflareBuild) return;
67
+ if (!id.includes('takumi')) return;
68
+ const stripped = code.replaceAll(
69
+ /\/\*\s*@vite-ignore\s*\*\/\s*\n\s*"@takumi-rs\/wasm"/g,
70
+ '"@takumi-rs/wasm"'
71
+ );
72
+ if (stripped !== code) return stripped;
73
+ },
74
+
52
75
  resolveId: {
53
76
  order: 'pre',
54
77
  async handler(source, importer) {
78
+ if (!isCloudflareBuild) return;
55
79
  if (!source.endsWith('.wasm')) return;
56
80
 
57
81
  let absolutePath: string;
@@ -61,14 +85,28 @@ export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
61
85
  return;
62
86
  }
63
87
 
64
- const filename = basename(absolutePath);
65
- wasmFiles.set(filename, absolutePath);
88
+ let outputName = wasmFiles.get(absolutePath);
89
+ if (!outputName) {
90
+ outputName = basename(absolutePath);
91
+ if (usedBasenames.has(outputName)) {
92
+ // Another wasm file already claimed this basename — disambiguate
93
+ // with a short content hash so both files land in the output.
94
+ const hash = createHash('sha256')
95
+ .update(await readFile(absolutePath))
96
+ .digest('hex')
97
+ .slice(0, 8);
98
+ outputName = outputName.replace(/\.wasm$/, `.${hash}.wasm`);
99
+ }
100
+ usedBasenames.add(outputName);
101
+ wasmFiles.set(absolutePath, outputName);
102
+ }
66
103
 
67
- return { id: './' + filename, external: true };
104
+ return { id: './' + outputName, external: true };
68
105
  },
69
106
  },
70
107
 
71
108
  async writeBundle(options, bundle) {
109
+ if (!isCloudflareBuild) return;
72
110
  if (wasmFiles.size === 0) return;
73
111
 
74
112
  const outDir = options.dir;
@@ -79,8 +117,8 @@ export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
79
117
  const targetDirs = new Set<string>();
80
118
  for (const chunk of Object.values(bundle)) {
81
119
  if (chunk.type !== 'chunk') continue;
82
- for (const filename of wasmFiles.keys()) {
83
- if (chunk.code.includes(filename)) {
120
+ for (const outputName of wasmFiles.values()) {
121
+ if (chunk.code.includes(outputName)) {
84
122
  targetDirs.add(dirname(resolve(outDir, chunk.fileName)));
85
123
  }
86
124
  }
@@ -91,8 +129,8 @@ export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
91
129
  const copies: Promise<void>[] = [];
92
130
  for (const dir of targetDirs) {
93
131
  await mkdir(dir, { recursive: true });
94
- for (const [filename, absolutePath] of wasmFiles) {
95
- copies.push(copyFile(absolutePath, resolve(dir, filename)));
132
+ for (const [absolutePath, outputName] of wasmFiles) {
133
+ copies.push(copyFile(absolutePath, resolve(dir, outputName)));
96
134
  }
97
135
  }
98
136
  await Promise.all(copies);
@@ -14,6 +14,7 @@
14
14
 
15
15
  import type { Plugin, ViteDevServer, DevEnvironment } from 'vite';
16
16
  import type { IncomingMessage, ServerResponse } from 'node:http';
17
+ import type { TLSSocket } from 'node:tls';
17
18
  import { join } from 'node:path';
18
19
  import type { PluginContext } from '../plugin-context.js';
19
20
  import { setViteServer } from '../dev-tools/warnings.js';
@@ -24,11 +25,8 @@ import {
24
25
  parseFirstAppFrame,
25
26
  type ErrorPhase,
26
27
  } from '../dev-tools/overlay.js';
27
- import {
28
- generateDevErrorPage,
29
- extractHmrOptions,
30
- type DevErrorHmrOptions,
31
- } from '../dev-tools/error-page.js';
28
+ import { generateDevErrorPage } from '../dev-tools/error-page.js';
29
+ import { extractHmrOptions, type DevErrorHmrOptions } from '../dev-tools/dev-page-shell.js';
32
30
  import { addVirtualModuleContext } from '../config-validation.js';
33
31
  import { compressResponse } from '../server/compress.js';
34
32
  import { isMetadataRouteServePath } from '../server/metadata-routes.js';
@@ -207,6 +205,7 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
207
205
  stack: unknown[] | null;
208
206
  }>
209
207
  ) => void;
208
+ devHmrOptions?: DevErrorHmrOptions;
210
209
  }
211
210
  | undefined;
212
211
  if (config) {
@@ -219,6 +218,11 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
219
218
  debugComponents
220
219
  );
221
220
  };
221
+ // Plumb the resolved HMR options into the RSC environment. The RSC
222
+ // entry can't read the Vite server config (separate module graph),
223
+ // but its dev 404 page and fallback error page need the HMR endpoint
224
+ // (including Vite 8's required WS token) for auto-reload (TIM-1067).
225
+ config.devHmrOptions = hmrOptions;
222
226
  }
223
227
 
224
228
  // Wire source-map handler so error pages show original positions.
@@ -357,15 +361,30 @@ function addTimberContext(error: Error): void {
357
361
  *
358
362
  * Constructs the full URL from the Host header and request URL,
359
363
  * and forwards the method, headers, and body.
364
+ *
365
+ * The scheme comes from the socket's TLS state — with `server.https` the
366
+ * browser sends `Origin: https://host`, and a hardcoded `http://` would
367
+ * fail CSRF origin comparison on every POST. X-Forwarded-Proto is NOT
368
+ * consulted: dev connections are direct, so the header would be
369
+ * attacker-controlled. (TIM-1067, B32)
370
+ *
371
+ * Exported for tests.
360
372
  */
361
- function toWebRequest(nodeReq: IncomingMessage): Request {
362
- const protocol = 'http';
363
- const host = nodeReq.headers.host ?? 'localhost';
373
+ export function toWebRequest(nodeReq: IncomingMessage): Request {
374
+ const protocol = (nodeReq.socket as TLSSocket | undefined)?.encrypted ? 'https' : 'http';
375
+ // Under HTTP/2 there is no Host header — the authority lives in the
376
+ // `:authority` pseudo-header.
377
+ const host =
378
+ nodeReq.headers.host ?? (nodeReq.headers[':authority'] as string | undefined) ?? 'localhost';
364
379
  const url = `${protocol}://${host}${nodeReq.url}`;
365
380
 
366
381
  const headers = new Headers();
367
382
  for (const [key, value] of Object.entries(nodeReq.headers)) {
368
383
  if (value === undefined) continue;
384
+ // HTTP/2 pseudo-headers (:method, :path, :authority, :scheme) are not
385
+ // valid Headers names — Headers.set() throws on them. They're already
386
+ // reflected in the request URL/method above.
387
+ if (key.startsWith(':')) continue;
369
388
  if (Array.isArray(value)) {
370
389
  for (const v of value) {
371
390
  headers.append(key, v);
@@ -374,6 +393,11 @@ function toWebRequest(nodeReq: IncomingMessage): Request {
374
393
  headers.set(key, value);
375
394
  }
376
395
  }
396
+ // HTTP/2 requests carry the authority in `:authority`, not Host — but
397
+ // downstream consumers (CSRF origin validation) read the Host header.
398
+ if (!headers.has('host')) {
399
+ headers.set('host', host);
400
+ }
377
401
 
378
402
  const method = nodeReq.method ?? 'GET';
379
403
  const hasBody = method !== 'GET' && method !== 'HEAD';
@@ -11,8 +11,14 @@
11
11
  * Design docs: design/11-platform.md, design/25-production-deployments.md
12
12
  */
13
13
 
14
+ import { builtinModules } from 'node:module';
14
15
  import type { Plugin } from 'vite';
15
16
 
17
+ // Every Node built-in as both bare (`stream`) and prefixed (`node:stream`).
18
+ // Available at runtime on all targets (Node natively, Workers via nodejs_compat).
19
+ // Listing explicitly silences Vite's "Automatically externalized" warnings.
20
+ const NODE_BUILTINS: string[] = builtinModules.flatMap((m) => [m, `node:${m}`]);
21
+
16
22
  // Build-time-only packages that must never be bundled into the runtime
17
23
  // server environments. With `noExternal: true` rolldown otherwise follows
18
24
  // the transitive graph through the user's `timber.config.ts` (loaded by the
@@ -31,6 +37,7 @@ import type { Plugin } from 'vite';
31
37
  //
32
38
  // Mirrored on both rsc and ssr environments. See TIM-988.
33
39
  const BUILD_ONLY_EXTERNALS: string[] = [
40
+ ...NODE_BUILTINS,
34
41
  'nitro',
35
42
  'rollup',
36
43
  '@mdx-js/rollup',
@@ -128,15 +135,18 @@ export function timberServerBundle(): Plugin[] {
128
135
  // back to lazy retry on failure. This handles two failure modes:
129
136
  //
130
137
  // 1. Forward references (e.g. Zod v4): `init_iso` calls `init_schemas`
131
- // which hasn't been defined yet. Eager execution fails, but when the
132
- // function is called lazily later, all dependencies are available.
138
+ // which hasn't been defined yet. Eager execution fails silently, but
139
+ // when the function is called lazily later, all dependencies are
140
+ // available and the retry succeeds (and caches).
133
141
  //
134
142
  // 2. Optional peer dep shims (e.g. @emotion/is-prop-valid for
135
143
  // framer-motion): Vite generates shims that throw for missing
136
- // optional deps. The throw is deferred to lazy execution where
137
- // the consuming package's try/catch handles it.
144
+ // optional deps. The eager attempt swallows the throw; the lazy call
145
+ // PROPAGATES it so the consuming package's try/catch handles it.
138
146
  //
139
- // The key: on failure, `fn` is NOT cleared, so the next call retries.
147
+ // The key: only the eager attempt swallows errors. Once the helper is
148
+ // returned, lazy calls use the original Rolldown semantics — a throwing
149
+ // init propagates to the caller (and clears `fn`, matching upstream).
140
150
  // On success, `fn` is set to 0 so subsequent calls are no-ops.
141
151
  const esmInitFixPlugin: Plugin = {
142
152
  name: 'timber-esm-init-fix',
@@ -147,13 +157,19 @@ export function timberServerBundle(): Plugin[] {
147
157
  const lazy = 'var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);';
148
158
  if (!code.includes(lazy)) return null;
149
159
 
150
- // Eager-with-retry: attempt init immediately. On success, mark done
151
- // (fn = 0). On failure, leave fn intact so the lazy wrapper retries
152
- // on next call — by then forward dependencies are initialized.
160
+ // Eager-with-retry: attempt init immediately, swallowing failures
161
+ // (forward refs aren't defined yet). After the eager attempt, flip
162
+ // to lazy mode where errors propagate to the caller — consumers'
163
+ // try/catch (framer-motion's optional @emotion dep) must see them.
153
164
  const eager = [
154
165
  'var __esmMin = (fn, res) => {',
155
- ' var l = () => { if (fn) { var f = fn; try { res = f(); fn = 0; } catch(e) {} } return res; };',
166
+ ' var eager = 1;',
167
+ ' var l = () => {',
168
+ ' if (fn) { if (eager) { try { res = fn(); fn = 0; } catch(e) {} } else { res = fn(fn = 0); } }',
169
+ ' return res;',
170
+ ' };',
156
171
  ' l();',
172
+ ' eager = 0;',
157
173
  ' return l;',
158
174
  '};',
159
175
  ].join(' ');
@@ -44,19 +44,67 @@ const CLIENT_ONLY_VIRTUAL = '\0timber:client-only';
44
44
  *
45
45
  * Used by the catch-all resolveId rule to remap subpath imports to src/
46
46
  * in server environments. When adding a new subpath export to package.json,
47
- * add it here too. See TIM-568.
47
+ * add it here too — tests/shims-drift.test.ts enumerates package.json
48
+ * exports and fails if an entry is missing. See TIM-568.
49
+ *
50
+ * Exported for the drift test.
48
51
  */
49
- const SUBPATH_SRC_MAP: Record<string, string> = {
52
+ export const SUBPATH_SRC_MAP: Record<string, string> = {
50
53
  'cache': 'cache/index.ts',
54
+ 'codec': 'codec.ts',
51
55
  'cookies': 'cookies/index.ts',
56
+ 'params': 'params/index.ts',
52
57
  'search-params': 'search-params/index.ts',
53
58
  'segment-params': 'segment-params/index.ts',
54
59
 
55
60
  'adapters/cloudflare': 'adapters/cloudflare.ts',
61
+ 'adapters/cloudflare/cache': 'adapters/cloudflare-kv-cache.ts',
56
62
  'adapters/cloudflare/dev': 'adapters/cloudflare-dev.ts',
57
63
  'adapters/nitro': 'adapters/nitro.ts',
58
64
  };
59
65
 
66
+ /**
67
+ * Runtime value exports of @timber-js/app/server, stubbed in the client
68
+ * (browser) environment so a stray client-side import fails with a clear
69
+ * call-time error instead of a confusing bundler error.
70
+ *
71
+ * Must stay in sync with the actual runtime exports of server/index.ts —
72
+ * tests/shims-drift.test.ts compares this list against
73
+ * Object.keys(import('../server/index.ts')) and fails on drift.
74
+ * Type-only exports (DenyOptions, AccessContext, …) are erased at runtime
75
+ * and must NOT appear here.
76
+ */
77
+ export const SERVER_STUB_EXPORTS: readonly string[] = [
78
+ // request-context
79
+ 'getHeaders',
80
+ 'getSegmentParams',
81
+ // cookie-context
82
+ 'getCookieJar',
83
+ // primitives
84
+ 'deny',
85
+ 'redirect',
86
+ 'redirectExternal',
87
+ 'RedirectType',
88
+ 'waitUntil',
89
+ // action-client
90
+ 'createActionClient',
91
+ 'ActionError',
92
+ 'validated',
93
+ // form-data
94
+ 'coerce',
95
+ 'parseFormData',
96
+ // form-flash
97
+ 'getFormFlash',
98
+ // revalidation
99
+ 'revalidatePath',
100
+ 'revalidateTag',
101
+ // tracing
102
+ 'getTraceId',
103
+ 'getSpanId',
104
+ 'withSpan',
105
+ 'addSpanEvent',
106
+ ];
107
+
60
108
  /**
61
109
  * Map from next/* import specifiers to shim file paths.
62
110
  *
@@ -88,6 +136,16 @@ function stripJsExtension(id: string): string {
88
136
  return id.endsWith('.js') ? id.slice(0, -3) : id;
89
137
  }
90
138
 
139
+ /**
140
+ * Read the Vite environment name from a plugin hook's `this` context.
141
+ *
142
+ * Duck-typed because the hook context type doesn't expose `environment`
143
+ * in a way that survives our Plugin typing — see Vite's Environment API.
144
+ */
145
+ function envName(hookCtx: unknown): string | undefined {
146
+ return (hookCtx as { environment?: { name?: string } }).environment?.name;
147
+ }
148
+
91
149
  /**
92
150
  * Create the timber-shims Vite plugin.
93
151
  *
@@ -111,14 +169,16 @@ export function timberShims(_ctx: PluginContext): Plugin {
111
169
  * 3. Check next/* shim map
112
170
  * 4. Return null (pass through) for everything else
113
171
  *
114
- * All @timber-js/app/* subpath imports resolve to dist/ via package.json
115
- * exports in all environments. This ensures a single module instance
116
- * for each module — the dist/ chunks share code via rollup's code
117
- * splitting. No src/ remapping is needed (see TIM-568).
172
+ * In server environments (RSC + SSR), @timber-js/app/* subpath imports
173
+ * are remapped to src/ via SUBPATH_SRC_MAP and the explicit branches
174
+ * below — dist/ chunks miss Vite's RSC transforms and would duplicate
175
+ * mutable module state (see TIM-568 and the rationale comments below).
118
176
  *
119
- * The only exception is @timber-js/app/server in the client (browser)
120
- * environment, which returns a virtual stub — server code must never
121
- * be bundled into the browser.
177
+ * In the client (browser) environment, imports resolve to dist/ via
178
+ * package.json exports — except @timber-js/app/client (remapped to src/
179
+ * to share module instances with browser-entry) and @timber-js/app/server,
180
+ * which returns a virtual stub: server code must never be bundled into
181
+ * the browser.
122
182
  */
123
183
  resolveId(id: string) {
124
184
  // Poison pill packages — resolve to virtual modules handled by load()
@@ -133,8 +193,8 @@ export function timberShims(_ctx: PluginContext): Plugin {
133
193
  // functions like redirect() and deny(). This prevents server/primitives.ts
134
194
  // from being pulled into the browser bundle.
135
195
  if (cleanId in SHIM_MAP) {
136
- const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
137
- if (envName === 'client' && cleanId === 'next/navigation') {
196
+ const env = envName(this);
197
+ if (env === 'client' && cleanId === 'next/navigation') {
138
198
  return resolve(SHIMS_DIR, 'navigation-client.ts');
139
199
  }
140
200
  return SHIM_MAP[cleanId];
@@ -171,8 +231,8 @@ export function timberShims(_ctx: PluginContext): Plugin {
171
231
  // In Vite, we intercept them here to resolve to src/ for dev/build
172
232
  // correctness (same rationale as the @timber-js/app/* remap above).
173
233
  if (cleanId === '#server-internal') {
174
- const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
175
- if (envName === 'client') {
234
+ const env = envName(this);
235
+ if (env === 'client') {
176
236
  return '\0timber:server-empty';
177
237
  }
178
238
  return resolve(PKG_ROOT, 'src', 'server', 'internal.ts');
@@ -197,8 +257,8 @@ export function timberShims(_ctx: PluginContext): Plugin {
197
257
  // specifier reaches us unresolved (e.g., in a Vite build without
198
258
  // aliases), handle it the same way as the #imports above.
199
259
  if (cleanId === '@timber-js/app/server/internal') {
200
- const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
201
- if (envName === 'client') {
260
+ const env = envName(this);
261
+ if (env === 'client') {
202
262
  return '\0timber:server-empty';
203
263
  }
204
264
  return resolve(PKG_ROOT, 'src', 'server', 'internal.ts');
@@ -210,8 +270,8 @@ export function timberShims(_ctx: PluginContext): Plugin {
210
270
  }
211
271
 
212
272
  if (cleanId === '@timber-js/app/server') {
213
- const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
214
- if (envName === 'client') {
273
+ const env = envName(this);
274
+ if (env === 'client') {
215
275
  return '\0timber:server-empty';
216
276
  }
217
277
  // RSC + SSR: remap to src/
@@ -219,7 +279,7 @@ export function timberShims(_ctx: PluginContext): Plugin {
219
279
  }
220
280
 
221
281
  if (cleanId === '@timber-js/app/client') {
222
- const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
282
+ const env = envName(this);
223
283
  // RSC: return null — needs dist/ for 'use client' boundary detection.
224
284
  // SSR + Client: resolve to src/ so that browser-entry (which uses
225
285
  // relative imports from src/) and user code (which uses the bare
@@ -227,7 +287,7 @@ export function timberShims(_ctx: PluginContext): Plugin {
227
287
  // Without this, state.ts is loaded twice (src/ and dist/) and
228
288
  // setGlobalRouter() writes to one copy while getRouterOrNull()
229
289
  // reads from the other (always null).
230
- if (envName === 'ssr' || envName === 'client') {
290
+ if (env === 'ssr' || env === 'client') {
231
291
  return resolve(PKG_ROOT, 'src', 'client', 'index.ts');
232
292
  }
233
293
  return null;
@@ -236,8 +296,8 @@ export function timberShims(_ctx: PluginContext): Plugin {
236
296
  // Catch-all: remap any other @timber-js/app/<subpath> to src/
237
297
  // in non-client environments (RSC + SSR).
238
298
  if (cleanId.startsWith('@timber-js/app/') && cleanId !== '@timber-js/app/package.json') {
239
- const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
240
- if (envName !== 'client') {
299
+ const env = envName(this);
300
+ if (env !== 'client') {
241
301
  const subpath = cleanId.slice('@timber-js/app/'.length);
242
302
  const srcPath = SUBPATH_SRC_MAP[subpath];
243
303
  if (srcPath) {
@@ -257,8 +317,8 @@ export function timberShims(_ctx: PluginContext): Plugin {
257
317
  * clearly identifies the boundary violation.
258
318
  */
259
319
  load(id: string) {
260
- const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
261
- const isClient = envName === 'client';
320
+ const env = envName(this);
321
+ const isClient = env === 'client';
262
322
 
263
323
  if (id === SERVER_ONLY_VIRTUAL) {
264
324
  if (isClient) {
@@ -288,41 +348,23 @@ export function timberShims(_ctx: PluginContext): Plugin {
288
348
  if (id === '\0timber:server-empty') {
289
349
  // Export named stubs instead of throwing at evaluation time.
290
350
  // Throwing at eval breaks the module system — the browser can't
291
- // resolve named imports like `import { notFound } from '...'`.
351
+ // resolve named imports like `import { deny } from '...'`.
292
352
  // Instead, each stub throws at call time with a clear message.
353
+ // The export names are generated from SERVER_STUB_EXPORTS so the
354
+ // list cannot drift from server/index.ts (drift test enforced).
355
+ // RedirectType is a plain const object on the server, so it is
356
+ // mirrored as one here — libraries read RedirectType.push as a value.
357
+ const stubExports = SERVER_STUB_EXPORTS.map((name) =>
358
+ name === 'RedirectType'
359
+ ? `export const RedirectType = { push: 'push', replace: 'replace' };`
360
+ : `export const ${name} = stub;`
361
+ ).join('\n');
293
362
  return `
294
363
  const msg = "[timber] @timber-js/app/server was imported from client code. " +
295
364
  "Server modules (headers, cookies, redirect, deny, etc.) cannot be used in client components. " +
296
365
  "If you need these APIs, move the import to a server component or middleware.";
297
366
  function stub() { throw new Error(msg); }
298
- export const headers = stub;
299
- export const cookies = stub;
300
- export const getHeaders = stub;
301
- export const getHeader = stub;
302
- export const getCookieJar = stub;
303
- export const getCookie = stub;
304
- export const getSearchParams = stub;
305
- export const getSegmentParams = stub;
306
- export const searchParams = stub;
307
- export const deny = stub;
308
- export const notFound = stub;
309
- export const redirect = stub;
310
- export const permanentRedirect = stub;
311
- export const redirectExternal = stub;
312
- export const waitUntil = stub;
313
- export const DenyOptions = {};
314
- export const RedirectType = {};
315
- export const DenySignal = stub;
316
- export const RedirectSignal = stub;
317
- export const createPipeline = stub;
318
- export const revalidatePath = stub;
319
- export const revalidateTag = stub;
320
- export const createActionClient = stub;
321
- export const ActionError = stub;
322
- export const validated = stub;
323
- export const getFormFlash = stub;
324
- export const parseFormData = stub;
325
- export const coerce = stub;
367
+ ${stubExports}
326
368
  `;
327
369
  }
328
370
  },