@timber-js/app 0.2.0-alpha.163 → 0.2.0-alpha.164

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 (102) hide show
  1. package/dist/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
  2. package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
  4. package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
  7. package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
  8. package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
  9. package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
  10. package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
  11. package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
  12. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  13. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  14. package/dist/adapters/nitro.d.ts +11 -0
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +77 -64
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.d.ts +3 -0
  19. package/dist/cache/index.d.ts.map +1 -1
  20. package/dist/cache/index.js +1 -1
  21. package/dist/cache/redis-handler.d.ts +1 -0
  22. package/dist/cache/redis-handler.d.ts.map +1 -1
  23. package/dist/cache/tag-aware-handler.d.ts +1 -0
  24. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/cli.js +2 -2
  27. package/dist/client/slot-context.d.ts +29 -0
  28. package/dist/client/slot-context.d.ts.map +1 -0
  29. package/dist/client/slot-outlet.d.ts +16 -0
  30. package/dist/client/slot-outlet.d.ts.map +1 -0
  31. package/dist/client/slot-provider.d.ts +20 -0
  32. package/dist/client/slot-provider.d.ts.map +1 -0
  33. package/dist/config-types.d.ts +2 -1
  34. package/dist/config-types.d.ts.map +1 -1
  35. package/dist/dev-tools/logs.d.ts.map +1 -1
  36. package/dist/index.js +293 -124
  37. package/dist/index.js.map +1 -1
  38. package/dist/plugin-context.d.ts +27 -0
  39. package/dist/plugin-context.d.ts.map +1 -1
  40. package/dist/plugins/cache.d.ts.map +1 -1
  41. package/dist/plugins/client-chunks.d.ts.map +1 -1
  42. package/dist/plugins/dev-server.d.ts.map +1 -1
  43. package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
  44. package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
  45. package/dist/plugins/prebuilt.d.ts.map +1 -1
  46. package/dist/plugins/prerender-sugar.d.ts.map +1 -1
  47. package/dist/routing/index.js +2 -2
  48. package/dist/server/html-injector-core.d.ts +30 -9
  49. package/dist/server/html-injector-core.d.ts.map +1 -1
  50. package/dist/server/html-injectors.d.ts.map +1 -1
  51. package/dist/server/index.js +1 -1
  52. package/dist/server/internal.js +346 -51
  53. package/dist/server/internal.js.map +1 -1
  54. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  55. package/dist/server/pipeline-phases.d.ts.map +1 -1
  56. package/dist/server/prebuilt/cache-key.d.ts +4 -0
  57. package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
  58. package/dist/server/prebuilt/key-discipline.d.ts +23 -0
  59. package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
  60. package/dist/server/prebuilt/slots.d.ts +74 -0
  61. package/dist/server/prebuilt/slots.d.ts.map +1 -0
  62. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  63. package/dist/server/prebuilt-runtime.d.ts +12 -2
  64. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  65. package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
  66. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
  67. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  68. package/docs/api/34-api-config.mdx +165 -3
  69. package/docs/learn/00-introduction.mdx +78 -44
  70. package/docs/learn/13-configuration.mdx +27 -8
  71. package/package.json +3 -2
  72. package/src/adapters/cloudflare-kv-cache.ts +5 -1
  73. package/src/adapters/nitro.ts +82 -64
  74. package/src/cache/index.ts +25 -2
  75. package/src/cache/redis-handler.ts +27 -7
  76. package/src/cache/tag-aware-handler.ts +5 -1
  77. package/src/cache/timber-cache.ts +21 -10
  78. package/src/client/slot-context.ts +48 -0
  79. package/src/client/slot-outlet.tsx +22 -0
  80. package/src/client/slot-provider.tsx +25 -0
  81. package/src/config-types.ts +2 -1
  82. package/src/dev-tools/logs.ts +7 -0
  83. package/src/plugin-context.ts +54 -0
  84. package/src/plugins/cache.ts +1 -2
  85. package/src/plugins/client-chunks.ts +42 -1
  86. package/src/plugins/dev-server.ts +12 -69
  87. package/src/plugins/prebuilt-options-analysis.ts +175 -0
  88. package/src/plugins/prebuilt.ts +82 -127
  89. package/src/plugins/prerender-sugar.ts +1 -2
  90. package/src/server/html-injector-core.ts +85 -27
  91. package/src/server/html-injectors.ts +5 -1
  92. package/src/server/node-stream-transforms.ts +6 -1
  93. package/src/server/pipeline-phases.ts +4 -1
  94. package/src/server/prebuilt/cache-key.ts +74 -0
  95. package/src/server/prebuilt/key-discipline.ts +53 -0
  96. package/src/server/prebuilt/slots.ts +167 -0
  97. package/src/server/prebuilt-builder.ts +57 -23
  98. package/src/server/prebuilt-runtime.ts +144 -60
  99. package/src/server/rsc-entry/deny-fallback.ts +92 -0
  100. package/src/server/rsc-entry/index.ts +16 -70
  101. package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
  102. package/dist/_chunks/plugin-context-DeAxFRMq.js.map +0 -1
