@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
@@ -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
+ }