@timber-js/app 0.2.0-alpha.161 → 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 (113) 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/internal.js +1 -2
  28. package/dist/client/internal.js.map +1 -1
  29. package/dist/client/segment-cache.d.ts.map +1 -1
  30. package/dist/client/slot-context.d.ts +29 -0
  31. package/dist/client/slot-context.d.ts.map +1 -0
  32. package/dist/client/slot-outlet.d.ts +16 -0
  33. package/dist/client/slot-outlet.d.ts.map +1 -0
  34. package/dist/client/slot-provider.d.ts +20 -0
  35. package/dist/client/slot-provider.d.ts.map +1 -0
  36. package/dist/config-types.d.ts +2 -1
  37. package/dist/config-types.d.ts.map +1 -1
  38. package/dist/dev-tools/logs.d.ts.map +1 -1
  39. package/dist/index.js +293 -124
  40. package/dist/index.js.map +1 -1
  41. package/dist/plugin-context.d.ts +27 -0
  42. package/dist/plugin-context.d.ts.map +1 -1
  43. package/dist/plugins/cache.d.ts.map +1 -1
  44. package/dist/plugins/client-chunks.d.ts.map +1 -1
  45. package/dist/plugins/dev-server.d.ts.map +1 -1
  46. package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
  47. package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
  48. package/dist/plugins/prebuilt.d.ts.map +1 -1
  49. package/dist/plugins/prerender-sugar.d.ts.map +1 -1
  50. package/dist/routing/index.js +2 -2
  51. package/dist/server/html-injector-core.d.ts +30 -9
  52. package/dist/server/html-injector-core.d.ts.map +1 -1
  53. package/dist/server/html-injectors.d.ts.map +1 -1
  54. package/dist/server/index.js +1 -1
  55. package/dist/server/internal.js +346 -51
  56. package/dist/server/internal.js.map +1 -1
  57. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  58. package/dist/server/pipeline-phases.d.ts.map +1 -1
  59. package/dist/server/prebuilt/cache-key.d.ts +4 -0
  60. package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
  61. package/dist/server/prebuilt/key-discipline.d.ts +23 -0
  62. package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
  63. package/dist/server/prebuilt/slots.d.ts +74 -0
  64. package/dist/server/prebuilt/slots.d.ts.map +1 -0
  65. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  66. package/dist/server/prebuilt-runtime.d.ts +12 -2
  67. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  68. package/dist/server/route-element-builder.d.ts.map +1 -1
  69. package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
  70. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
  71. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  72. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  73. package/dist/server/state-tree-diff.d.ts +26 -3
  74. package/dist/server/state-tree-diff.d.ts.map +1 -1
  75. package/docs/api/34-api-config.mdx +165 -3
  76. package/docs/learn/00-introduction.mdx +78 -44
  77. package/docs/learn/13-configuration.mdx +27 -8
  78. package/package.json +3 -2
  79. package/src/adapters/cloudflare-kv-cache.ts +5 -1
  80. package/src/adapters/nitro.ts +82 -64
  81. package/src/cache/index.ts +25 -2
  82. package/src/cache/redis-handler.ts +27 -7
  83. package/src/cache/tag-aware-handler.ts +5 -1
  84. package/src/cache/timber-cache.ts +21 -10
  85. package/src/client/segment-cache.ts +4 -5
  86. package/src/client/slot-context.ts +48 -0
  87. package/src/client/slot-outlet.tsx +22 -0
  88. package/src/client/slot-provider.tsx +25 -0
  89. package/src/config-types.ts +2 -1
  90. package/src/dev-tools/logs.ts +7 -0
  91. package/src/plugin-context.ts +54 -0
  92. package/src/plugins/cache.ts +1 -2
  93. package/src/plugins/client-chunks.ts +42 -1
  94. package/src/plugins/dev-server.ts +12 -69
  95. package/src/plugins/prebuilt-options-analysis.ts +175 -0
  96. package/src/plugins/prebuilt.ts +82 -127
  97. package/src/plugins/prerender-sugar.ts +1 -2
  98. package/src/server/html-injector-core.ts +85 -27
  99. package/src/server/html-injectors.ts +5 -1
  100. package/src/server/node-stream-transforms.ts +6 -1
  101. package/src/server/pipeline-phases.ts +4 -1
  102. package/src/server/prebuilt/cache-key.ts +74 -0
  103. package/src/server/prebuilt/key-discipline.ts +53 -0
  104. package/src/server/prebuilt/slots.ts +167 -0
  105. package/src/server/prebuilt-builder.ts +57 -23
  106. package/src/server/prebuilt-runtime.ts +144 -60
  107. package/src/server/route-element-builder.ts +82 -73
  108. package/src/server/rsc-entry/deny-fallback.ts +92 -0
  109. package/src/server/rsc-entry/helpers.ts +12 -10
  110. package/src/server/rsc-entry/index.ts +16 -70
  111. package/src/server/state-tree-diff.ts +49 -4
  112. package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
  113. package/dist/_chunks/plugin-context-DeAxFRMq.js.map +0 -1
