@timber-js/app 0.2.0-alpha.181 → 0.2.0-alpha.183
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/{build-output-helper-DF5uScqS.js → build-output-helper-BC-Zg0_w.js} +38 -8
- package/dist/_chunks/build-output-helper-BC-Zg0_w.js.map +1 -0
- package/dist/_chunks/{cloudflare-AHoWYTYr.js → cloudflare-nlD9KhDj.js} +21 -3
- package/dist/_chunks/{cloudflare-AHoWYTYr.js.map → cloudflare-nlD9KhDj.js.map} +1 -1
- package/dist/_chunks/plugin-context---kTF5v8.js.map +1 -1
- package/dist/adapters/build-output-helper.d.ts +6 -0
- package/dist/adapters/build-output-helper.d.ts.map +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.d.ts +10 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +2 -2
- package/dist/adapters/nitro.d.ts +14 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +14 -1
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/adapters/shared.d.ts +1 -1
- package/dist/adapters/shared.d.ts.map +1 -1
- package/dist/adapters/types.d.ts +14 -0
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/internal.js +75 -12
- package/dist/client/internal.js.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +2 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/index.js +352 -200
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +6 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-report.d.ts +4 -3
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/request-dep.d.ts +11 -0
- package/dist/plugins/request-dep.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +26 -2
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +12 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/sitemap-generator.d.ts +1 -1
- package/dist/server/sitemap-generator.d.ts.map +1 -1
- package/dist/server/static-generator.d.ts +106 -0
- package/dist/server/static-generator.d.ts.map +1 -0
- package/docs/learn/13-configuration.mdx +5 -1
- package/package.json +3 -3
- package/src/adapters/build-output-helper.ts +34 -0
- package/src/adapters/cloudflare.ts +43 -1
- package/src/adapters/nitro.ts +58 -2
- package/src/adapters/shared.ts +38 -10
- package/src/adapters/types.ts +14 -0
- package/src/client/browser-entry/index.ts +6 -7
- package/src/client/browser-entry/post-hydration.ts +16 -5
- package/src/client/router.ts +3 -1
- package/src/client/rsc-fetch.ts +136 -19
- package/src/index.ts +1 -1
- package/src/plugin-context.ts +6 -0
- package/src/plugins/adapter-build.ts +5 -0
- package/src/plugins/build-report.ts +6 -12
- package/src/plugins/request-dep.ts +47 -6
- package/src/plugins/static-build.ts +264 -2
- package/src/server/rsc-entry/index.ts +24 -0
- package/src/server/sitemap-generator.ts +1 -1
- package/src/server/static-generator.ts +692 -0
- package/dist/_chunks/build-output-helper-DF5uScqS.js.map +0 -1
|
@@ -40,7 +40,7 @@ import schemaCodecs from 'virtual:timber-schema';
|
|
|
40
40
|
import { setLinkCodecs } from '../../params/codec-registry.js';
|
|
41
41
|
setLinkCodecs(schemaCodecs);
|
|
42
42
|
|
|
43
|
-
import { setClientDeploymentId } from '../rsc-fetch.js';
|
|
43
|
+
import { setClientDeploymentId, setStaticMode } from '../rsc-fetch.js';
|
|
44
44
|
|
|
45
45
|
import { setupServerActions } from './action-dispatch.js';
|
|
46
46
|
import { createRscPayloadStream } from './rsc-stream.js';
|
|
@@ -63,12 +63,11 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
63
63
|
setClientDeploymentId(deploymentId);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
window.history.scrollRestoration = 'manual';
|
|
66
|
+
// Static mode: fetch _rsc/*.rsc files instead of route URLs with
|
|
67
|
+
// Accept headers. Static hosts ignore Accept headers (TIM-1243).
|
|
68
|
+
if ((runtimeConfig as Record<string, unknown>).output === 'static') {
|
|
69
|
+
setStaticMode(true);
|
|
70
|
+
}
|
|
72
71
|
|
|
73
72
|
// Step 2: Decode inlined RSC payload (may be null for JS-only clients)
|
|
74
73
|
const rscResult = createRscPayloadStream();
|
|
@@ -59,13 +59,24 @@ export function setupPostHydration({
|
|
|
59
59
|
segmentInfo: initialSegmentInfo,
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
62
|
+
// Leave scrollRestoration = 'auto' so the browser natively restores
|
|
63
|
+
// scroll on reload, tab restore, and back-from-external-page — cases
|
|
64
|
+
// JS cannot reliably cover because 'manual' persists on the session
|
|
65
|
+
// history entry across reloads. For client navigations the router's
|
|
66
|
+
// restoreScrollAfterPaint() re-applies the saved position after React
|
|
67
|
+
// commits, writing last and winning over the browser's auto-restore.
|
|
68
|
+
// Explicitly set 'auto' to heal entries from previously-deployed builds
|
|
69
|
+
// that set 'manual'.
|
|
70
|
+
window.history.scrollRestoration = 'auto';
|
|
71
|
+
|
|
72
|
+
// Initialize scroll state for the initial entry. On reload the browser's
|
|
73
|
+
// native restoration may land before or after this runs; the debounced
|
|
74
|
+
// scroll listener below updates the entry state to the true position.
|
|
75
|
+
const initialScrollY = getScrollY();
|
|
65
76
|
if (navApiController) {
|
|
66
|
-
navApiController.saveScrollPosition(
|
|
77
|
+
navApiController.saveScrollPosition(initialScrollY);
|
|
67
78
|
} else {
|
|
68
|
-
window.history.replaceState({ timber: true, scrollY:
|
|
79
|
+
window.history.replaceState({ timber: true, scrollY: initialScrollY }, '');
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
// Register popstate handler for back/forward navigation.
|
package/src/client/router.ts
CHANGED
|
@@ -695,7 +695,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
695
695
|
}
|
|
696
696
|
if (error instanceof ServerErrorResponse) {
|
|
697
697
|
setHardNavigating(true);
|
|
698
|
-
|
|
698
|
+
// Use the original `url` (with #fragment), not error.url which
|
|
699
|
+
// is hash-stripped. Preserves fragment on hard navigation.
|
|
700
|
+
window.location.href = url;
|
|
699
701
|
await new Promise(() => {});
|
|
700
702
|
}
|
|
701
703
|
if (error instanceof NonRscResponse) {
|
package/src/client/rsc-fetch.ts
CHANGED
|
@@ -88,6 +88,56 @@ export function getClientDeploymentId(): string | null {
|
|
|
88
88
|
return clientDeploymentId;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// ─── Static Mode ────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* When true, RSC fetches use _rsc/*.rsc file URLs instead of
|
|
95
|
+
* the route URL with Accept headers. Static hosts ignore Accept
|
|
96
|
+
* headers, so the client must fetch the pre-generated .rsc files
|
|
97
|
+
* directly. Set at bootstrap from virtual:timber-config output mode.
|
|
98
|
+
*/
|
|
99
|
+
let staticMode = false;
|
|
100
|
+
|
|
101
|
+
export function setStaticMode(enabled: boolean): void {
|
|
102
|
+
staticMode = enabled;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isStaticMode(): boolean {
|
|
106
|
+
return staticMode;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Convert a route URL to the corresponding _rsc/*.rsc file path.
|
|
111
|
+
* Mirrors the naming in plugins/static-build.ts staticOutputPath.
|
|
112
|
+
*
|
|
113
|
+
* / → /_rsc/index.rsc
|
|
114
|
+
* /about → /_rsc/about.rsc
|
|
115
|
+
* /blog/hello → /_rsc/blog/hello.rsc
|
|
116
|
+
*/
|
|
117
|
+
function toStaticRscUrl(url: string): string {
|
|
118
|
+
const hashIndex = url.indexOf('#');
|
|
119
|
+
const queryIndex = url.indexOf('?');
|
|
120
|
+
const hashEnd = hashIndex === -1 ? url.length : hashIndex;
|
|
121
|
+
const queryEnd = queryIndex === -1 ? url.length : queryIndex;
|
|
122
|
+
const end = Math.min(hashEnd, queryEnd);
|
|
123
|
+
let pathname = url.slice(0, end);
|
|
124
|
+
// Strip trailing slash (unless root) to match static build output naming
|
|
125
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
126
|
+
pathname = pathname.slice(0, -1);
|
|
127
|
+
}
|
|
128
|
+
const rscPath = pathname === '/' ? '/index' : pathname;
|
|
129
|
+
return `/_rsc${rscPath}.rsc`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Convert a route URL to the corresponding _rsc/*.params.json sidecar path.
|
|
134
|
+
* Used in static mode to fetch route params that are normally carried
|
|
135
|
+
* in the X-Timber-Params response header. See TIM-1246.
|
|
136
|
+
*/
|
|
137
|
+
function toStaticParamsUrl(url: string): string {
|
|
138
|
+
return toStaticRscUrl(url).replace(/\.rsc$/, '.params.json');
|
|
139
|
+
}
|
|
140
|
+
|
|
91
141
|
// ─── Reload Signal ───────────────────────────────────────────────
|
|
92
142
|
|
|
93
143
|
/** Header name used by the server to signal a version skew reload. */
|
|
@@ -326,8 +376,13 @@ export async function fetchRscPayload(
|
|
|
326
376
|
currentUrl?: string,
|
|
327
377
|
signal?: AbortSignal
|
|
328
378
|
): Promise<FetchResult> {
|
|
329
|
-
|
|
330
|
-
|
|
379
|
+
// In static mode, fetch the pre-generated _rsc/*.rsc file directly
|
|
380
|
+
// instead of the route URL with Accept headers. Static hosts ignore
|
|
381
|
+
// Accept headers, so the route URL would return HTML. The _rsc param
|
|
382
|
+
// is still appended for cache busting.
|
|
383
|
+
const fetchTarget = staticMode ? toStaticRscUrl(url) : url;
|
|
384
|
+
const rscUrl = appendRscParam(fetchTarget);
|
|
385
|
+
const headers = buildRscHeaders(staticMode ? undefined : stateTree, currentUrl);
|
|
331
386
|
if (deps.decodeRsc) {
|
|
332
387
|
// Production path: use createFromFetch for streaming RSC decoding.
|
|
333
388
|
// createFromFetch takes a Promise<Response> and progressively parses
|
|
@@ -336,6 +391,21 @@ export async function fetchRscPayload(
|
|
|
336
391
|
// Intercept the response to read segment metadata before createFromFetch
|
|
337
392
|
// consumes the body. Reading headers does NOT consume the body stream.
|
|
338
393
|
const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
|
|
394
|
+
// In static mode, fetch the params sidecar in parallel. The .rsc
|
|
395
|
+
// file has no HTTP headers, so params come from a .params.json
|
|
396
|
+
// sidecar written at build time. See TIM-1246.
|
|
397
|
+
const staticParamsFetch = staticMode
|
|
398
|
+
? deps
|
|
399
|
+
.fetch(toStaticParamsUrl(url), { signal })
|
|
400
|
+
.then((r) => (r.ok ? (r.json() as Promise<Record<string, string | string[]>>) : null))
|
|
401
|
+
.catch((e) => {
|
|
402
|
+
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
|
403
|
+
return null;
|
|
404
|
+
})
|
|
405
|
+
: null;
|
|
406
|
+
// Observe the sidecar promise so its rejection doesn't become
|
|
407
|
+
// unhandled if wrappedPromise rejects first (TIM-1248).
|
|
408
|
+
staticParamsFetch?.catch(() => {});
|
|
339
409
|
let segmentInfo: SegmentInfo[] | null = null;
|
|
340
410
|
let params: Record<string, string | string[]> | null = null;
|
|
341
411
|
let skippedSegments: string[] | null = null;
|
|
@@ -368,14 +438,27 @@ export async function fetchRscPayload(
|
|
|
368
438
|
if (response.headers.get('X-Timber-Error') === '1') {
|
|
369
439
|
throw new ServerErrorResponse(response.status, url);
|
|
370
440
|
}
|
|
371
|
-
// Content-Type guard: reject non-RSC responses
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
441
|
+
// Content-Type guard: reject non-RSC responses before createFromFetch
|
|
442
|
+
// tries to parse the body as Flight data.
|
|
443
|
+
// In static mode, accept octet-stream/text/plain/absent content-type
|
|
444
|
+
// (static hosts serve .rsc files with these), but still reject 404s
|
|
445
|
+
// and text/html (missing .rsc file → host returns 404 page or SPA
|
|
446
|
+
// HTML fallback). See TIM-1231, TIM-1243, TIM-1247.
|
|
447
|
+
if (staticMode) {
|
|
448
|
+
const contentType = response.headers.get('content-type');
|
|
449
|
+
if (
|
|
450
|
+
!response.ok ||
|
|
451
|
+
(contentType && contentType.split(';')[0].trim().toLowerCase() === 'text/html')
|
|
452
|
+
) {
|
|
453
|
+
response.body?.cancel();
|
|
454
|
+
throw new NonRscResponse(url);
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
const contentType = response.headers.get('content-type');
|
|
458
|
+
if (!contentType || !contentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)) {
|
|
459
|
+
response.body?.cancel();
|
|
460
|
+
throw new NonRscResponse(url);
|
|
461
|
+
}
|
|
379
462
|
}
|
|
380
463
|
// Metadata (<title>/<meta>/<link>) now rides the RSC Flight payload
|
|
381
464
|
// as React elements — React 19 Float handles them. See TIM-1151.
|
|
@@ -400,6 +483,11 @@ export async function fetchRscPayload(
|
|
|
400
483
|
});
|
|
401
484
|
// Await headers so segmentInfo/params are populated.
|
|
402
485
|
await wrappedPromise;
|
|
486
|
+
// In static mode, params come from the sidecar (header extraction
|
|
487
|
+
// returns null because .rsc files have no HTTP headers). TIM-1246.
|
|
488
|
+
if (staticParamsFetch && !params) {
|
|
489
|
+
params = await staticParamsFetch;
|
|
490
|
+
}
|
|
403
491
|
// Start decoding but do NOT await — return the in-progress thenable.
|
|
404
492
|
// React can render a Flight thenable directly: it suspends on unresolved
|
|
405
493
|
// parts and progressively renders as chunks arrive, spreading work across
|
|
@@ -432,20 +520,49 @@ export async function fetchRscPayload(
|
|
|
432
520
|
throw new RedirectError(location);
|
|
433
521
|
}
|
|
434
522
|
}
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
) {
|
|
441
|
-
response.
|
|
442
|
-
|
|
523
|
+
// Server error guard (same as production path above).
|
|
524
|
+
if (response.headers.get('X-Timber-Error') === '1') {
|
|
525
|
+
throw new ServerErrorResponse(response.status, url);
|
|
526
|
+
}
|
|
527
|
+
// Content-Type guard (same as production path above). See TIM-1231, TIM-1243, TIM-1247.
|
|
528
|
+
if (staticMode) {
|
|
529
|
+
const fallbackContentType = response.headers.get('content-type');
|
|
530
|
+
if (
|
|
531
|
+
!response.ok ||
|
|
532
|
+
(fallbackContentType &&
|
|
533
|
+
fallbackContentType.split(';')[0].trim().toLowerCase() === 'text/html')
|
|
534
|
+
) {
|
|
535
|
+
response.body?.cancel();
|
|
536
|
+
throw new NonRscResponse(url);
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
const fallbackContentType = response.headers.get('content-type');
|
|
540
|
+
if (
|
|
541
|
+
!fallbackContentType ||
|
|
542
|
+
!fallbackContentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)
|
|
543
|
+
) {
|
|
544
|
+
response.body?.cancel();
|
|
545
|
+
throw new NonRscResponse(url);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
let fallbackParams = extractParams(response);
|
|
549
|
+
// In static mode, params come from the sidecar. TIM-1246.
|
|
550
|
+
if (staticMode && !fallbackParams) {
|
|
551
|
+
try {
|
|
552
|
+
const paramsResponse = await deps.fetch(toStaticParamsUrl(url), { signal });
|
|
553
|
+
if (paramsResponse.ok) {
|
|
554
|
+
fallbackParams = (await paramsResponse.json()) as Record<string, string | string[]>;
|
|
555
|
+
}
|
|
556
|
+
} catch (e) {
|
|
557
|
+
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
|
558
|
+
// No params sidecar — non-dynamic route, params stay null
|
|
559
|
+
}
|
|
443
560
|
}
|
|
444
561
|
return {
|
|
445
562
|
payload: await response.text(),
|
|
446
563
|
decodePromise: null,
|
|
447
564
|
segmentInfo: extractSegmentInfo(response),
|
|
448
|
-
params:
|
|
565
|
+
params: fallbackParams,
|
|
449
566
|
skippedSegments: extractSkippedSegments(response),
|
|
450
567
|
};
|
|
451
568
|
}
|
package/src/index.ts
CHANGED
|
@@ -557,13 +557,13 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
557
557
|
timberRouting(ctx),
|
|
558
558
|
timberEntries(ctx),
|
|
559
559
|
timberBuildManifest(ctx),
|
|
560
|
-
timberStaticBuild(ctx),
|
|
561
560
|
timberFonts(ctx),
|
|
562
561
|
timberMdx(ctx),
|
|
563
562
|
timberRequestDep(ctx),
|
|
564
563
|
timberCacheTransform(ctx),
|
|
565
564
|
timberPrerenderSugar(ctx), // export const prerender → cache.component() desugaring
|
|
566
565
|
timberPrebuilt(ctx), // cache.component() → __prebuilt() callsite rewrite
|
|
566
|
+
timberStaticBuild(ctx), // Static mode: validate + post-build HTML generation
|
|
567
567
|
timberContent(ctx),
|
|
568
568
|
timberServerBundle(), // Bundle all deps in server environments for prod
|
|
569
569
|
timberCloudflareWasm(ctx), // Externalize WASM for Cloudflare Workers
|
package/src/plugin-context.ts
CHANGED
|
@@ -160,6 +160,12 @@ export interface PluginContext {
|
|
|
160
160
|
* ranges and rewrite CODE via MagicString; the AST itself is never mutated.
|
|
161
161
|
*/
|
|
162
162
|
parseCached?: ParseMemo;
|
|
163
|
+
/**
|
|
164
|
+
* Content-Type mappings for extensionless static API route outputs.
|
|
165
|
+
* Populated during static generation, consumed by adapter-build to
|
|
166
|
+
* write per-path Content-Type rules in _headers. See TIM-1241.
|
|
167
|
+
*/
|
|
168
|
+
staticContentTypes?: Record<string, string>;
|
|
163
169
|
}
|
|
164
170
|
|
|
165
171
|
// ── AST parse memo ───────────────────────────────────────────────────────
|
|
@@ -88,6 +88,11 @@ export function timberAdapterBuild(ctx: PluginContext): Plugin {
|
|
|
88
88
|
output: ctx.config.output ?? 'server',
|
|
89
89
|
clientJavascriptDisabled: ctx.clientJavascript.disabled,
|
|
90
90
|
manifestInit,
|
|
91
|
+
staticPagesDir:
|
|
92
|
+
(ctx.config.output ?? 'server') === 'static'
|
|
93
|
+
? join(buildDir, 'static-pages')
|
|
94
|
+
: undefined,
|
|
95
|
+
staticContentTypes: ctx.staticContentTypes,
|
|
91
96
|
};
|
|
92
97
|
|
|
93
98
|
await adapter.buildOutput(adapterConfig, buildDir);
|
|
@@ -48,25 +48,19 @@ const ROUTE_TYPE_ICONS: Record<RouteType, string> = {
|
|
|
48
48
|
/**
|
|
49
49
|
* Classify a route by its segment chain and output mode.
|
|
50
50
|
*
|
|
51
|
-
* In server mode (default), all pages are dynamic (rendered per-request)
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* In server mode (default), all pages are dynamic (rendered per-request)
|
|
52
|
+
* and API routes (route.ts) are classified as function.
|
|
53
|
+
* In static mode, everything is pre-rendered at build time — all routes
|
|
54
|
+
* are static regardless of dynamic segments or route.ts presence.
|
|
54
55
|
*/
|
|
55
56
|
export function classifyRoute(
|
|
56
57
|
segments: SegmentNode[],
|
|
57
58
|
outputMode: 'server' | 'static' = 'server'
|
|
58
59
|
): RouteType {
|
|
60
|
+
if (outputMode === 'static') return 'static';
|
|
59
61
|
const leaf = segments[segments.length - 1];
|
|
60
62
|
if (leaf?.route) return 'function';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const isDynamic = segments.some(
|
|
64
|
-
(s) =>
|
|
65
|
-
s.segmentType === 'dynamic' ||
|
|
66
|
-
s.segmentType === 'catch-all' ||
|
|
67
|
-
s.segmentType === 'optional-catch-all'
|
|
68
|
-
);
|
|
69
|
-
return isDynamic ? 'dynamic' : 'static';
|
|
63
|
+
return 'dynamic';
|
|
70
64
|
}
|
|
71
65
|
|
|
72
66
|
// ─── Size helpers ─────────────────────────────────────────────────────────
|
|
@@ -48,6 +48,15 @@ const DEFINITION_FACTORY_SOURCES = new Set([
|
|
|
48
48
|
|
|
49
49
|
const DEFINITION_FACTORY_NAMES = new Set(['defineSearchParams', 'defineCookie']);
|
|
50
50
|
|
|
51
|
+
// ─── Framework module detection ─────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export function isFrameworkModule(id: string): boolean {
|
|
54
|
+
return (
|
|
55
|
+
/[\\/]node_modules[\\/]@timber-js[\\/]app[\\/]/.test(id) ||
|
|
56
|
+
/[\\/]packages[\\/]timber-app[\\/]src[\\/]/.test(id)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
// ─── Fast token pre-filter ───────────────────────────────────────────────
|
|
52
61
|
|
|
53
62
|
const ACCESSOR_TOKENS = [
|
|
@@ -73,7 +82,7 @@ function hasRelevantToken(code: string): boolean {
|
|
|
73
82
|
|
|
74
83
|
// ─── Per-module facts ────────────────────────────────────────────────────
|
|
75
84
|
|
|
76
|
-
interface ModuleFacts {
|
|
85
|
+
export interface ModuleFacts {
|
|
77
86
|
/** Module directly calls a request accessor. */
|
|
78
87
|
callsAccessor: boolean;
|
|
79
88
|
/** Module imports a definition factory (defineSearchParams/defineCookie). */
|
|
@@ -84,7 +93,7 @@ interface ModuleFacts {
|
|
|
84
93
|
|
|
85
94
|
// ─── AST analysis ────────────────────────────────────────────────────────
|
|
86
95
|
|
|
87
|
-
function analyzeModule(code: string, program: ProgramNode): ModuleFacts {
|
|
96
|
+
export function analyzeModule(code: string, program: ProgramNode): ModuleFacts {
|
|
88
97
|
const facts: ModuleFacts = {
|
|
89
98
|
callsAccessor: false,
|
|
90
99
|
importsDefinitionFactory: false,
|
|
@@ -224,7 +233,34 @@ function walkForCall(
|
|
|
224
233
|
return false;
|
|
225
234
|
}
|
|
226
235
|
|
|
227
|
-
/**
|
|
236
|
+
/**
|
|
237
|
+
* Check if the object chain of a `.get()` call contains `localName`.
|
|
238
|
+
* Walks through MemberExpression objects and CallExpression callees
|
|
239
|
+
* to handle chained patterns like `getFilterCookie(key).get()` or
|
|
240
|
+
* `cookies.theme.get()`.
|
|
241
|
+
*/
|
|
242
|
+
function objectChainContains(node: Record<string, unknown>, name: string): boolean {
|
|
243
|
+
if (node.type === 'Identifier' && (node as unknown as IdentifierNode).name === name) return true;
|
|
244
|
+
if (node.type === 'MemberExpression') {
|
|
245
|
+
return objectChainContains(
|
|
246
|
+
(node as unknown as MemberExpressionNode).object as Record<string, unknown>,
|
|
247
|
+
name
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (node.type === 'CallExpression') {
|
|
251
|
+
return objectChainContains(
|
|
252
|
+
(node as unknown as CallExpressionNode).callee as Record<string, unknown>,
|
|
253
|
+
name
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Check if `.get(...)` is called on `localName` anywhere in the module.
|
|
261
|
+
* Handles direct (`localName.get()`), chained call (`localName(args).get()`),
|
|
262
|
+
* and nested member (`localName.prop.get()`) patterns.
|
|
263
|
+
*/
|
|
228
264
|
function hasGetterCall(node: Record<string, unknown>, localName: string): boolean {
|
|
229
265
|
if (node.type === 'CallExpression') {
|
|
230
266
|
const call = node as unknown as CallExpressionNode;
|
|
@@ -232,10 +268,9 @@ function hasGetterCall(node: Record<string, unknown>, localName: string): boolea
|
|
|
232
268
|
const member = call.callee as MemberExpressionNode;
|
|
233
269
|
if (
|
|
234
270
|
!member.computed &&
|
|
235
|
-
member.object.type === 'Identifier' &&
|
|
236
|
-
(member.object as IdentifierNode).name === localName &&
|
|
237
271
|
member.property.type === 'Identifier' &&
|
|
238
|
-
(member.property as IdentifierNode).name === 'get'
|
|
272
|
+
(member.property as IdentifierNode).name === 'get' &&
|
|
273
|
+
objectChainContains(member.object as Record<string, unknown>, localName)
|
|
239
274
|
) {
|
|
240
275
|
return true;
|
|
241
276
|
}
|
|
@@ -342,6 +377,12 @@ export function timberRequestDep(ctx: PluginContext): Plugin {
|
|
|
342
377
|
if (!/\.[mc]?[jt]sx?$/.test(id) && !/\.mdx?$/.test(id)) return;
|
|
343
378
|
// Skip virtual modules
|
|
344
379
|
if (id.startsWith('\0') || id.startsWith('virtual:')) return;
|
|
380
|
+
// Skip framework modules — their internal accessor calls (e.g.,
|
|
381
|
+
// getCookieJar in define-cookie.ts) are implementation details.
|
|
382
|
+
// In dev they're externalized and never reach transform; in prod
|
|
383
|
+
// they would cause false positives (any module importing defineCookie
|
|
384
|
+
// would be tainted). Analysis relies on user-code .get() patterns.
|
|
385
|
+
if (isFrameworkModule(id)) return;
|
|
345
386
|
// Fast token check
|
|
346
387
|
if (!hasRelevantToken(code)) return;
|
|
347
388
|
|