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