@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.
- 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 +11 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +77 -64
- 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/internal.js +1 -2
- package/dist/client/internal.js.map +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- 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/route-element-builder.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/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +26 -3
- package/dist/server/state-tree-diff.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 +82 -64
- 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/segment-cache.ts +4 -5
- 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/route-element-builder.ts +82 -73
- package/src/server/rsc-entry/deny-fallback.ts +92 -0
- package/src/server/rsc-entry/helpers.ts +12 -10
- package/src/server/rsc-entry/index.ts +16 -70
- package/src/server/state-tree-diff.ts +49 -4
- package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
- package/dist/_chunks/plugin-context-DeAxFRMq.js.map +0 -1
package/src/plugins/prebuilt.ts
CHANGED
|
@@ -60,7 +60,6 @@
|
|
|
60
60
|
import { readFileSync } from 'node:fs';
|
|
61
61
|
import { relative } from 'node:path';
|
|
62
62
|
import MagicString from 'magic-string';
|
|
63
|
-
import { parseAst } from 'vite';
|
|
64
63
|
import type { Plugin } from 'vite';
|
|
65
64
|
import type { PluginContext } from '../plugin-context.js';
|
|
66
65
|
import type { SegmentNode } from '../routing/types.js';
|
|
@@ -70,6 +69,7 @@ import {
|
|
|
70
69
|
generateStableCallsiteId,
|
|
71
70
|
} from './callsite-ast.js';
|
|
72
71
|
import { collectJsxUsages, recordCallSiteProps } from './callsite-jsx.js';
|
|
72
|
+
import { mayPrerender, unwrapExpression } from './prebuilt-options-analysis.js';
|
|
73
73
|
import { cleanPrebuiltDir, runPrebuiltCapture } from './prebuilt-capture.js';
|
|
74
74
|
import type {
|
|
75
75
|
CallExpressionNode,
|
|
@@ -135,86 +135,6 @@ function importInsertionOffset(program: ProgramNode): number {
|
|
|
135
135
|
return program.body.length > 0 ? program.body[program.body.length - 1].end : 0;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
/** TS/paren wrappers that may sit between a declarator and the call. */
|
|
139
|
-
const EXPRESSION_WRAPPER_TYPES = new Set([
|
|
140
|
-
'TSAsExpression',
|
|
141
|
-
'TSSatisfiesExpression',
|
|
142
|
-
'TSNonNullExpression',
|
|
143
|
-
'ParenthesizedExpression',
|
|
144
|
-
]);
|
|
145
|
-
|
|
146
|
-
function unwrapExpression(node: PositionedNode): PositionedNode {
|
|
147
|
-
let current = node;
|
|
148
|
-
while (EXPRESSION_WRAPPER_TYPES.has(current.type) && current.expression) {
|
|
149
|
-
current = current.expression as PositionedNode;
|
|
150
|
-
}
|
|
151
|
-
return current;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
interface ObjectPropertyNode extends PositionedNode {
|
|
155
|
-
type: 'Property';
|
|
156
|
-
computed?: boolean;
|
|
157
|
-
key: PositionedNode & { name?: string; value?: unknown };
|
|
158
|
-
value: PositionedNode & { value?: unknown };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Can this call site's options put the component in the prerender tier?
|
|
163
|
-
*
|
|
164
|
-
* Runtime-tier call sites (`ttl`/`tags` without `prerender`) must do NO
|
|
165
|
-
* build work — recording them would make the post-build hook import the
|
|
166
|
-
* built RSC entry (running production startup) and load route modules for
|
|
167
|
-
* routes that have nothing to prerender (codex P2 on PR #836). The answer
|
|
168
|
-
* is static only when the options argument is an object literal:
|
|
169
|
-
*
|
|
170
|
-
* - literal with `prerender: true` → yes
|
|
171
|
-
* - literal with `prerender: false`/absent → no (spread-free)
|
|
172
|
-
* - anything else (identifier, spread, non-literal `prerender` value)
|
|
173
|
-
* → conservatively yes — the capture pass re-checks the real options
|
|
174
|
-
* at runtime and skips non-prerender registrations; over-inclusion
|
|
175
|
-
* costs a wasted entry load, under-inclusion would silently skip
|
|
176
|
-
* prerendering.
|
|
177
|
-
*/
|
|
178
|
-
function mayPrerender(call: CallExpressionNode): boolean {
|
|
179
|
-
if (call.arguments.length < 2) return false; // no options → no lifetime → never prerender
|
|
180
|
-
const options = unwrapExpression(call.arguments[1] as PositionedNode);
|
|
181
|
-
if (options.type !== 'ObjectExpression') return true;
|
|
182
|
-
const properties = (options as unknown as { properties: PositionedNode[] }).properties ?? [];
|
|
183
|
-
// Walk in source order with last-write-wins, mirroring object-literal
|
|
184
|
-
// evaluation: `{ prerender: false, ...opts }` may end up prerenderable
|
|
185
|
-
// (the spread can override), while `{ ...opts, prerender: false }`
|
|
186
|
-
// is statically false (codex P2 on PR #836). Computed keys are treated
|
|
187
|
-
// like spreads — they may evaluate to 'prerender'.
|
|
188
|
-
type Verdict = 'absent' | 'true' | 'false' | 'unknown';
|
|
189
|
-
let verdict: Verdict = 'absent';
|
|
190
|
-
for (const prop of properties) {
|
|
191
|
-
if (prop.type === 'SpreadElement') {
|
|
192
|
-
verdict = 'unknown';
|
|
193
|
-
continue;
|
|
194
|
-
}
|
|
195
|
-
if (prop.type !== 'Property') continue;
|
|
196
|
-
const property = prop as ObjectPropertyNode;
|
|
197
|
-
if (property.computed) {
|
|
198
|
-
verdict = 'unknown';
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
const keyName =
|
|
202
|
-
property.key.type === 'Identifier'
|
|
203
|
-
? property.key.name
|
|
204
|
-
: property.key.type === 'Literal'
|
|
205
|
-
? String(property.key.value)
|
|
206
|
-
: undefined;
|
|
207
|
-
if (keyName !== 'prerender') continue;
|
|
208
|
-
const value = unwrapExpression(property.value);
|
|
209
|
-
if (value.type === 'Literal') {
|
|
210
|
-
verdict = (value as { value?: unknown }).value === true ? 'true' : 'false';
|
|
211
|
-
} else {
|
|
212
|
-
verdict = 'unknown'; // `prerender: flag` — not statically known
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
return verdict === 'true' || verdict === 'unknown';
|
|
216
|
-
}
|
|
217
|
-
|
|
218
138
|
interface CandidateSite {
|
|
219
139
|
call: CallExpressionNode;
|
|
220
140
|
stmt: PositionedNode;
|
|
@@ -336,44 +256,40 @@ function collectPageAndLayoutFiles(root: SegmentNode): string[] {
|
|
|
336
256
|
return files;
|
|
337
257
|
}
|
|
338
258
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
function collectCrossModuleProps(
|
|
259
|
+
// Sentinel key for default-export component IDs in the binding map.
|
|
260
|
+
// Uses \0 prefix so it can never collide with a real JS export name
|
|
261
|
+
// (TIM-1187: 'Component'/'default' leaked into the named-import namespace).
|
|
262
|
+
const DEFAULT_SENTINEL = '\0default';
|
|
263
|
+
|
|
264
|
+
async function collectCrossModuleProps(
|
|
346
265
|
ctx: PluginContext,
|
|
347
266
|
moduleId: string,
|
|
348
|
-
getInfo: (id: string) => PrebuiltModuleInfo | null
|
|
349
|
-
|
|
267
|
+
getInfo: (id: string) => PrebuiltModuleInfo | null,
|
|
268
|
+
resolve: (source: string, importer: string) => Promise<string | null>
|
|
269
|
+
): Promise<void> {
|
|
350
270
|
const bindingNames = ctx.prebuiltBindingNames!;
|
|
351
271
|
|
|
352
272
|
const info = getInfo(moduleId);
|
|
353
273
|
if (!info) return;
|
|
354
274
|
|
|
355
|
-
// Which of this module's imports are prebuilt-declaring modules?
|
|
356
|
-
// Exclude self — same-module usages are already captured in the transform.
|
|
357
275
|
const declaringImports = info.importedIds.filter((id) => bindingNames.has(id) && id !== moduleId);
|
|
358
276
|
if (declaringImports.length === 0) return;
|
|
359
277
|
|
|
360
278
|
// Collect exported binding names from reachable declaring modules.
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
// raw specifier, and a wrong match would produce spurious cache entries.
|
|
279
|
+
// Skip sentinel keys — they are per-module and handled in the default
|
|
280
|
+
// import branch below. Only real export names go into the merged map.
|
|
364
281
|
const exportNameToId = new Map<string, string>();
|
|
365
282
|
const collisions = new Set<string>();
|
|
366
283
|
for (const declId of declaringImports) {
|
|
367
284
|
const map = bindingNames.get(declId)!;
|
|
368
285
|
for (const [name, compId] of map) {
|
|
286
|
+
if (name === DEFAULT_SENTINEL) continue;
|
|
369
287
|
if (exportNameToId.has(name)) collisions.add(name);
|
|
370
288
|
exportNameToId.set(name, compId);
|
|
371
289
|
}
|
|
372
290
|
}
|
|
373
291
|
for (const name of collisions) exportNameToId.delete(name);
|
|
374
292
|
|
|
375
|
-
// Read the route file source. getModuleInfo may provide `code` on
|
|
376
|
-
// Vite's extended Rollup interface; fall back to reading from disk.
|
|
377
293
|
let source: string | null = null;
|
|
378
294
|
const extInfo = getInfo(moduleId) as unknown as { code?: string | null };
|
|
379
295
|
if (extInfo && typeof extInfo.code === 'string') {
|
|
@@ -389,17 +305,17 @@ function collectCrossModuleProps(
|
|
|
389
305
|
|
|
390
306
|
let program: ProgramNode;
|
|
391
307
|
try {
|
|
392
|
-
program =
|
|
308
|
+
program = ctx.parseCached!.parse(source);
|
|
393
309
|
} catch {
|
|
394
310
|
return;
|
|
395
311
|
}
|
|
396
312
|
|
|
397
|
-
|
|
398
|
-
// the known exported binding names from declaring modules.
|
|
313
|
+
const declaringSet = new Set(declaringImports);
|
|
399
314
|
const localToComponentId = new Map<string, string>();
|
|
400
315
|
for (const stmt of program.body) {
|
|
401
316
|
if (stmt.type !== 'ImportDeclaration') continue;
|
|
402
317
|
const importDecl = stmt as unknown as {
|
|
318
|
+
source: { value: string };
|
|
403
319
|
specifiers: Array<{
|
|
404
320
|
type: string;
|
|
405
321
|
local: { name: string };
|
|
@@ -409,19 +325,26 @@ function collectCrossModuleProps(
|
|
|
409
325
|
for (const spec of importDecl.specifiers) {
|
|
410
326
|
if (spec.type === 'ImportSpecifier') {
|
|
411
327
|
const exportedName = spec.imported?.name ?? spec.local.name;
|
|
412
|
-
|
|
413
|
-
|
|
328
|
+
// `import { default as X }` is parsed as an ImportSpecifier
|
|
329
|
+
// with imported.name === 'default' — route through the same
|
|
330
|
+
// per-source sentinel lookup as ImportDefaultSpecifier.
|
|
331
|
+
if (exportedName === 'default') {
|
|
332
|
+
const resolvedId = await resolve(importDecl.source.value, moduleId);
|
|
333
|
+
if (resolvedId && declaringSet.has(resolvedId)) {
|
|
334
|
+
const map = bindingNames.get(resolvedId)!;
|
|
335
|
+
const compId = map.get(DEFAULT_SENTINEL);
|
|
336
|
+
if (compId) localToComponentId.set(spec.local.name, compId);
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
const compId = exportNameToId.get(exportedName);
|
|
340
|
+
if (compId) localToComponentId.set(spec.local.name, compId);
|
|
341
|
+
}
|
|
414
342
|
} else if (spec.type === 'ImportDefaultSpecifier') {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (compId) {
|
|
421
|
-
localToComponentId.set(spec.local.name, compId);
|
|
422
|
-
} else if (exportNameToId.size === 1) {
|
|
423
|
-
const [, soleId] = [...exportNameToId.entries()][0];
|
|
424
|
-
localToComponentId.set(spec.local.name, soleId);
|
|
343
|
+
const resolvedId = await resolve(importDecl.source.value, moduleId);
|
|
344
|
+
if (resolvedId && declaringSet.has(resolvedId)) {
|
|
345
|
+
const map = bindingNames.get(resolvedId)!;
|
|
346
|
+
const compId = map.get(DEFAULT_SENTINEL);
|
|
347
|
+
if (compId) localToComponentId.set(spec.local.name, compId);
|
|
425
348
|
}
|
|
426
349
|
}
|
|
427
350
|
}
|
|
@@ -462,7 +385,7 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
|
|
|
462
385
|
|
|
463
386
|
let program: ProgramNode;
|
|
464
387
|
try {
|
|
465
|
-
program =
|
|
388
|
+
program = ctx.parseCached!.parse(code);
|
|
466
389
|
} catch {
|
|
467
390
|
return;
|
|
468
391
|
}
|
|
@@ -516,14 +439,25 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
|
|
|
516
439
|
// prop detection at generateBundle time (TIM-1142).
|
|
517
440
|
const bindingMap = new Map<string, string>();
|
|
518
441
|
let componentIdIdx = 0;
|
|
519
|
-
for (const { call, displayName } of callSites) {
|
|
442
|
+
for (const { call, stmt, displayName } of callSites) {
|
|
520
443
|
if (!ctx.dev && mayPrerender(call)) {
|
|
521
|
-
|
|
444
|
+
const compId = componentIds[componentIdIdx++];
|
|
445
|
+
// Direct `export default cache.component(...)` produces
|
|
446
|
+
// displayName 'Component' from an ExportDefaultDeclaration —
|
|
447
|
+
// a synthetic name, not a real export. Store only the sentinel
|
|
448
|
+
// key. A variable literally named `Component` (from a
|
|
449
|
+
// VariableDeclarator) is a real binding and stored as-is.
|
|
450
|
+
if (stmt.type === 'ExportDefaultDeclaration') {
|
|
451
|
+
bindingMap.set(DEFAULT_SENTINEL, compId);
|
|
452
|
+
} else {
|
|
453
|
+
bindingMap.set(displayName, compId);
|
|
454
|
+
}
|
|
522
455
|
}
|
|
523
456
|
}
|
|
524
|
-
// Detect
|
|
525
|
-
//
|
|
526
|
-
//
|
|
457
|
+
// Detect default re-exports that reference a declared binding
|
|
458
|
+
// and add a sentinel alias so cross-module default imports can
|
|
459
|
+
// resolve the component ID. Handles both `export default X` and
|
|
460
|
+
// `export { X as default }` (TIM-1189).
|
|
527
461
|
for (const stmt of program.body) {
|
|
528
462
|
if (
|
|
529
463
|
stmt.type === 'ExportDefaultDeclaration' &&
|
|
@@ -531,8 +465,21 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
|
|
|
531
465
|
(stmt.declaration as PositionedNode).type === 'Identifier'
|
|
532
466
|
) {
|
|
533
467
|
const name = (stmt.declaration as PositionedNode as unknown as IdentifierNode).name;
|
|
534
|
-
if (bindingMap.has(name) && !bindingMap.has(
|
|
535
|
-
bindingMap.set(
|
|
468
|
+
if (bindingMap.has(name) && !bindingMap.has(DEFAULT_SENTINEL)) {
|
|
469
|
+
bindingMap.set(DEFAULT_SENTINEL, bindingMap.get(name)!);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (stmt.type === 'ExportNamedDeclaration' && Array.isArray(stmt.specifiers)) {
|
|
473
|
+
for (const spec of stmt.specifiers as Array<{
|
|
474
|
+
local: IdentifierNode;
|
|
475
|
+
exported: IdentifierNode;
|
|
476
|
+
}>) {
|
|
477
|
+
if (spec.exported.name === 'default') {
|
|
478
|
+
const compId = bindingMap.get(spec.local.name);
|
|
479
|
+
if (compId && !bindingMap.has(DEFAULT_SENTINEL)) {
|
|
480
|
+
bindingMap.set(DEFAULT_SENTINEL, compId);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
536
483
|
}
|
|
537
484
|
}
|
|
538
485
|
}
|
|
@@ -545,7 +492,10 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
|
|
|
545
492
|
// Same-module JSX usages: if a prebuilt component is declared AND
|
|
546
493
|
// used (<BlogBody variant="full" />) in the same module, capture
|
|
547
494
|
// its static call-site props here (TIM-1142, design/44 §Props).
|
|
548
|
-
|
|
495
|
+
// Exclude sentinel keys — they are not real JSX element names.
|
|
496
|
+
const declaredBindings = new Set(
|
|
497
|
+
[...bindingMap.keys()].filter((k) => k !== DEFAULT_SENTINEL)
|
|
498
|
+
);
|
|
549
499
|
if (declaredBindings.size > 0) {
|
|
550
500
|
const usages = collectJsxUsages(program, declaredBindings);
|
|
551
501
|
for (const [bindingName, propsList] of usages) {
|
|
@@ -582,7 +532,7 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
|
|
|
582
532
|
* SHARED components work — a component imported by several routes is
|
|
583
533
|
* captured under every importing route's param combos.
|
|
584
534
|
*/
|
|
585
|
-
generateBundle(
|
|
535
|
+
async generateBundle(
|
|
586
536
|
_options?: unknown,
|
|
587
537
|
bundle?: Record<
|
|
588
538
|
string,
|
|
@@ -591,18 +541,23 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
|
|
|
591
541
|
) {
|
|
592
542
|
if (ctx.dev) return;
|
|
593
543
|
if (this?.environment?.name !== 'rsc') return;
|
|
594
|
-
// Recompute attribution from scratch every build — a stale map from
|
|
595
|
-
// a previous watch-mode build would otherwise keep scheduling the
|
|
596
|
-
// capture (and its built-entry import) for components that no
|
|
597
|
-
// longer exist (codex P2 on PR #836).
|
|
598
544
|
ctx.routePrebuiltComponents = undefined;
|
|
599
545
|
const moduleComponents = ctx.prebuiltModuleComponents;
|
|
600
546
|
if (!moduleComponents || moduleComponents.size === 0 || !ctx.routeTree) return;
|
|
601
547
|
|
|
602
548
|
const self = this as unknown as {
|
|
603
549
|
getModuleInfo?: (id: string) => PrebuiltModuleInfo | null;
|
|
550
|
+
resolve?: (
|
|
551
|
+
source: string,
|
|
552
|
+
importer: string,
|
|
553
|
+
opts?: unknown
|
|
554
|
+
) => Promise<{ id: string } | null>;
|
|
604
555
|
};
|
|
605
556
|
const getInfo = (id: string) => self.getModuleInfo?.(id) ?? null;
|
|
557
|
+
const resolveFn = async (source: string, importer: string): Promise<string | null> => {
|
|
558
|
+
const result = await self.resolve?.(source, importer);
|
|
559
|
+
return result?.id ?? null;
|
|
560
|
+
};
|
|
606
561
|
|
|
607
562
|
const routeComponents = new Map<string, string[]>();
|
|
608
563
|
for (const filePath of collectPageAndLayoutFiles(ctx.routeTree.root)) {
|
|
@@ -648,7 +603,7 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
|
|
|
648
603
|
visited.add(modId);
|
|
649
604
|
if (!scannedModules.has(modId)) {
|
|
650
605
|
scannedModules.add(modId);
|
|
651
|
-
collectCrossModuleProps(ctx, modId, getInfo);
|
|
606
|
+
await collectCrossModuleProps(ctx, modId, getInfo, resolveFn);
|
|
652
607
|
}
|
|
653
608
|
const modInfo = getInfo(modId);
|
|
654
609
|
if (!modInfo) continue;
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import MagicString from 'magic-string';
|
|
25
|
-
import { parseAst } from 'vite';
|
|
26
25
|
import type { Plugin } from 'vite';
|
|
27
26
|
import type { PluginContext } from '../plugin-context.js';
|
|
28
27
|
import type { SegmentNode, RouteFile } from '../routing/types.js';
|
|
@@ -205,7 +204,7 @@ export function timberPrerenderSugar(ctx: PluginContext): Plugin {
|
|
|
205
204
|
// This page has a resolved prerender value — wrap its default export
|
|
206
205
|
let program: { body: AstNode[] };
|
|
207
206
|
try {
|
|
208
|
-
program =
|
|
207
|
+
program = ctx.parseCached!.parse(code) as unknown as { body: AstNode[] };
|
|
209
208
|
} catch {
|
|
210
209
|
return;
|
|
211
210
|
}
|
|
@@ -112,58 +112,116 @@ export class BufferAggregator {
|
|
|
112
112
|
*
|
|
113
113
|
* - `passthrough` — already found and removed the suffix on a previous chunk.
|
|
114
114
|
* Caller emits the chunk unchanged.
|
|
115
|
-
* - `noSuffix` — current chunk does not contain the suffix.
|
|
115
|
+
* - `noSuffix` — current chunk does not contain the suffix. When `emitSafe`
|
|
116
|
+
* is set, the caller must emit those bytes (the safe head minus the
|
|
117
|
+
* retained tail). When `emitSafe` is null, the entire chunk was absorbed
|
|
118
|
+
* into the tail buffer and there is nothing to emit yet.
|
|
116
119
|
* - `suffixFound` — caller emits `before` and `after` (skipping the suffix
|
|
117
120
|
* itself). The suffix will be re-emitted later by {@link suffixFlush}.
|
|
118
121
|
*/
|
|
119
122
|
export type SuffixChunkResult =
|
|
120
123
|
| { kind: 'passthrough' }
|
|
121
|
-
| { kind: 'noSuffix' }
|
|
124
|
+
| { kind: 'noSuffix'; emitSafe?: Uint8Array }
|
|
122
125
|
| { kind: 'suffixFound'; before: Uint8Array | null; after: Uint8Array | null };
|
|
123
126
|
|
|
127
|
+
/**
|
|
128
|
+
* State for the suffix mover. Works entirely in bytes — no TextDecoder
|
|
129
|
+
* needed because `</body></html>` is pure ASCII and its byte values are
|
|
130
|
+
* all < 0x80, so the pattern cannot appear inside multi-byte UTF-8
|
|
131
|
+
* sequences. This avoids the surrogate-pair and codepoint-corruption
|
|
132
|
+
* hazards that a text-based search + re-encode would introduce.
|
|
133
|
+
*
|
|
134
|
+
* We retain a trailing byte buffer (length `SUFFIX_BYTES.length - 1`)
|
|
135
|
+
* across chunks so the suffix is detected even when split across chunk
|
|
136
|
+
* boundaries — the same tail-buffer approach the head injector uses.
|
|
137
|
+
*/
|
|
124
138
|
export interface SuffixState {
|
|
125
139
|
found: boolean;
|
|
140
|
+
/** Trailing bytes from the previous chunk for cross-boundary search. */
|
|
141
|
+
tail: Uint8Array | null;
|
|
142
|
+
readonly tailLen: number;
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
export function createSuffixState(): SuffixState {
|
|
129
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
found: false,
|
|
148
|
+
tail: null,
|
|
149
|
+
tailLen: SUFFIX_BYTES.byteLength - 1,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Find `needle` in `haystack` starting from byte offset `start`. Returns -1 if not found. */
|
|
154
|
+
function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, start = 0): number {
|
|
155
|
+
const hLen = haystack.byteLength;
|
|
156
|
+
const nLen = needle.byteLength;
|
|
157
|
+
if (nLen === 0) return start;
|
|
158
|
+
const last = hLen - nLen;
|
|
159
|
+
outer: for (let i = start; i <= last; i++) {
|
|
160
|
+
for (let j = 0; j < nLen; j++) {
|
|
161
|
+
if (haystack[i + j] !== needle[j]) continue outer;
|
|
162
|
+
}
|
|
163
|
+
return i;
|
|
164
|
+
}
|
|
165
|
+
return -1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Concatenate two Uint8Arrays into one. */
|
|
169
|
+
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
170
|
+
const result = new Uint8Array(a.byteLength + b.byteLength);
|
|
171
|
+
result.set(a, 0);
|
|
172
|
+
result.set(b, a.byteLength);
|
|
173
|
+
return result;
|
|
130
174
|
}
|
|
131
175
|
|
|
132
176
|
/**
|
|
133
|
-
* Search a chunk for {@link
|
|
134
|
-
* surrounding bytes (the caller emits them; the suffix is held for the
|
|
135
|
-
* end of the stream).
|
|
136
|
-
*
|
|
137
|
-
*
|
|
177
|
+
* Search a chunk for {@link SUFFIX_BYTES}. If found, mark state and return
|
|
178
|
+
* the surrounding bytes (the caller emits them; the suffix is held for the
|
|
179
|
+
* end of the stream). A trailing byte tail is retained across calls so the
|
|
180
|
+
* suffix is detected even when split across chunk boundaries.
|
|
181
|
+
*
|
|
182
|
+
* All output is raw byte slices — no text decoding or re-encoding — so
|
|
183
|
+
* multi-byte UTF-8 codepoints and astral characters are never corrupted.
|
|
138
184
|
*/
|
|
139
185
|
export function processSuffixChunk(state: SuffixState, chunk: Uint8Array): SuffixChunkResult {
|
|
140
186
|
if (state.found) return { kind: 'passthrough' };
|
|
141
187
|
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
188
|
+
// Combine tail (if any) with the new chunk for searching.
|
|
189
|
+
const combined = state.tail ? concatBytes(state.tail, chunk) : chunk;
|
|
190
|
+
|
|
191
|
+
const idx = indexOfBytes(combined, SUFFIX_BYTES);
|
|
192
|
+
if (idx !== -1) {
|
|
193
|
+
state.found = true;
|
|
194
|
+
state.tail = null;
|
|
195
|
+
const beforeEnd = idx;
|
|
196
|
+
const afterStart = idx + SUFFIX_BYTES.byteLength;
|
|
197
|
+
return {
|
|
198
|
+
kind: 'suffixFound',
|
|
199
|
+
before: beforeEnd > 0 ? combined.slice(0, beforeEnd) : null,
|
|
200
|
+
after: afterStart < combined.byteLength ? combined.slice(afterStart) : null,
|
|
201
|
+
};
|
|
151
202
|
}
|
|
152
203
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
};
|
|
204
|
+
// Suffix not yet seen — emit everything except the last tailLen bytes,
|
|
205
|
+
// which might be the start of the suffix split across chunks.
|
|
206
|
+
const safeEnd = Math.max(0, combined.byteLength - state.tailLen);
|
|
207
|
+
state.tail = combined.slice(safeEnd);
|
|
208
|
+
|
|
209
|
+
if (safeEnd === 0) return { kind: 'noSuffix' };
|
|
210
|
+
return { kind: 'noSuffix', emitSafe: combined.slice(0, safeEnd) };
|
|
160
211
|
}
|
|
161
212
|
|
|
162
213
|
/**
|
|
163
|
-
* Final emit for the suffix mover.
|
|
164
|
-
*
|
|
214
|
+
* Final emit for the suffix mover. Returns any leftover tail bytes
|
|
215
|
+
* followed by the suffix. Always appends the suffix — even if none was
|
|
216
|
+
* ever observed in the input — so output is well-formed.
|
|
165
217
|
*/
|
|
166
|
-
export function suffixFlush(
|
|
218
|
+
export function suffixFlush(state: SuffixState): Uint8Array {
|
|
219
|
+
if (state.tail && state.tail.byteLength > 0) {
|
|
220
|
+
const result = concatBytes(state.tail, SUFFIX_BYTES);
|
|
221
|
+
state.tail = null;
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
state.tail = null;
|
|
167
225
|
return SUFFIX_BYTES;
|
|
168
226
|
}
|
|
169
227
|
|
|
@@ -110,10 +110,14 @@ export function createMoveSuffixStream(): TransformStream<Uint8Array, Uint8Array
|
|
|
110
110
|
return new TransformStream<Uint8Array, Uint8Array>({
|
|
111
111
|
transform(chunk, controller) {
|
|
112
112
|
const result = processSuffixChunk(state, chunk);
|
|
113
|
-
if (result.kind === 'passthrough'
|
|
113
|
+
if (result.kind === 'passthrough') {
|
|
114
114
|
controller.enqueue(chunk);
|
|
115
115
|
return;
|
|
116
116
|
}
|
|
117
|
+
if (result.kind === 'noSuffix') {
|
|
118
|
+
if (result.emitSafe) controller.enqueue(result.emitSafe);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
117
121
|
if (result.before) controller.enqueue(result.before);
|
|
118
122
|
if (result.after) controller.enqueue(result.after);
|
|
119
123
|
},
|
|
@@ -103,11 +103,16 @@ export function createNodeMoveSuffixTransform(): Transform {
|
|
|
103
103
|
return new Transform({
|
|
104
104
|
transform(chunk: Buffer, _encoding, callback) {
|
|
105
105
|
const result = processSuffixChunk(state, chunk);
|
|
106
|
-
if (result.kind === 'passthrough'
|
|
106
|
+
if (result.kind === 'passthrough') {
|
|
107
107
|
this.push(chunk);
|
|
108
108
|
callback();
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
|
+
if (result.kind === 'noSuffix') {
|
|
112
|
+
if (result.emitSafe) this.push(Buffer.from(result.emitSafe));
|
|
113
|
+
callback();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
111
116
|
if (result.before) this.push(Buffer.from(result.before));
|
|
112
117
|
if (result.after) this.push(Buffer.from(result.after));
|
|
113
118
|
callback();
|
|
@@ -446,7 +446,10 @@ export async function handleRequest(
|
|
|
446
446
|
// Fires before middleware so the browser can begin fetching critical
|
|
447
447
|
// assets while middleware runs. Non-fatal — a failing emitter never
|
|
448
448
|
// blocks the request.
|
|
449
|
-
|
|
449
|
+
//
|
|
450
|
+
// Skip on RSC requests (client-side navigations) — the browser already
|
|
451
|
+
// has CSS, fonts, and JS chunks from the initial full-page load.
|
|
452
|
+
if (config.earlyHints && !isRscRequest) {
|
|
450
453
|
try {
|
|
451
454
|
await config.earlyHints(match, req, responseHeaders);
|
|
452
455
|
} catch (err) {
|
|
@@ -177,6 +177,80 @@ function toUnkeyable(error: unknown): null {
|
|
|
177
177
|
return null;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Split a props record into data props (participate in the cache key) and
|
|
182
|
+
* slot values (React-element holes, excluded from the key) per the
|
|
183
|
+
* component's declared `slots` option (TIM-1173).
|
|
184
|
+
*
|
|
185
|
+
* Every DECLARED slot lands in `slotValues` — present or not — because the
|
|
186
|
+
* capture render always injects a SlotOutlet placeholder for each declared
|
|
187
|
+
* slot: the cached shell is identical whether the caller passed the slot
|
|
188
|
+
* or omitted it, so slot presence must not vary the key. `dataProps` is
|
|
189
|
+
* everything else and is what gets hashed.
|
|
190
|
+
*
|
|
191
|
+
* Returns null when the props OBJECT itself is outside the identity
|
|
192
|
+
* contract at the top level — accessors, symbol-keyed or non-enumerable
|
|
193
|
+
* properties, or a non-`Object.prototype` prototype. Splitting such an
|
|
194
|
+
* object would normalize away observable state (and reading an accessor
|
|
195
|
+
* would invoke user code, which this walk must never do) BEFORE
|
|
196
|
+
* `computeComponentCacheKey` gets the chance to reject it — a cached
|
|
197
|
+
* shell could then be served for inputs a live render distinguishes.
|
|
198
|
+
* Null means unkeyable: the caller renders live with the original props.
|
|
199
|
+
* The walk is descriptor-reads only; getters are never invoked. Nested
|
|
200
|
+
* data-prop values still face the full allowlist when hashed.
|
|
201
|
+
*/
|
|
202
|
+
/** Create an own enumerable data property — safe for keys like `__proto__`. */
|
|
203
|
+
function defineOwn(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
204
|
+
Object.defineProperty(target, key, {
|
|
205
|
+
value,
|
|
206
|
+
enumerable: true,
|
|
207
|
+
writable: true,
|
|
208
|
+
configurable: true,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function splitSlotProps(
|
|
213
|
+
props: Record<string, unknown>,
|
|
214
|
+
slots: readonly string[]
|
|
215
|
+
): { dataProps: Record<string, unknown>; slotValues: Record<string, unknown> } | null {
|
|
216
|
+
// TOTAL, like tryCanonicalize: the reflection ops below run against a
|
|
217
|
+
// user-controlled object, and a Proxy trap (getPrototypeOf, ownKeys,
|
|
218
|
+
// getOwnPropertyDescriptor) can throw arbitrarily — that must degrade
|
|
219
|
+
// to unkeyable (live render), not abort the request. toUnkeyable logs
|
|
220
|
+
// the unexpected throw for visibility. Codex P2 on PR #890.
|
|
221
|
+
try {
|
|
222
|
+
if (Object.getPrototypeOf(props) !== Object.prototype) return null;
|
|
223
|
+
if (Object.getOwnPropertySymbols(props).length > 0) return null;
|
|
224
|
+
const slotSet = new Set(slots);
|
|
225
|
+
const dataProps: Record<string, unknown> = {};
|
|
226
|
+
const slotValues: Record<string, unknown> = {};
|
|
227
|
+
// Own-property defines + Object.hasOwn, never assignment or `in`: a
|
|
228
|
+
// prop/slot legitimately named `__proto__` must land as an own data
|
|
229
|
+
// property (assignment would mutate the map's prototype), and an absent
|
|
230
|
+
// slot named `toString`/`constructor` must not read as present through
|
|
231
|
+
// the prototype chain (the provider would then expose the inherited
|
|
232
|
+
// function instead of undefined). Codex P2 on PR #890.
|
|
233
|
+
for (const key of Object.getOwnPropertyNames(props)) {
|
|
234
|
+
const desc = Object.getOwnPropertyDescriptor(props, key);
|
|
235
|
+
if (
|
|
236
|
+
desc === undefined ||
|
|
237
|
+
desc.get !== undefined ||
|
|
238
|
+
desc.set !== undefined ||
|
|
239
|
+
!desc.enumerable
|
|
240
|
+
) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
defineOwn(slotSet.has(key) ? slotValues : dataProps, key, desc.value);
|
|
244
|
+
}
|
|
245
|
+
for (const slot of slotSet) {
|
|
246
|
+
if (!Object.hasOwn(slotValues, slot)) defineOwn(slotValues, slot, undefined);
|
|
247
|
+
}
|
|
248
|
+
return { dataProps, slotValues };
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return toUnkeyable(error);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
180
254
|
/**
|
|
181
255
|
* Compute the cache-entry hash for a component render:
|
|
182
256
|
* `sha256({props, params}).slice(0, 16)` over canonical JSON.
|