@@ -122,14 +122,33 @@ Server actions still work — HTML forms submit natively via POST without JavaSc
122
122
 
123
123
  ## All Options
124
124
 
125
- | Option | Type | Default | Description |
126
- | ------------------ | ----------------------- | ---------------------------- | ------------------------- |
127
- | `output` | `'server' \| 'static'` | `'server'` | Output mode |
128
- | `adapter` | `TimberPlatformAdapter` | — | Deployment adapter |
129
- | `cacheHandler` | `CacheHandler` | `MemoryCacheHandler` | Cache backend |
130
- | `clientJavascript` | `boolean \| object` | `true` | Control client-side JS |
131
- | `pageExtensions` | `string[]` | `['tsx', 'ts', 'jsx', 'js']` | File extensions for pages |
132
- | `mdx` | `object` | — | MDX remark/rehype plugins |
125
+ | Option | Type | Default | Description |
126
+ | ------------------- | --------------------------- | ---------------------------- | ---------------------------------------- |
127
+ | `output` | `'server' \| 'static'` | `'server'` | Output mode |
128
+ | `debug` | `boolean` | `false` | Enable timber debug logging in prod |
129
+ | `buildDir` | `string` | `'.timber/dist'` | Build output directory |
130
+ | `clientJavascript` | `boolean \| object` | `true` | Control client-side JS |
131
+ | `adapter` | `TimberPlatformAdapter` | — | Deployment adapter |
132
+ | `cacheHandler` | `CacheHandler` | `MemoryCacheHandler` | Cache backend |
133
+ | `cdnPurge` | `CdnPurgeHandler` | — | CDN purge on revalidation |
134
+ | `serverTiming` | `'detailed' \| 'total' \| false` | `'detailed'` / `'total'` | Server-Timing header |
135
+ | `allowedOrigins` | `string[]` | — | CORS / CSRF allowed origins |
136
+ | `csrf` | `boolean` | `true` | CSRF protection |
137
+ | `limits` | `object` | — | Request body size limits |
138
+ | `actions` | `object` | — | Server action behavior |
139
+ | `forms` | `object` | — | Form handling (sensitive field stripping) |
140
+ | `pageExtensions` | `string[]` | `['tsx', 'ts', 'jsx', 'js']` | File extensions for pages |
141
+ | `slowRequestMs` | `number` | `3000` | Slow request warning threshold (ms) |
142
+ | `renderTimeoutMs` | `number` | `30000` | Render abort timeout (ms) |
143
+ | `devBrowserLogs` | `string` | `'warn'` | Forward browser console to server in dev |
144
+ | `dev` | `object` | — | Dev-mode options |
145
+ | `budget` | `object` | — | Build-time performance budgets |
146
+ | `appDir` | `string` | auto-detected | Override app directory location |
147
+ | `mdx` | `object` | — | MDX remark/rehype plugins |
148
+ | `actionEncryption` | `object` | — | Server action bound args encryption |
149
+ | `reactCompiler` | `boolean \| object` | `false` | React Compiler auto-memoization |
150
+ | `sitemap` | `object` | — | Auto-generated sitemap.xml |
151
+ | `topLoader` | `object` | enabled | Navigation progress bar |
133
152
 
134
153
  For the full type definition, see the [Config API Reference](/docs/api-config).
135
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.163",
3
+ "version": "0.2.0-alpha.164",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -156,7 +156,8 @@
156
156
  "@opentelemetry/sdk-trace-base": "^2.8.0",
157
157
  "cookie": "^1.1.1",
158
158
  "magic-string": "^0.30.21",
159
- "nitro": "3.0.260610-beta"
159
+ "nitro": "3.0.260610-beta",
160
+ "srvx": "^0.11.17"
160
161
  },