@@ -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
 
@@ -55,7 +55,6 @@
55
55
 
56
56
  import { relative } from 'node:path';
57
57
  import MagicString from 'magic-string';
58
- import { parseAst } from 'vite';
59
58
  import type { Plugin } from 'vite';
60
59
  import type { PluginContext } from '../plugin-context.js';
61
60
  import {
@@ -147,7 +146,7 @@ export function timberCacheTransform(ctx: PluginContext): Plugin {
147
146
 
148
147
  let program: ProgramNode;
149
148
  try {
150
- program = parseAst(code, { lang: 'tsx' }) as unknown as ProgramNode;
149
+ program = ctx.parseCached!.parse(code);
151
150
  } catch {
152
151
  return;
153
152
  }
@@ -48,6 +48,47 @@ function findAppBoundary(
48
48
  return { inApp: true, segment: null };
49
49
  }
50
50
 
51
+ /**
52
+ * Check if a string is a bare import specifier (package name, not a path).
53
+ * Bare specifiers don't start with `.`, `/`, or contain `:/`.
54
+ */
55
+ function isBareSpecifier(id: string): boolean {
56
+ return !id.startsWith('.') && !id.startsWith('/') && !id.includes(':/');
57
+ }
58
+
59
+ /**
60
+ * Look up facade ancestors for a module ID, handling bare package specifiers.
61
+ *
62
+ * The facade ancestry map is keyed by absolute resolved paths (from Rollup's
63
+ * moduleIds), but the RSC plugin passes bare package names (e.g. 'streamdown')
64
+ * as meta.id for npm package client references (TIM-1108). When a direct
65
+ * lookup misses and the id is a bare specifier, scan map keys for a matching
66
+ * node_modules path.
67
+ */
68
+ function lookupFacades(
69
+ id: string,
70
+ sharedModuleFacades: Map<string, string[]>
71
+ ): string[] | undefined {
72
+ const direct = sharedModuleFacades.get(id);
73
+ if (direct) return direct;
74
+
75
+ if (!isBareSpecifier(id)) return undefined;
76
+
77
+ // Bare specifier: match against node_modules/<name>/ in map keys.
78
+ // Scoped packages use @scope/name, unscoped use just name.
79
+ // A package may have multiple module IDs in the RSC bundle (e.g. main
80
+ // entry + re-exported submodules), so aggregate facades from all matches.
81
+ const needle = `/node_modules/${id}/`;
82
+ let merged: string[] | undefined;
83
+ for (const [key, facades] of sharedModuleFacades) {
84
+ if (key.includes(needle)) {
85
+ if (!merged) merged = [...facades];
86
+ else merged.push(...facades);
87
+ }
88
+ }
89
+ return merged;
90
+ }
91
+
51
92
  /**
52
93
  * For a `shared:` client reference, check the facade ancestry map to
53
94
  * see if all consuming facades belong to the same route boundary.
@@ -60,7 +101,7 @@ function reclassifySharedModule(
60
101
  root: string,
61
102
  sharedModuleFacades: Map<string, string[]>
62
103
  ): string | null {
63
- const facades = sharedModuleFacades.get(meta.id);
104
+ const facades = lookupFacades(meta.id, sharedModuleFacades);
64
105
  if (!facades || facades.length === 0) return null;
65
106
 
66
107
  const boundaries = new Set<string>();
@@ -17,6 +17,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
17
17
  import type { TLSSocket } from 'node:tls';
18
18
  import { existsSync, statSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
+ import { sendNodeResponse } from 'srvx/node';
20
21
  import type { PluginContext } from '../plugin-context.js';
21
22
  import {
22
23
  sendErrorToOverlay,
@@ -298,8 +299,17 @@ function createTimberMiddleware(server: ViteDevServer, ctx: PluginContext) {
298
299
  // See design/25-production-deployments.md.
299
300
  const finalResponse = compressResponse(webRequest, webResponse);
300
301
 
301
- // Convert Web Response → Node ServerResponse
302
- await sendWebResponse(res, finalResponse);
302
+ // Flush headers eagerly so streaming responses (SSE, long-polling)
303
+ // reach the client before the first body chunk. Node.js buffers
304
+ // headers until write()/end() otherwise, which stalls EventSource
305
+ // clients that wait for the response to open. srvx's writeHead
306
+ // skips if headersSent is already true, so pre-flushing is safe.
307
+ res.writeHead(finalResponse.status, [...finalResponse.headers].flat());
308
+ res.flushHeaders();
309
+
310
+ // Stream the body with backpressure and disconnect handling.
311
+ // srvx skips its own writeHead since headers are already sent.
312
+ await sendNodeResponse(res, finalResponse);
303
313
  } catch (error) {
304
314
  // Pipeline error — classify the phase, send to overlay, respond 500.
305
315
  // The dev server remains running for recovery on file fix + HMR.
@@ -448,73 +458,6 @@ function nodeReadableToWebStream(nodeStream: IncomingMessage): ReadableStream<Ui
448
458
  });
449
459
  }
450
460
 
451
- /**
452
- * Write a Web Response to a Node ServerResponse.
453
- *
454
- * Copies status code, headers, and streams the body.
455
- */
456
- async function sendWebResponse(nodeRes: ServerResponse, webResponse: Response): Promise<void> {
457
- nodeRes.statusCode = webResponse.status;
458
-
459
- // Copy headers. Set-Cookie needs special handling: Headers.forEach()
460
- // joins multiple Set-Cookie values with ", " into one entry, but each
461
- // cookie must be its own header per RFC 6265 §4.1. Use getSetCookie()
462
- // to preserve individual Set-Cookie headers.
463
- webResponse.headers.forEach((value, key) => {
464
- if (key.toLowerCase() !== 'set-cookie') {
465
- nodeRes.setHeader(key, value);
466
- }
467
- });
468
- const setCookies = webResponse.headers.getSetCookie();
469
- if (setCookies.length > 0) {
470
- nodeRes.setHeader('Set-Cookie', setCookies);
471
- }
472
-
473
- // Stream the body
474
- if (!webResponse.body) {
475
- nodeRes.end();
476
- return;
477
- }
478
-
479
- // Flush headers immediately so the client can start processing
480
- // the response (critical for SSE and other streaming responses).
481
- nodeRes.flushHeaders();
482
-
483
- const reader = webResponse.body.getReader();
484
-
485
- // Cancel the reader when the client disconnects. This causes any pending
486
- // reader.read() to reject, breaking the pump loop. Critical for SSE and
487
- // other infinite streams — without this, disconnected clients leak readers.
488
- let clientDisconnected = false;
489
- const onClose = () => {
490
- clientDisconnected = true;
491
- reader.cancel('Client disconnected').catch(() => {});
492
- };
493
- nodeRes.on('close', onClose);
494
-
495
- try {
496
- while (true) {
497
- const { done, value } = await reader.read();
498
- if (done) break;
499
- // write() returns false when the kernel buffer is full, but we
500
- // don't need back-pressure here — just keep pushing chunks.
501
- nodeRes.write(value);
502
- }
503
- } catch (err) {
504
- // reader.cancel() from the close handler causes read() to reject.
505
- // This is expected on client disconnect — not an error.
506
- if (!clientDisconnected) {
507
- throw err;
508
- }
509
- } finally {
510
- nodeRes.off('close', onClose);
511
- reader.releaseLock();
512
- if (!nodeRes.writableEnded) {
513
- nodeRes.end();
514
- }
515
- }
516
- }
517
-
518
461
  // ─── URL Classification ──────────────────────────────────────────────────
519
462
 
520
463
  /**
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Static analysis of `cache.component(fn, options)` option literals for
3
+ * the timber-prebuilt transform: can this call site's options put the
4
+ * component in the prerender tier?
5
+ *
6
+ * Runtime-tier call sites must do NO build work — recording them would
7
+ * make the post-build hook import the built RSC entry (running production
8
+ * startup) and load route modules for routes that have nothing to
9
+ * prerender (codex P2 on PR #836). The analysis is conservative in one
10
+ * direction only: over-inclusion costs a wasted entry load (the capture
11
+ * pass re-checks the real options at runtime and skips), under-inclusion
12
+ * would silently skip prerendering.
13
+ */
14
+
15
+ import type { CallExpressionNode, PositionedNode } from './callsite-ast.js';
16
+
17
+ const EXPRESSION_WRAPPER_TYPES = new Set([
18
+ 'TSAsExpression',
19
+ 'TSSatisfiesExpression',
20
+ 'TSNonNullExpression',
21
+ 'ParenthesizedExpression',
22
+ ]);
23
+
24
+ /** Unwrap TS expression wrappers (`as`, `satisfies`, `!`, parens). */
25
+ export function unwrapExpression(node: PositionedNode): PositionedNode {
26
+ let current = node;
27
+ while (EXPRESSION_WRAPPER_TYPES.has(current.type) && current.expression) {
28
+ current = current.expression as PositionedNode;
29
+ }
30
+ return current;
31
+ }
32
+
33
+ interface ObjectPropertyNode extends PositionedNode {
34
+ type: 'Property';
35
+ computed?: boolean;
36
+ key: PositionedNode & { name?: string; value?: unknown };
37
+ value: PositionedNode & { value?: unknown };
38
+ }
39
+
40
+ /** Three-state static knowledge about a property's runtime effect. */
41
+ type Tri = 'yes' | 'no' | 'unknown';
42
+
43
+ /**
44
+ * Statically classify a `slots` value node against resolveSlotNames'
45
+ * runtime rules (server/prebuilt/slots.ts):
46
+ *
47
+ * - `yes` — certainly an ACTIVE shape: dense, non-empty array literal of
48
+ * non-empty string literals.
49
+ * - `no` — certainly NOT active (absent/empty/malformed): registration
50
+ * falls back to non-slot semantics with `prerender` preserved.
51
+ * - `unknown` — identifiers, spreads, holes, non-literal elements.
52
+ */
53
+ function classifySlotsValue(node: PositionedNode): Tri {
54
+ const value = unwrapExpression(node);
55
+ if (value.type === 'ArrayExpression') {
56
+ const elements = ((value as unknown as { elements: (PositionedNode | null)[] }).elements ??
57
+ []) as (PositionedNode | null)[];
58
+ if (elements.length === 0) return 'no'; // empty → kind 'none'
59
+ let allValidStrings = true;
60
+ for (const el of elements) {
61
+ if (el === null) return 'no'; // hole → sparse → malformed at runtime
62
+ if (el.type === 'SpreadElement') return 'unknown';
63
+ const entry = unwrapExpression(el) as { type: string; value?: unknown };
64
+ if (entry.type !== 'Literal') {
65
+ allValidStrings = false; // could still be a valid string at runtime
66
+ continue;
67
+ }
68
+ if (typeof entry.value !== 'string' || entry.value.length === 0) {
69
+ return 'no'; // a non-string/empty literal is malformed regardless of the rest
70
+ }
71
+ }
72
+ return allValidStrings ? 'yes' : 'unknown';
73
+ }
74
+ if (value.type === 'Literal') return 'no'; // null / primitive → malformed → disabled
75
+ if (value.type === 'Identifier' && (value as { name?: string }).name === 'undefined') {
76
+ return 'no'; // absent
77
+ }
78
+ return 'unknown';
79
+ }
80
+
81
+ /** Statically classify whether a `ttl` value gives a runtime lifetime (`ttl !== undefined`). */
82
+ function classifyTtlValue(node: PositionedNode): Tri {
83
+ const value = unwrapExpression(node);
84
+ if (value.type === 'Literal') return 'yes'; // any literal (number, null, …) !== undefined
85
+ if (value.type === 'Identifier' && (value as { name?: string }).name === 'undefined') return 'no';
86
+ return 'unknown';
87
+ }
88
+
89
+ /** Statically classify whether a `tags` value gives a runtime lifetime (`tags != null`). */
90
+ function classifyTagsValue(node: PositionedNode): Tri {
91
+ const value = unwrapExpression(node);
92
+ if (value.type === 'ArrayExpression') return 'yes';
93
+ if (value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression') return 'yes';
94
+ if (value.type === 'Literal') {
95
+ return (value as { value?: unknown }).value === null ? 'no' : 'yes';
96
+ }
97
+ if (value.type === 'Identifier' && (value as { name?: string }).name === 'undefined') return 'no';
98
+ return 'unknown';
99
+ }
100
+
101
+ /**
102
+ * Can this call site's options put the component in the prerender tier?
103
+ * The answer is static only when the options argument is an object
104
+ * literal:
105
+ *
106
+ * - literal with `prerender: true` → yes
107
+ * - literal with `prerender: false`/absent → no (spread-free)
108
+ * - literal with statically ACTIVE `slots` + certain runtime lifetime
109
+ * → no — registration strips `prerender` for active slot components
110
+ * (TIM-1173), so attributing the callsite would make the post-build
111
+ * hook import the RSC entry and enumerate route params for entries
112
+ * the capture pass then skips (codex P2 on PR #890)
113
+ * - anything else (identifier, spread, non-literal values)
114
+ * → conservatively yes — the capture pass re-checks at runtime
115
+ *
116
+ * Properties walk in source order with last-write-wins, mirroring
117
+ * object-literal evaluation: `{ prerender: false, ...opts }` may end up
118
+ * prerenderable (the spread can override), while `{ ...opts,
119
+ * prerender: false }` is statically false (codex P2 on PR #836).
120
+ * Computed keys are treated like spreads — they may evaluate to any of
121
+ * the tracked names.
122
+ */
123
+ export function mayPrerender(call: CallExpressionNode): boolean {
124
+ if (call.arguments.length < 2) return false; // no options → no lifetime → never prerender
125
+ const options = unwrapExpression(call.arguments[1] as PositionedNode);
126
+ if (options.type !== 'ObjectExpression') return true;
127
+ const properties = (options as unknown as { properties: PositionedNode[] }).properties ?? [];
128
+
129
+ type Verdict = 'absent' | 'true' | 'false' | 'unknown';
130
+ let verdict: Verdict = 'absent';
131
+ let slotsActive: Tri = 'no'; // absent slots are certainly not active
132
+ let ttl: Tri = 'no';
133
+ let tags: Tri = 'no';
134
+
135
+ for (const prop of properties) {
136
+ if (prop.type === 'SpreadElement' || (prop as ObjectPropertyNode).computed) {
137
+ // A spread/computed key may write ANY of the tracked options.
138
+ verdict = 'unknown';
139
+ slotsActive = 'unknown';
140
+ ttl = 'unknown';
141
+ tags = 'unknown';
142
+ continue;
143
+ }
144
+ if (prop.type !== 'Property') continue;
145
+ const property = prop as ObjectPropertyNode;
146
+ const keyName =
147
+ property.key.type === 'Identifier'
148
+ ? property.key.name
149
+ : property.key.type === 'Literal'
150
+ ? String(property.key.value)
151
+ : undefined;
152
+ if (keyName === 'prerender') {
153
+ const value = unwrapExpression(property.value);
154
+ if (value.type === 'Literal') {
155
+ verdict = (value as { value?: unknown }).value === true ? 'true' : 'false';
156
+ } else {
157
+ verdict = 'unknown'; // `prerender: flag` — not statically known
158
+ }
159
+ } else if (keyName === 'slots') {
160
+ slotsActive = classifySlotsValue(property.value);
161
+ } else if (keyName === 'ttl') {
162
+ ttl = classifyTtlValue(property.value);
163
+ } else if (keyName === 'tags') {
164
+ tags = classifyTagsValue(property.value);
165
+ }
166
+ }
167
+
168
+ if (verdict !== 'true' && verdict !== 'unknown') return false;
169
+ // Active slots + certain runtime lifetime → registration strips
170
+ // prerender, so the callsite is certainly runtime-tier. Any uncertainty
171
+ // (slots or lifetime not statically known) keeps the conservative
172
+ // attribution.
173
+ if (slotsActive === 'yes' && (ttl === 'yes' || tags === 'yes')) return false;
174
+ return true;
175
+ }