@timber-js/app 0.2.0-alpha.163 → 0.2.0-alpha.165
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.
- package/dist/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
- package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
- package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
- package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
- package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
- package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
- package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
- package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/nitro.d.ts +40 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +133 -73
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.d.ts +3 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/redis-handler.d.ts +1 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/tag-aware-handler.d.ts +1 -0
- package/dist/cache/tag-aware-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/slot-context.d.ts +29 -0
- package/dist/client/slot-context.d.ts.map +1 -0
- package/dist/client/slot-outlet.d.ts +16 -0
- package/dist/client/slot-outlet.d.ts.map +1 -0
- package/dist/client/slot-provider.d.ts +20 -0
- package/dist/client/slot-provider.d.ts.map +1 -0
- package/dist/config-types.d.ts +2 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/dev-tools/logs.d.ts.map +1 -1
- package/dist/index.js +293 -124
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +27 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
- package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
- package/dist/plugins/prebuilt.d.ts.map +1 -1
- package/dist/plugins/prerender-sugar.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/html-injector-core.d.ts +30 -9
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +346 -51
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/prebuilt/cache-key.d.ts +4 -0
- package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
- package/dist/server/prebuilt/key-discipline.d.ts +23 -0
- package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
- package/dist/server/prebuilt/slots.d.ts +74 -0
- package/dist/server/prebuilt/slots.d.ts.map +1 -0
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/prebuilt-runtime.d.ts +12 -2
- package/dist/server/prebuilt-runtime.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/docs/api/34-api-config.mdx +165 -3
- package/docs/learn/00-introduction.mdx +78 -44
- package/docs/learn/13-configuration.mdx +27 -8
- package/package.json +3 -2
- package/src/adapters/cloudflare-kv-cache.ts +5 -1
- package/src/adapters/nitro.ts +188 -68
- package/src/cache/index.ts +25 -2
- package/src/cache/redis-handler.ts +27 -7
- package/src/cache/tag-aware-handler.ts +5 -1
- package/src/cache/timber-cache.ts +21 -10
- package/src/client/slot-context.ts +48 -0
- package/src/client/slot-outlet.tsx +22 -0
- package/src/client/slot-provider.tsx +25 -0
- package/src/config-types.ts +2 -1
- package/src/dev-tools/logs.ts +7 -0
- package/src/plugin-context.ts +54 -0
- package/src/plugins/cache.ts +1 -2
- package/src/plugins/client-chunks.ts +42 -1
- package/src/plugins/dev-server.ts +12 -69
- package/src/plugins/prebuilt-options-analysis.ts +175 -0
- package/src/plugins/prebuilt.ts +82 -127
- package/src/plugins/prerender-sugar.ts +1 -2
- package/src/server/html-injector-core.ts +85 -27
- package/src/server/html-injectors.ts +5 -1
- package/src/server/node-stream-transforms.ts +6 -1
- package/src/server/pipeline-phases.ts +4 -1
- package/src/server/prebuilt/cache-key.ts +74 -0
- package/src/server/prebuilt/key-discipline.ts +53 -0
- package/src/server/prebuilt/slots.ts +167 -0
- package/src/server/prebuilt-builder.ts +57 -23
- package/src/server/prebuilt-runtime.ts +144 -60
- package/src/server/rsc-entry/deny-fallback.ts +92 -0
- package/src/server/rsc-entry/index.ts +16 -70
- package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
- 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
|
+
}
|
package/src/config-types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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".
|
package/src/dev-tools/logs.ts
CHANGED
|
@@ -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 = {
|
package/src/plugin-context.ts
CHANGED
|
@@ -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
|
|
package/src/plugins/cache.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
//
|
|
302
|
-
|
|
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
|
+
}
|