161
162
  "peerDependencies": {
162
163
  "@content-collections/core": "^0.14.0 || ^0.15.0",
@@ -112,7 +112,11 @@ export class CloudflareKVCacheHandler implements CacheHandler {
112
112
  return { value: entry.value, stale };
113
113
  }
114
114
 
115
- async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
115
+ async set(
116
+ key: string,
117
+ value: unknown,
118
+ opts: { ttl: number; tags: string[]; generation?: number }
119
+ ): Promise<void> {
116
120
  const kv = this.getKV();
117
121
  const entry: KVCacheEntry = {
118
122
  value,
@@ -195,11 +195,14 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
195
195
  publicDirName: 'public',
196
196
  });
197
197
 
198
- // Write the compression helper module for runtime use.
198
+ // Write runtime helper modules used by the preview server.
199
199
  // See design/25-production-deployments.md — self-hosted deployments
200
200
  // need application-level compression (Cloudflare handles it at the edge).
201
201
  await writeFile(join(outDir, '_compress.mjs'), await generateCompressModule());
202
202
 
203
+ // Web→Node response bridge with backpressure. See TIM-1154.
204
+ await writeFile(join(outDir, '_send-response.mjs'), generateSendResponseModule());
205
+
203
206
  // Prepend the manifest assignment directly into the RSC entry so
204
207
  // globalThis.__TIMBER_BUILD_MANIFEST__ is set before any module reads it.
205
208
  // This must be top-level code, not an import, because rollup tree-shakes
@@ -378,6 +381,9 @@ const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}'
378
381
  // Import compression helper for self-hosted response compression.
379
382
  const { compressResponse } = await import('./_compress.mjs');
380
383
 
