@timber-js/app 0.2.0-alpha.180 → 0.2.0-alpha.182
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/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/internal.js +54 -2
- package/dist/client/internal.js.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +14 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +4 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/index.js +28 -4
- package/dist/index.js.map +1 -1
- package/dist/plugins/request-dep.d.ts +11 -0
- package/dist/plugins/request-dep.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/client/browser-entry/index.ts +0 -7
- package/src/client/browser-entry/post-hydration.ts +16 -5
- package/src/client/router.ts +27 -4
- package/src/client/rsc-fetch.ts +40 -0
- package/src/client/segment-cache.ts +14 -0
- package/src/plugins/request-dep.ts +47 -6
|
@@ -17,5 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { Plugin } from 'vite';
|
|
19
19
|
import type { PluginContext } from '../plugin-context.js';
|
|
20
|
+
import { type ProgramNode } from './callsite-ast.js';
|
|
21
|
+
export declare function isFrameworkModule(id: string): boolean;
|
|
22
|
+
export interface ModuleFacts {
|
|
23
|
+
/** Module directly calls a request accessor. */
|
|
24
|
+
callsAccessor: boolean;
|
|
25
|
+
/** Module imports a definition factory (defineSearchParams/defineCookie). */
|
|
26
|
+
importsDefinitionFactory: boolean;
|
|
27
|
+
/** Import specifiers where `.get()` was called on the binding. */
|
|
28
|
+
getterCallSources: string[];
|
|
29
|
+
}
|
|
30
|
+
export declare function analyzeModule(code: string, program: ProgramNode): ModuleFacts;
|
|
20
31
|
export declare function timberRequestDep(ctx: PluginContext): Plugin;
|
|
21
32
|
//# sourceMappingURL=request-dep.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request-dep.d.ts","sourceRoot":"","sources":["../../src/plugins/request-dep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AACnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"request-dep.d.ts","sourceRoot":"","sources":["../../src/plugins/request-dep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AACnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,EAEL,KAAK,WAAW,EAIjB,MAAM,mBAAmB,CAAC;AA0B3B,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAKrD;AA2BD,MAAM,WAAW,WAAW;IAC1B,gDAAgD;IAChD,aAAa,EAAE,OAAO,CAAC;IACvB,6EAA6E;IAC7E,wBAAwB,EAAE,OAAO,CAAC;IAClC,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAID,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,WAAW,CAiF7E;AA0LD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAgH3D"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.182",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
"jsonc-parser": "^3.3.1",
|
|
159
159
|
"magic-string": "^0.30.21",
|
|
160
160
|
"nitro": "3.0.260610-beta",
|
|
161
|
-
"srvx": "^0.
|
|
161
|
+
"srvx": "^0.12.0"
|
|
162
162
|
},
|
|
163
163
|
"peerDependencies": {
|
|
164
164
|
"@content-collections/core": "^0.14.0 || ^0.15.0",
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
"@content-collections/vite": "^0.2.0 || ^0.3.0",
|
|
167
167
|
"@mdx-js/rollup": "^3.0.0",
|
|
168
168
|
"@vitejs/plugin-react": "^6.0.0",
|
|
169
|
-
"@vitejs/plugin-rsc": ">=0.5.
|
|
169
|
+
"@vitejs/plugin-rsc": ">=0.5.28",
|
|
170
170
|
"nuqs": "^2.0.0",
|
|
171
171
|
"react": "19.2.7",
|
|
172
172
|
"react-dom": "19.2.7",
|
|
@@ -63,13 +63,6 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
63
63
|
setClientDeploymentId(deploymentId);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
// Take manual control of scroll restoration. Even though segment tree
|
|
67
|
-
// merging preserves shared layout DOM via cloneElement (so React doesn't
|
|
68
|
-
// reset scroll on those elements), the root-level reactRoot.render() with
|
|
69
|
-
// a new element tree can still cause scroll resets on the document during
|
|
70
|
-
// reconciliation. Manual control ensures consistent behavior.
|
|
71
|
-
window.history.scrollRestoration = 'manual';
|
|
72
|
-
|
|
73
66
|
// Step 2: Decode inlined RSC payload (may be null for JS-only clients)
|
|
74
67
|
const rscResult = createRscPayloadStream();
|
|
75
68
|
|
|
@@ -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
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
RedirectError,
|
|
17
17
|
ServerErrorResponse,
|
|
18
18
|
VersionSkewError,
|
|
19
|
+
NonRscResponse,
|
|
19
20
|
} from './rsc-fetch.js';
|
|
20
21
|
import { setHardNavigating, supersedeNavigationTransitions } from './navigation-root.js';
|
|
21
22
|
import type { FetchResult } from './rsc-fetch.js';
|
|
@@ -542,8 +543,16 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
542
543
|
departingUrl?: string;
|
|
543
544
|
}
|
|
544
545
|
): Promise<FetchResult & { navState: NavigationState }> {
|
|
545
|
-
// Check prefetch cache first.
|
|
546
|
-
//
|
|
546
|
+
// Check prefetch cache first. A negative entry means a prior prefetch
|
|
547
|
+
// determined this URL is not a route (non-RSC Content-Type). Hard-navigate
|
|
548
|
+
// immediately without a second fetch. See TIM-1231.
|
|
549
|
+
if (prefetchCache.isNegative(url)) {
|
|
550
|
+
prefetchCache.consume(url);
|
|
551
|
+
throw new NonRscResponse(url);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// PrefetchResult has optional segmentInfo/params fields — normalize
|
|
555
|
+
// to null for FetchResult compatibility.
|
|
547
556
|
const prefetched = prefetchCache.consume(url);
|
|
548
557
|
let result: FetchResult | undefined = prefetched
|
|
549
558
|
? {
|
|
@@ -686,7 +695,17 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
686
695
|
}
|
|
687
696
|
if (error instanceof ServerErrorResponse) {
|
|
688
697
|
setHardNavigating(true);
|
|
689
|
-
|
|
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;
|
|
701
|
+
await new Promise(() => {});
|
|
702
|
+
}
|
|
703
|
+
if (error instanceof NonRscResponse) {
|
|
704
|
+
setHardNavigating(true);
|
|
705
|
+
// Use the original `url` (with #fragment), not error.url which
|
|
706
|
+
// is hash-stripped. Preserves fragment on hard navigation (e.g.,
|
|
707
|
+
// /manual.pdf#page=5). Matches no-JS <a> behavior.
|
|
708
|
+
window.location.href = url;
|
|
690
709
|
await new Promise(() => {});
|
|
691
710
|
}
|
|
692
711
|
throw error;
|
|
@@ -804,7 +823,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
804
823
|
result.decodePromise?.catch(() => {});
|
|
805
824
|
prefetchCache.set(fetchUrl, result);
|
|
806
825
|
},
|
|
807
|
-
() => {
|
|
826
|
+
(error) => {
|
|
827
|
+
if (error instanceof NonRscResponse) {
|
|
828
|
+
prefetchCache.setNegative(fetchUrl);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
808
831
|
// Prefetch failure is non-fatal — navigation will fetch fresh
|
|
809
832
|
}
|
|
810
833
|
);
|
package/src/client/rsc-fetch.ts
CHANGED
|
@@ -246,6 +246,24 @@ export class ServerErrorResponse extends Error {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Thrown when the RSC fetch response has a Content-Type that is not
|
|
251
|
+
* text/x-component — e.g., a static asset (image, CSS, JS) served
|
|
252
|
+
* for a same-origin URL that isn't a route. The response body is
|
|
253
|
+
* cancelled immediately (headers-only cost). Caught in navigate()
|
|
254
|
+
* to trigger a hard navigation; caught in prefetch() to store a
|
|
255
|
+
* negative cache entry so click hard-navigates without a second fetch.
|
|
256
|
+
*
|
|
257
|
+
* See TIM-1231.
|
|
258
|
+
*/
|
|
259
|
+
export class NonRscResponse extends Error {
|
|
260
|
+
readonly url: string;
|
|
261
|
+
constructor(url: string) {
|
|
262
|
+
super(`Non-RSC response for ${url}`);
|
|
263
|
+
this.url = url;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
249
267
|
// ─── Stream Completion Tracking ───────────────────────────────────
|
|
250
268
|
|
|
251
269
|
/**
|
|
@@ -350,6 +368,15 @@ export async function fetchRscPayload(
|
|
|
350
368
|
if (response.headers.get('X-Timber-Error') === '1') {
|
|
351
369
|
throw new ServerErrorResponse(response.status, url);
|
|
352
370
|
}
|
|
371
|
+
// Content-Type guard: reject non-RSC responses (static assets, proxied
|
|
372
|
+
// files, etc.) before createFromFetch tries to parse the body as Flight
|
|
373
|
+
// data. Cancel the body immediately so the asset is never downloaded.
|
|
374
|
+
// See TIM-1231.
|
|
375
|
+
const contentType = response.headers.get('content-type');
|
|
376
|
+
if (!contentType || !contentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)) {
|
|
377
|
+
response.body?.cancel();
|
|
378
|
+
throw new NonRscResponse(url);
|
|
379
|
+
}
|
|
353
380
|
// Metadata (<title>/<meta>/<link>) now rides the RSC Flight payload
|
|
354
381
|
// as React elements — React 19 Float handles them. See TIM-1151.
|
|
355
382
|
segmentInfo = extractSegmentInfo(response);
|
|
@@ -405,6 +432,19 @@ export async function fetchRscPayload(
|
|
|
405
432
|
throw new RedirectError(location);
|
|
406
433
|
}
|
|
407
434
|
}
|
|
435
|
+
// Server error guard (same as production path above).
|
|
436
|
+
if (response.headers.get('X-Timber-Error') === '1') {
|
|
437
|
+
throw new ServerErrorResponse(response.status, url);
|
|
438
|
+
}
|
|
439
|
+
// Content-Type guard (same as production path above). See TIM-1231.
|
|
440
|
+
const fallbackContentType = response.headers.get('content-type');
|
|
441
|
+
if (
|
|
442
|
+
!fallbackContentType ||
|
|
443
|
+
!fallbackContentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)
|
|
444
|
+
) {
|
|
445
|
+
response.body?.cancel();
|
|
446
|
+
throw new NonRscResponse(url);
|
|
447
|
+
}
|
|
408
448
|
return {
|
|
409
449
|
payload: await response.text(),
|
|
410
450
|
decodePromise: null,
|
|
@@ -239,6 +239,9 @@ interface PrefetchEntry {
|
|
|
239
239
|
expiresAt: number;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/** Sentinel value for negative cache entries (URL is not a route). */
|
|
243
|
+
const NEGATIVE_ENTRY: PrefetchResult = Object.freeze({ payload: null });
|
|
244
|
+
|
|
242
245
|
/**
|
|
243
246
|
* Short-lived cache for hover-triggered prefetches. Entries expire after
|
|
244
247
|
* 30 seconds. When a link is clicked, the prefetched payload is consumed
|
|
@@ -276,4 +279,15 @@ export class PrefetchCache {
|
|
|
276
279
|
}
|
|
277
280
|
return result;
|
|
278
281
|
}
|
|
282
|
+
|
|
283
|
+
/** Store a negative entry — the URL is not a route (non-RSC Content-Type). */
|
|
284
|
+
setNegative(url: string): void {
|
|
285
|
+
this.set(url, NEGATIVE_ENTRY);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Check if the entry is a negative cache entry (URL is not a route). */
|
|
289
|
+
isNegative(url: string): boolean {
|
|
290
|
+
const entry = this.get(url);
|
|
291
|
+
return entry === NEGATIVE_ENTRY;
|
|
292
|
+
}
|
|
279
293
|
}
|
|
@@ -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
|
|