384
+ // Web→Node response bridge with backpressure (TIM-1154).
385
+ const { sendNodeResponse } = await import('./_send-response.mjs');
386
+
381
387
  const MIME_TYPES = {
382
388
  '.html': 'text/html',
383
389
  '.js': 'application/javascript',
@@ -414,7 +420,7 @@ const publicDir = join(__dirname, '${publicDir}');
414
420
  const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : null;
415
421
  const portIsExplicit = envPort != null && Number.isFinite(envPort) && envPort > 0;
416
422
  const startPort = portIsExplicit ? envPort : 3000;
417
- const host = process.env.HOST || process.env.HOSTNAME || 'localhost';
423
+ const host = process.env.HOST || 'localhost';
418
424
 
419
425
  // Set after listenWithBump() resolves so request handlers can build
420
426
  // absolute URLs from the actual bound port (which may differ from
@@ -501,68 +507,11 @@ const server = createServer(async (req, res) => {
501
507
  // Compress the response for self-hosted deployments.
502
508
  const webResponse = compressResponse(webRequest, rawResponse);
503
509
 
504
- // Write the response back to the Node response.
505
- //
506
- // Set-Cookie needs special handling: Headers.entries() joins multiple
507
- // Set-Cookie values with ", " into a single entry, but each cookie must
508
- // be its own header per RFC 6265 §4.1. Object.fromEntries() would then
509
- // collapse them into one malformed Set-Cookie header that browsers reject
510
- // (or only honor partially). Use getSetCookie() to preserve individual
511
- // values, and pass them as an array — Node's writeHead accepts
512
- // Record<string, string | string[]> and emits one header per array entry.
513
- //
514
- // Without this, EVERY cookie set by the framework on Node—/Nitro deployments
515
- // is silently dropped: defineCookie().setCookie() in actions, middleware
516
- // cookie writes, the framework's own session cookies, all of it. See
517
- // LOCAL-741.
518
- const responseHeaders = {};
519
- webResponse.headers.forEach((value, key) => {
520
- if (key.toLowerCase() !== 'set-cookie') {
521
- responseHeaders[key] = value;
522
- }
523
- });
524
- const setCookies = webResponse.headers.getSetCookie();
525
- if (setCookies.length > 0) {
526
- responseHeaders['set-cookie'] = setCookies;
527
- }
528
- res.writeHead(webResponse.status, responseHeaders);
529
-
530
- if (webResponse.body) {
531
- const reader = webResponse.body.getReader();
532
-
533
- // Cancel the reader when the client disconnects. This causes any
534
- // pending reader.read() to reject, breaking the pump loop. Critical
535
- // for SSE and other infinite streams — without this, disconnected
536
- // clients leak readers.
537
- let clientDisconnected = false;
538
- const onClose = () => {
539
- clientDisconnected = true;
540
- reader.cancel('Client disconnected').catch(() => {});
541
- };
542
- res.on('close', onClose);
543
-
544
- try {
545
- while (true) {
546
- const { done, value } = await reader.read();
547
- if (done) break;
548
- res.write(value);
549
- }
550
- } catch (err) {
551
- // reader.cancel() from the close handler causes read() to reject.
552
- // This is expected on client disconnect — not an error.
553
- if (!clientDisconnected) {
554
- throw err;
555
- }
556
- } finally {
557
- res.off('close', onClose);
558
- reader.releaseLock();
559
- if (!res.writableEnded) {
560
- res.end();
561
- }
562
- }
563
- } else {
564
- res.end();
565
- }
510
+ // Write the response to Node's ServerResponse. sendNodeResponse handles
511
+ // status, headers (including Set-Cookie splitting via [...headers]),
512
+ // body streaming with backpressure, and client disconnect cleanup.
513
+ // See TIM-1154.
514
+ await sendNodeResponse(res, webResponse);
566
515
  } catch (err) {
567
516
  console.error('[timber preview] Request error:', err);
568
517
  if (!res.headersSent) {
@@ -702,6 +651,75 @@ function spawnNitroPreview(command: string, args: string[], cwd: string): Promis
702
651
  });
703
652
  }
704
653
 
654
+ // ─── Send Response Module ───────────────────────────────────────────────────
655
+
656
+ /**
657
+ * Generate a standalone ESM module that exports sendNodeResponse.
658
+ *
659
+ * Mirrors srvx/node's sendNodeResponse: status + headers via writeHead,
660
+ * body streaming with backpressure (waits for drain on write() === false),
661
+ * and client disconnect cleanup. Written to `_send-response.mjs` during
662
+ * buildOutput for use by the preview server script. See TIM-1154.
663
+ *
664
+ * @internal Exported for testing.
665
+ */
666
+ export function generateSendResponseModule(): string {
667
+ return `// Generated by @timber-js/app — Web→Node response bridge.
668
+ // Do not edit — this file is regenerated on each build.
669
+ // Mirrors srvx/node's sendNodeResponse with backpressure support.
670
+
671
+ export function sendNodeResponse(nodeRes, webRes) {
672
+ if (!webRes) {
673
+ nodeRes.statusCode = 500;
674
+ return new Promise((resolve) => nodeRes.end(resolve));
675
+ }
676
+ const rawHeaders = [...webRes.headers];
677
+ const writeHeaders = rawHeaders.flat();
678
+ if (!nodeRes.headersSent) {
679
+ if (nodeRes.req?.httpVersion === '2.0') {
680
+ nodeRes.writeHead(webRes.status, writeHeaders);
681
+ } else {
682
+ nodeRes.writeHead(webRes.status, webRes.statusText, writeHeaders);
683
+ }
684
+ }
685
+ if (!webRes.body) {
686
+ return new Promise((resolve) => nodeRes.end(resolve));
687
+ }
688
+ // Stream with backpressure: pause reading when the kernel buffer is
689
+ // full (write() returns false) and resume on 'drain'.
690
+ if (nodeRes.destroyed) {
691
+ webRes.body.cancel();
692
+ return;
693
+ }
694
+ const reader = webRes.body.getReader();
695
+ function streamCancel(error) {
696
+ reader.cancel(error).catch(() => {});
697
+ if (error) nodeRes.destroy(error);
698
+ }
699
+ function streamHandle({ done, value }) {
700
+ try {
701
+ if (done) {
702
+ nodeRes.end();
703
+ } else if (nodeRes.write(value)) {
704
+ reader.read().then(streamHandle, streamCancel);
705
+ } else {
706
+ nodeRes.once('drain', () => reader.read().then(streamHandle, streamCancel));
707
+ }
708
+ } catch (error) {
709
+ streamCancel(error instanceof Error ? error : undefined);
710
+ }
711
+ }
712
+ nodeRes.on('close', streamCancel);
713
+ nodeRes.on('error', streamCancel);
714
+ reader.read().then(streamHandle, streamCancel);
715
+ return reader.closed.catch(streamCancel).finally(() => {
716
+ nodeRes.off('close', streamCancel);
717
+ nodeRes.off('error', streamCancel);
718
+ });
719
+ }
720
+ `;
721
+ }
722
+
705
723
  // ─── Helpers ─────────────────────────────────────────────────────────────────
706
724
 
707
725
  /**
@@ -4,7 +4,11 @@ import { estimateByteSize } from './sizeof.js';
4
4
 
5
5
  export interface CacheHandler {
6
6
  get(key: string): Promise<{ value: unknown; stale: boolean } | null>;
7
- set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void>;
7
+ set(
8
+ key: string,
9
+ value: unknown,
10
+ opts: { ttl: number; tags: string[]; generation?: number }
11
+ ): Promise<void>;
8
12
  invalidate(opts: { key?: string; tag?: string }): Promise<void>;
9
13
  }
10
14
 
@@ -38,6 +42,7 @@ export class MemoryCacheHandler implements CacheHandler {
38
42
  string,
39
43
  { value: unknown; expiresAt: number; tags: string[]; byteSize: number }
40
44
  >();
45
+ private generations = new Map<string, number>();
41
46
  private maxEntries: number;
42
47
  private maxBytes: number | undefined;
43
48
  private maxEntryBytes: number | undefined;
@@ -64,7 +69,17 @@ export class MemoryCacheHandler implements CacheHandler {
64
69
  return { value: entry.value, stale };
65
70
  }
66
71
 
67
- async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }) {
72
+ async set(
73
+ key: string,
74
+ value: unknown,
75
+ opts: { ttl: number; tags: string[]; generation?: number }
76
+ ) {
77
+ // CAS guard: skip write if a newer generation has already been written
78
+ if (opts.generation !== undefined) {
79
+ const current = this.generations.get(key) ?? 0;
80
+ if (opts.generation < current) return;
81
+ }
82
+
68
83
  const byteSize = this._trackBytes ? estimateByteSize(value) : 0;
69
84
 
70
85
  // Reject entries exceeding per-entry byte limit
@@ -95,6 +110,11 @@ export class MemoryCacheHandler implements CacheHandler {
95
110
  }
96
111
  }
97
112
 
113
+ // Record generation only after all admission checks pass
114
+ if (opts.generation !== undefined) {
115
+ this.generations.set(key, opts.generation);
116
+ }
117
+
98
118
  this.store.set(key, {
99
119
  value,
100
120
  expiresAt: Date.now() + opts.ttl * 1000,
@@ -111,12 +131,14 @@ export class MemoryCacheHandler implements CacheHandler {
111
131
  this.currentBytes -= entry.byteSize;
112
132
  this.store.delete(opts.key);
113
133
  }
134
+ this.generations.delete(opts.key);
114
135
  }
115
136
  if (opts.tag) {
116
137
  for (const [key, entry] of this.store) {
117
138
  if (entry.tags.includes(opts.tag)) {
118
139
  this.currentBytes -= entry.byteSize;
119
140
  this.store.delete(key);
141
+ this.generations.delete(key);
120
142
  }
121
143
  }
122
144
  }
@@ -139,6 +161,7 @@ export class MemoryCacheHandler implements CacheHandler {
139
161
  const entry = this.store.get(oldest)!;
140
162
  this.currentBytes -= entry.byteSize;
141
163
  this.store.delete(oldest);
164
+ this.generations.delete(oldest);
142
165
  }
143
166
  }
144
167
  }
@@ -141,7 +141,11 @@ export class RedisCacheHandler implements CacheHandler {
141
141
  return { value: entry.value, stale };
142
142
  }
143
143
 
144
- async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
144
+ async set(
145
+ key: string,
146
+ value: unknown,
147
+ opts: { ttl: number; tags: string[]; generation?: number }
148
+ ): Promise<void> {
145
149
  const ck = this.cacheKey(key);
146
150
  const expiresAt = Date.now() + opts.ttl * 1000;
147
151
  const payload = JSON.stringify({ value, expiresAt, tags: opts.tags });
@@ -186,13 +190,29 @@ export class RedisCacheHandler implements CacheHandler {
186
190
  const tk = this.tagKey(opts.tag);
187
191
  const keys = await this.client.smembers(tk);
188
192
 
189
- // One del per key: the only multi-key shape every client accepts.
190
- // ioredis takes del(...keys), node-redis takes del(key | key[]), and
191
- // @upstash/redis takes del(...keys) but breaks on an array argument.
192
- await Promise.all(keys.map((k) => this.client.del(this.cacheKey(k))));
193
+ // Re-check each member before deleting — the tag set can contain stale
194
+ // memberships from entries whose tags changed since they were added.
195
+ // Mirrors TagAwareCacheHandler's eager strategy (tag-aware-handler.ts:183-198).
196
+ await Promise.all(
197
+ keys.map(async (k) => {
198
+ const raw = await this.client.get(this.cacheKey(k));
199
+ if (raw === null) {
200
+ await this.client.srem(tk, k);
201
+ return;
202
+ }
203
+ const entry = JSON.parse(raw) as { tags?: string[] };
204
+ if (entry.tags?.includes(opts.tag!)) {
205
+ await this.client.del(this.cacheKey(k));
206
+ }
207
+ await this.client.srem(tk, k);
208
+ })
209
+ );
193
210
 
194
- // Clean up the tag set itself
195
- await this.client.del(tk);
211
+ // Clean up the now-empty tag set so it doesn't consume memory
212
+ const remaining = await this.client.smembers(tk);
213
+ if (remaining.length === 0) {
214
+ await this.client.del(tk);
215
+ }
196
216
  }
197
217
  }
198
218
  }
@@ -105,7 +105,11 @@ export class TagAwareCacheHandler implements CacheHandler {
105
105
  return { value: entry.value, stale };
106
106
  }
107
107
 
108
- async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
108
+ async set(
109
+ key: string,
110
+ value: unknown,
111
+ opts: { ttl: number; tags: string[]; generation?: number }
112
+ ): Promise<void> {
109
113
  const physicalTtl = Math.max(opts.ttl * 2 + 60, 120);
110
114
 
111
115
  if (this.strategy === 'native') {
@@ -13,6 +13,22 @@ import { fnv1aHash } from './fast-hash.js';
13
13
 
14
14
  let defaultSingleflight = createSingleflight();
15
15
 
16
+ /**
17
+ * Per-key monotonic generation counter for CAS writes (TIM-1158).
18
+ *
19
+ * Unbounded, like the singleflight map — the key space is the same set of
20
+ * cache keys the handler already tracks. Pruning was removed because it
21
+ * desyncs from MemoryCacheHandler.generations: a pruned key restarts at 1
22
+ * while the handler still holds the old higher generation, causing fresh
23
+ * writes to be rejected as stale.
24
+ */
25
+ const writeGenerations = new Map<string, number>();
26
+ function nextGeneration(key: string): number {
27
+ const gen = (writeGenerations.get(key) ?? 0) + 1;
28
+ writeGenerations.set(key, gen);
29
+ return gen;
30
+ }
31
+
16
32
  /**
17
33
  * Set the timeout for the module-level default singleflight.
18
34
  * Called at framework boot with renderTimeoutMs from timber.config.ts so that
@@ -164,21 +180,16 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
164
180
  * - the key/tags were invalidated after fn started (TIM-1028), or
165
181
  * - the singleflight timed out (signal aborted) — the timed-out
166
182
  * flight's write could overwrite a newer value from a subsequent
167
- * flight (TIM-1092).
168
- *
169
- * Residual race: if fn() completes within the timeout but
170
- * handler.set() is slow enough that the timeout fires during the
171
- * set, the write lands with stale data. This window is narrow
172
- * (requires set latency to straddle the timeout boundary) and
173
- * self-healing (next cache miss triggers a fresh write). Fully
174
- * closing it requires CAS/versioned writes on CacheHandler —
175
- * tracked in TIM-1158.
183
+ * flight (TIM-1092), or
184
+ * - the handler rejects the write because a newer generation has
185
+ * already been stored (TIM-1158 CAS guard).
176
186
  */
177
187
  const executeAndStore = async (signal: AbortSignal): Promise<Awaited<ReturnType<Fn>>> => {
178
188
  const startEpoch = currentInvalidationEpoch();
189
+ const generation = nextGeneration(key);
179
190
  const result = await fn(...args);
180
191
  if (!signal.aborted && !wasInvalidatedSince(startEpoch, key, tags)) {
181
- await getHandler().set(key, result, { ttl: opts.ttl, tags });
192
+ await getHandler().set(key, result, { ttl: opts.ttl, tags, generation });
182
193
  }
183
194
  return result;
184
195
  };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * SlotContext — delivers live slot content to SlotOutlet holes inside
3
+ * cached component shells (TIM-1173).
4
+ *
5
+ * A `cache.component(Fn, { slots: [...] })` shell is captured with a
6
+ * stable <SlotOutlet slot={name} /> client reference in place of each
7
+ * declared slot prop. At request time the revived shell is wrapped in a
8
+ * SlotsProvider carrying the live slot values; each outlet reads its
9
+ * value from this context by name.
10
+ *
11
+ * The value is a per-instance record — the NEAREST provider wins, so two
12
+ * instances of the same cached component on one page each resolve their
13
+ * own children, and a slot component nested inside another slot
14
+ * component's children reads its own provider, not the outer one.
15
+ *
16
+ * Generalizes the ChildSegmentContext pattern (TIM-1181) from a single
17
+ * implicit `children` hole on layouts to explicitly declared, named slot
18
+ * props on any cached component.
19
+ *
20
+ * SINGLETON GUARANTEE: globalThis + Symbol.for — the RSC client bundler
21
+ * can duplicate this module across chunks; globalThis guarantees a single
22
+ * context instance. Same pattern as ChildSegmentContext.
23
+ *
24
+ * See design/45-cache-lifetimes.md §Slot Components.
25
+ */
26
+
27
+ 'use client';
28
+
29
+ import React, { type ReactNode } from 'react';
30
+
31
+ export type SlotValues = Record<string, ReactNode>;
32
+
33
+ const CTX_KEY = Symbol.for('__timber_slot_ctx');
34
+
35
+ function getOrCreateContext(): React.Context<SlotValues | null> {
36
+ const existing = (globalThis as Record<symbol, unknown>)[CTX_KEY] as
37
+ | React.Context<SlotValues | null>
38
+ | undefined;
39
+ if (existing !== undefined) return existing;
40
+ if (typeof React.createContext === 'function') {
41
+ const ctx = React.createContext<SlotValues | null>(null);
42
+ (globalThis as Record<symbol, unknown>)[CTX_KEY] = ctx;
43
+ return ctx;
44
+ }
45
+ return undefined as unknown as React.Context<SlotValues | null>;
46
+ }
47
+
48
+ export const SlotContext = getOrCreateContext();
@@ -0,0 +1,22 @@
1
+ /**
2
+ * SlotOutlet — the hole in a cached component shell (TIM-1173).
3
+ *
4
+ * At capture time the prebuilt runtime renders the wrapped component with
5
+ * <SlotOutlet slot={name} /> in place of each declared slot prop. Being a
6
+ * client component, it serializes into the flight payload as a stable,
7
+ * deterministic client reference — the same bytes regardless of what live
8
+ * content will fill the hole. At request time the revived shell sits under
9
+ * a SlotsProvider and each outlet reads its live value from SlotContext.
10
+ *
11
+ * See design/45-cache-lifetimes.md §Slot Components.
12
+ */
13
+
14
+ 'use client';
15
+
16
+ import { useContext } from 'react';
17
+ import { SlotContext } from './slot-context.js';
18
+
19
+ export function SlotOutlet({ slot }: { slot: string }) {
20
+ const values = useContext(SlotContext);
21
+ return values ? (values[slot] ?? null) : null;
22
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * SlotsProvider — wraps a revived cached shell to supply live slot content
3
+ * to the SlotOutlet holes inside it (TIM-1173).
4
+ *
5
+ * Tree structure per cached-component instance:
6
+ * SlotsProvider(values={children: <Live />})
7
+ * └── revived shell
8
+ * └── SlotOutlet(slot="children") → reads context → <Live />
9
+ *
10
+ * See design/45-cache-lifetimes.md §Slot Components.
11
+ */
12
+
13
+ 'use client';
14
+
15
+ import { createElement, type ReactNode } from 'react';
16
+ import { SlotContext, type SlotValues } from './slot-context.js';
17
+
18
+ interface SlotsProviderProps {
19
+ values: SlotValues;
20
+ children: ReactNode;
21
+ }
22
+
23
+ export function SlotsProvider({ values, children }: SlotsProviderProps) {
24
+ return createElement(SlotContext.Provider, { value: values }, children);
25
+ }
@@ -137,7 +137,8 @@ export interface TimberUserConfig {
137
137
  * the connection is closed. Protects against hung fetches and Suspense
138
138
  * components that never resolve.
139
139
  *
140
- * Set to 0 to disable (not recommended in production).
140
+ * Setting 0 uses a 120-second safety ceiling rather than disabling
141
+ * the timeout entirely.
141
142
  * Default: 30000 (30 seconds).
142
143
  *
143
144
  * See design/02-rendering-pipeline.md §"Streaming Constraints".
@@ -228,6 +228,13 @@ function patchConsole(server: ViteDevServer, projectRoot: string): () => void {
228
228
  // Server runtime logs (render errors, action errors, etc.) are preserved.
229
229
  if (isFrameworkInternalCaller()) return;
230
230
 
231
+ // Break the server→browser→server console forwarding loop. Vite's
232
+ // forwardConsole re-logs browser console output on the server via
233
+ // `logger.error("[console.error] ...")`. Our patch would send it back
234
+ // to the browser, Vite's client patch forwards it again → exponential
235
+ // log spam. Detect the `[console.<level>]` prefix Vite adds and skip.
236
+ if (typeof args[0] === 'string' && args[0].includes('[console.')) return;
237
+
231
238
  // Serialize and forward to browser
232
239
  try {
233
240
  const payload: ServerLogPayload = {
@@ -11,6 +11,7 @@
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { createRequire } from 'node:module';
14
+ import { parseAst } from 'vite';
14
15
  import type { RouteTree } from './routing/types';
15
16
  import type { BuildManifest } from './server/build-manifest';
16
17
  import type { CaptureSummary } from './server/prebuilt-builder';
@@ -18,6 +19,7 @@ import type { StartupTimer } from './utils/startup-timer';
18
19
  import { createStartupTimer } from './utils/startup-timer';
19
20
  import type { TimberUserConfig, ClientJavascriptConfig } from './config-types.js';
20
21
  import type { HoldingServer } from './dev-tools/holding-server.js';
22
+ import type { ProgramNode } from './plugins/callsite-ast.js';
21
23
 
22
24
  // Re-export for sub-plugin convenience — they import from plugin-context.ts
23
25
  export type { TimberUserConfig, ClientJavascriptConfig } from './config-types.js';
@@ -147,6 +149,57 @@ export interface PluginContext {
147
149
  prebuiltLazyChunks?: Map<string, string>;
148
150
  /** Post-build prebuilt capture summary (populated by timber-prebuilt, consumed by build-report). */
149
151
  captureSummary?: CaptureSummary;
152
+ /**
153
+ * Content-keyed AST parse memo shared across cache/prebuilt/prerender-sugar
154
+ * transforms. Collapses both cross-plugin and cross-environment duplicate
155
+ * parses: identical code hits, rewritten code misses (correct because Vite
156
+ * transforms chain — a rewrite produces different code). Bounded LRU (32
157
+ * entries) so it doesn't hold programs for the whole build.
158
+ *
159
+ * Consumers MUST treat the returned ProgramNode as read-only — they collect
160
+ * ranges and rewrite CODE via MagicString; the AST itself is never mutated.
161
+ */
162
+ parseCached?: ParseMemo;
163
+ }
164
+
165
+ // ── AST parse memo ───────────────────────────────────────────────────────
166
+
167
+ const PARSE_MEMO_CAPACITY = 32;
168
+
169
+ /**
170
+ * Content-keyed LRU memo for `parseAst`. Transforms for the same module run
171
+ * back-to-back across plugins and environments, so a tiny window captures the
172
+ * wins. The returned ProgramNode is shared — callers MUST NOT mutate it.
173
+ */
174
+ export class ParseMemo {
175
+ private cache = new Map<string, ProgramNode>();
176
+
177
+ /**
178
+ * Parse code, returning a cached ProgramNode if the same code was parsed
179
+ * before. The returned AST MUST NOT be mutated — it may be shared across
180
+ * multiple consumers.
181
+ */
182
+ parse(code: string): ProgramNode {
183
+ const existing = this.cache.get(code);
184
+ if (existing) {
185
+ // LRU refresh: move to end
186
+ this.cache.delete(code);
187
+ this.cache.set(code, existing);
188
+ return existing;
189
+ }
190
+ const program = parseAst(code, { lang: 'tsx' }) as unknown as ProgramNode;
191
+ if (this.cache.size >= PARSE_MEMO_CAPACITY) {
192
+ // Evict oldest (first inserted)
193
+ const firstKey = this.cache.keys().next().value!;
194
+ this.cache.delete(firstKey);
195
+ }
196
+ this.cache.set(code, program);
197
+ return program;
198
+ }
199
+
200
+ get size(): number {
201
+ return this.cache.size;
202
+ }
150
203
  }
151
204
 
152
205
  // ── App directory resolution ──────────────────────────────────────────────
@@ -197,6 +250,7 @@ export function createPluginContext(config?: TimberUserConfig, root?: string): P
197
250
  timer: createStartupTimer(),
198
251
  holdingServer: null,
199
252
  buildDir: resolveBuildDir(projectRoot, resolvedConfig.buildDir),
253
+ parseCached: new ParseMemo(),
200
254
  };
201
255
  }
202
256