@timber-js/app 0.2.0-alpha.209 → 0.2.0-alpha.210
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-BerlqoXA.js → actions-Rjk4htmA.js} +19 -25
- package/dist/_chunks/{actions-BerlqoXA.js.map → actions-Rjk4htmA.js.map} +1 -1
- package/dist/_chunks/als-registry-DaxkVjt5.js.map +1 -1
- package/dist/_chunks/{cache-api-CR23J_NC.js → cache-api-DGdYfNJn.js} +4 -4
- package/dist/_chunks/{cache-api-CR23J_NC.js.map → cache-api-DGdYfNJn.js.map} +1 -1
- package/dist/_chunks/{chains-CpFg56UB.js → chains-BoO51joc.js} +2 -2
- package/dist/_chunks/{chains-CpFg56UB.js.map → chains-BoO51joc.js.map} +1 -1
- package/dist/_chunks/{cli-check-C6Ev6wBO.js → cli-check-ajNY3B2e.js} +3 -3
- package/dist/_chunks/{cli-check-C6Ev6wBO.js.map → cli-check-ajNY3B2e.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js → cli-schema-sync-D2eI8jEg.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js.map → cli-schema-sync-D2eI8jEg.js.map} +1 -1
- package/dist/_chunks/{file-cache-Dw6BJPG7.js → codegen-Bps1sLKJ.js} +3 -29
- package/dist/_chunks/codegen-Bps1sLKJ.js.map +1 -0
- package/dist/_chunks/{convention-lint-jKTwKwPe.js → convention-lint-DLmhGsRS.js} +54 -316
- package/dist/_chunks/convention-lint-DLmhGsRS.js.map +1 -0
- package/dist/_chunks/{dev-server-C4WZdB7L.js → dev-server-v97rQH4b.js} +99 -9
- package/dist/_chunks/dev-server-v97rQH4b.js.map +1 -0
- package/dist/_chunks/{error-boundary-tA7kVfs4.js → error-boundary-DsNScGRM.js} +4 -4
- package/dist/_chunks/{error-boundary-tA7kVfs4.js.map → error-boundary-DsNScGRM.js.map} +1 -1
- package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js → json-lossy-check-CVuRs2hG.js} +2 -2
- package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js.map → json-lossy-check-CVuRs2hG.js.map} +1 -1
- package/dist/_chunks/{live-graph-Dv-JJCZw.js → live-graph-9cSnn_h9.js} +3 -3
- package/dist/_chunks/{live-graph-Dv-JJCZw.js.map → live-graph-9cSnn_h9.js.map} +1 -1
- package/dist/_chunks/{logger-DiDt5ppH.js → logger-BP0LN6vP.js} +17 -2
- package/dist/_chunks/{logger-DiDt5ppH.js.map → logger-BP0LN6vP.js.map} +1 -1
- package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -1
- package/dist/_chunks/navigation-root-BQfo1-kG.js.map +1 -1
- package/dist/_chunks/{poison-scan-CpeT6_OJ.js → poison-scan-vGV7Re0B.js} +2 -2
- package/dist/_chunks/{poison-scan-CpeT6_OJ.js.map → poison-scan-vGV7Re0B.js.map} +1 -1
- package/dist/_chunks/{scanner-Bw0oq1HB.js → scanner-DmqdxzbW.js} +392 -7
- package/dist/_chunks/scanner-DmqdxzbW.js.map +1 -0
- package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
- package/dist/_chunks/{sizeof-UwzwB1uM.js → sizeof-BM1409x2.js} +2 -2
- package/dist/_chunks/{sizeof-UwzwB1uM.js.map → sizeof-BM1409x2.js.map} +1 -1
- package/dist/_chunks/{status-page-marker-gaihi0KZ.js → status-page-marker-BRX9Ib-d.js} +1 -45
- package/dist/_chunks/status-page-marker-BRX9Ib-d.js.map +1 -0
- package/dist/_chunks/{walkers-BXExhzzk.js → walkers-Czu2jXFq.js} +3 -3
- package/dist/_chunks/{walkers-BXExhzzk.js.map → walkers-Czu2jXFq.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/analyze/crawl-entry.js +2 -2
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cache/index.js +2 -2
- package/dist/cache/stores/memory.js +1 -1
- package/dist/cli.js +3 -3
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/history.d.ts +0 -9
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +9 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +25 -38
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +22 -0
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts +14 -2
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +9 -1
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/navigation-transition.d.ts +6 -1
- package/dist/client/navigation-transition.d.ts.map +1 -1
- package/dist/client/react-root.d.ts.map +1 -1
- package/dist/client/router-effects.d.ts +10 -3
- package/dist/client/router-effects.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +3 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +65 -9
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +0 -15
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-router.d.ts +13 -6
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/config-validation.d.ts +19 -2
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -7
- package/dist/index.js.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/export-detect.d.ts +19 -0
- package/dist/routing/export-detect.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/types.d.ts +7 -0
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +9 -4
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/csrf.d.ts +38 -19
- package/dist/server/csrf.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +8 -4
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/form-flash.d.ts +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +249 -16
- package/dist/server/internal.js.map +1 -1
- package/dist/server/logger.d.ts +16 -3
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/metadata-collector.d.ts +2 -0
- package/dist/server/metadata-collector.d.ts.map +1 -1
- package/dist/server/metadata-routes.d.ts +12 -1
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +29 -1
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +8 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +8 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/{wrap-action-dispatch.d.ts → action-dispatcher.d.ts} +18 -40
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/safe-load.d.ts +5 -12
- package/dist/server/safe-load.d.ts.map +1 -1
- package/docs/api/30-api-server.mdx +1 -1
- package/docs/api/31-api-client.mdx +23 -20
- package/docs/api/34-api-config.mdx +4 -2
- package/package.json +1 -1
- package/src/client/browser-entry/action-dispatch.ts +28 -28
- package/src/client/browser-entry/post-hydration.ts +11 -1
- package/src/client/browser-entry/router-init.ts +10 -4
- package/src/client/history.ts +0 -22
- package/src/client/index.ts +2 -1
- package/src/client/link.tsx +30 -1
- package/src/client/navigation-api.ts +36 -3
- package/src/client/navigation-root.tsx +13 -1
- package/src/client/navigation-transition.ts +17 -7
- package/src/client/react-root.ts +11 -2
- package/src/client/router-effects.ts +11 -4
- package/src/client/router-pipeline.ts +4 -0
- package/src/client/router-types.ts +80 -6
- package/src/client/router.ts +36 -24
- package/src/client/segment-cache.ts +0 -65
- package/src/client/use-router.ts +25 -6
- package/src/config-validation.ts +121 -5
- package/src/index.ts +5 -8
- package/src/routing/convention-lint.ts +75 -0
- package/src/routing/export-detect.ts +88 -0
- package/src/routing/manifest-codegen.ts +6 -0
- package/src/routing/scanner.ts +9 -0
- package/src/routing/types.ts +7 -0
- package/src/server/action-handler.ts +14 -11
- package/src/server/actions.ts +37 -42
- package/src/server/als-registry.ts +1 -1
- package/src/server/csrf.ts +100 -72
- package/src/server/error-boundary-wrapper.ts +11 -12
- package/src/server/fallback-error.ts +13 -21
- package/src/server/form-flash.ts +1 -1
- package/src/server/logger.ts +19 -3
- package/src/server/metadata-collector.ts +4 -1
- package/src/server/metadata-routes.ts +23 -8
- package/src/server/pipeline-helpers.ts +89 -8
- package/src/server/pipeline.ts +27 -2
- package/src/server/route-element-builder.ts +1 -0
- package/src/server/route-matcher.ts +11 -0
- package/src/server/rsc-entry/{wrap-action-dispatch.ts → action-dispatcher.ts} +20 -62
- package/src/server/rsc-entry/index.ts +14 -27
- package/src/server/safe-load.ts +5 -12
- package/dist/_chunks/convention-lint-jKTwKwPe.js.map +0 -1
- package/dist/_chunks/dev-server-C4WZdB7L.js.map +0 -1
- package/dist/_chunks/file-cache-Dw6BJPG7.js.map +0 -1
- package/dist/_chunks/scanner-Bw0oq1HB.js.map +0 -1
- package/dist/_chunks/status-page-marker-gaihi0KZ.js.map +0 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +0 -1
package/src/client/router.ts
CHANGED
|
@@ -22,7 +22,14 @@ import { createScrollEffects, createSpaExits, createNavigationRecovery } from '.
|
|
|
22
22
|
import { createNavigationLifecycle } from './router-lifecycle.ts';
|
|
23
23
|
import { createNavigationPipeline, prefetchKeyFor } from './router-pipeline.ts';
|
|
24
24
|
import { recordSkew } from './router-skew.ts';
|
|
25
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
NavigationOptions,
|
|
27
|
+
NavigationTransitionType,
|
|
28
|
+
RefreshOptions,
|
|
29
|
+
RouterDeps,
|
|
30
|
+
RouterInstance,
|
|
31
|
+
TraverseDirection,
|
|
32
|
+
} from './router-types.ts';
|
|
26
33
|
import { createRenderOwner, type CommitOutcome } from './navigation-transition.ts';
|
|
27
34
|
|
|
28
35
|
// ─── Router Factory ──────────────────────────────────────────────
|
|
@@ -95,7 +102,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
95
102
|
currentOwner,
|
|
96
103
|
leaveSpaIfOwned,
|
|
97
104
|
// Hoisted — `navigate` is a function declaration below.
|
|
98
|
-
navigate: (url) => navigate(url, { replace: true }),
|
|
105
|
+
navigate: (url, types) => navigate(url, { replace: true, _renderTypes: types }),
|
|
99
106
|
});
|
|
100
107
|
|
|
101
108
|
async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
|
|
@@ -103,6 +110,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
103
110
|
const replace = options.replace === true;
|
|
104
111
|
const externalSignal = options._signal as AbortSignal | undefined;
|
|
105
112
|
const skipHistory = options._skipHistory === true;
|
|
113
|
+
// A push, a replace, and a server action redirect all move the user
|
|
114
|
+
// forward; the caller's own types ride beside it. A redirect this router
|
|
115
|
+
// follows keeps the types of the render it interrupted instead.
|
|
116
|
+
const types = options._renderTypes ?? [
|
|
117
|
+
'navigation-forward' satisfies NavigationTransitionType,
|
|
118
|
+
...(options.transitionTypes ?? []),
|
|
119
|
+
];
|
|
106
120
|
|
|
107
121
|
// Split the #fragment off the navigation URL (TIM-1035). The full URL
|
|
108
122
|
// (with hash) is committed to the address bar; the hash-less URL is used
|
|
@@ -161,6 +175,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
161
175
|
await renderViaTransition(
|
|
162
176
|
fetchUrl,
|
|
163
177
|
owner,
|
|
178
|
+
types,
|
|
164
179
|
() =>
|
|
165
180
|
performNavigationFetch(fetchUrl, {
|
|
166
181
|
replace,
|
|
@@ -185,7 +200,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
185
200
|
// load of the destination, fragment included (TIM-1234). Reloading
|
|
186
201
|
// instead would rebuild the page they were *leaving* and discard
|
|
187
202
|
// the click (TIM-1275).
|
|
188
|
-
if (await recoverFromNavigationError(error, owner, url, departingUrl)) return;
|
|
203
|
+
if (await recoverFromNavigationError(error, owner, url, departingUrl, types)) return;
|
|
189
204
|
throw error;
|
|
190
205
|
}
|
|
191
206
|
},
|
|
@@ -204,6 +219,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
204
219
|
*/
|
|
205
220
|
async function fetchCommitAndRender(
|
|
206
221
|
url: string,
|
|
222
|
+
/** The view transition type the render adds. */
|
|
223
|
+
type: NavigationTransitionType,
|
|
207
224
|
opts: {
|
|
208
225
|
/** Omitted by refresh(): the server renders the complete payload. */
|
|
209
226
|
stateTree?: { segments: string[] };
|
|
@@ -221,6 +238,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
221
238
|
await renderViaTransition(
|
|
222
239
|
url,
|
|
223
240
|
owner,
|
|
241
|
+
[type],
|
|
224
242
|
async () => {
|
|
225
243
|
const result = await fetchRscPayload(
|
|
226
244
|
url,
|
|
@@ -252,7 +270,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
252
270
|
// browser has already traversed, and `refresh()` is by definition
|
|
253
271
|
// where it already is. So `hardNavigate()` takes its same-document
|
|
254
272
|
// branch and reloads rather than pushing an entry.
|
|
255
|
-
if (await recoverFromNavigationError(error, owner, url, url)) return;
|
|
273
|
+
if (await recoverFromNavigationError(error, owner, url, url, [type])) return;
|
|
256
274
|
throw error;
|
|
257
275
|
}
|
|
258
276
|
|
|
@@ -262,7 +280,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
262
280
|
);
|
|
263
281
|
}
|
|
264
282
|
|
|
265
|
-
async function refresh(options
|
|
283
|
+
async function refresh(options: RefreshOptions = {}): Promise<void> {
|
|
266
284
|
const currentUrl = deps.getCurrentUrl();
|
|
267
285
|
|
|
268
286
|
// A refresh on a stale client is a full document load of the current URL.
|
|
@@ -273,13 +291,16 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
273
291
|
await leaveSpaSuperseding(currentUrl, currentUrl);
|
|
274
292
|
}
|
|
275
293
|
|
|
276
|
-
await fetchCommitAndRender(currentUrl,
|
|
294
|
+
await fetchCommitAndRender(currentUrl, options.transitionType ?? 'refresh', {
|
|
295
|
+
onCommit: options.onCommit,
|
|
296
|
+
});
|
|
277
297
|
}
|
|
278
298
|
|
|
279
299
|
async function handlePopState(
|
|
280
300
|
url: string,
|
|
281
301
|
scrollY: number = 0,
|
|
282
|
-
externalSignal?: AbortSignal
|
|
302
|
+
externalSignal?: AbortSignal,
|
|
303
|
+
direction: TraverseDirection = 'navigation-traverse'
|
|
283
304
|
): Promise<void> {
|
|
284
305
|
// A stale client hard-navigates on back/forward too — this path never
|
|
285
306
|
// reaches navigate(), so it needs the same check. `url` is where the
|
|
@@ -326,7 +347,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
326
347
|
// that misses one of them (TIM-1478). The perform resolves
|
|
327
348
|
// immediately: there is no fetch, and the cached payload is
|
|
328
349
|
// already decoded.
|
|
329
|
-
await renderViaTransition(url, owner, async () => {
|
|
350
|
+
await renderViaTransition(url, owner, [direction], async () => {
|
|
330
351
|
const { navState, commit } = prepareNavigation(url, {
|
|
331
352
|
payload: entry.payload,
|
|
332
353
|
params: entry.params,
|
|
@@ -352,7 +373,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
352
373
|
// This happens when navigating back to the initial SSR'd page
|
|
353
374
|
// (its payload is null since it was rendered via SSR, not RSC fetch)
|
|
354
375
|
// or when the entry doesn't exist at all.
|
|
355
|
-
await fetchCommitAndRender(url, {
|
|
376
|
+
await fetchCommitAndRender(url, direction, {
|
|
356
377
|
stateTree: currentStateTree(),
|
|
357
378
|
scrollY,
|
|
358
379
|
externalSignal,
|
|
@@ -447,7 +468,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
447
468
|
let outcomeResolve!: (o: CommitOutcome) => void;
|
|
448
469
|
const outcomePromise = new Promise<CommitOutcome>((r) => (outcomeResolve = r));
|
|
449
470
|
const [, outcome] = await Promise.all([
|
|
450
|
-
refresh({ onCommit: outcomeResolve }).catch(() => {}),
|
|
471
|
+
refresh({ transitionType: 'revalidate', onCommit: outcomeResolve }).catch(() => {}),
|
|
451
472
|
outcomePromise,
|
|
452
473
|
]);
|
|
453
474
|
return outcome === 'committed';
|
|
@@ -478,6 +499,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
478
499
|
renderViaTransition(
|
|
479
500
|
currentUrl,
|
|
480
501
|
owner,
|
|
502
|
+
['revalidate' satisfies NavigationTransitionType],
|
|
481
503
|
async () => {
|
|
482
504
|
const { navState, commit } = prepareNavigation(currentUrl, {
|
|
483
505
|
payload: payloadTree,
|
|
@@ -502,20 +524,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
502
524
|
},
|
|
503
525
|
runWhenIdle: (task: () => void) => lifecycle.runWhenIdle(task),
|
|
504
526
|
settleHandoffs: () => lifecycle.settleHandoffs(),
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
} else {
|
|
510
|
-
// TIM-1465: evict all query variants sharing this pathname
|
|
511
|
-
historyStack.deleteByPathname(path);
|
|
512
|
-
}
|
|
513
|
-
prefetchCache.invalidateUrl(path);
|
|
514
|
-
// TIM-1466: suppress segment/slot reuse so the next navigation
|
|
515
|
-
// gets a full render instead of reusing stale shared layouts.
|
|
516
|
-
// Uses invalidateReuse() instead of clear() to preserve treePaths
|
|
517
|
-
// for interception scoping — a modal mounted on the current tree
|
|
518
|
-
// must still be navigable (codex on #1167).
|
|
527
|
+
suppressSegmentReuse(): void {
|
|
528
|
+
// TIM-1466: uses invalidateReuse() instead of clear() to preserve
|
|
529
|
+
// treePaths for interception scoping — a modal mounted on the current
|
|
530
|
+
// tree must still be navigable (codex on #1167).
|
|
519
531
|
segmentCache.invalidateReuse();
|
|
520
532
|
},
|
|
521
533
|
evictStaleCaches(): void {
|
|
@@ -364,27 +364,6 @@ function prefetchMapKey(key: PrefetchKey): string {
|
|
|
364
364
|
return `${key.from.length}:${key.from}:${key.scope.length}:${key.scope}:${key.url}`;
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
-
/**
|
|
368
|
-
* Extract the destination URL from a length-prefixed map key.
|
|
369
|
-
* Key format: `${from.length}:${from}:${scope.length}:${scope}:${url}`
|
|
370
|
-
*/
|
|
371
|
-
function extractUrlFromMapKey(mapKey: string): string | null {
|
|
372
|
-
// Parse from-length prefix
|
|
373
|
-
const firstColon = mapKey.indexOf(':');
|
|
374
|
-
if (firstColon === -1) return null;
|
|
375
|
-
const fromLen = parseInt(mapKey.slice(0, firstColon), 10);
|
|
376
|
-
if (Number.isNaN(fromLen)) return null;
|
|
377
|
-
// Skip past `from.length:from:`
|
|
378
|
-
const scopeStart = firstColon + 1 + fromLen + 1;
|
|
379
|
-
// Parse scope-length prefix
|
|
380
|
-
const secondColon = mapKey.indexOf(':', scopeStart);
|
|
381
|
-
if (secondColon === -1) return null;
|
|
382
|
-
const scopeLen = parseInt(mapKey.slice(scopeStart, secondColon), 10);
|
|
383
|
-
if (Number.isNaN(scopeLen)) return null;
|
|
384
|
-
// Skip past `scope.length:scope:` — remainder is the URL
|
|
385
|
-
return mapKey.slice(secondColon + 1 + scopeLen + 1);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
367
|
/**
|
|
389
368
|
* The `scope` half of a `PrefetchKey`, derived from the state tree that will
|
|
390
369
|
* actually be sent. Taking it from the header value rather than re-walking the
|
|
@@ -522,50 +501,6 @@ export class PrefetchCache {
|
|
|
522
501
|
return this.get(key) === NEGATIVE_ENTRY;
|
|
523
502
|
}
|
|
524
503
|
|
|
525
|
-
/**
|
|
526
|
-
* Invalidate entries whose destination URL matches (TIM-1454).
|
|
527
|
-
*
|
|
528
|
-
* When `url` has no search string, evicts all entries sharing that
|
|
529
|
-
* pathname — `/products` evicts `/products?page=1` too, because
|
|
530
|
-
* `revalidatePath('/products')` invalidates the route regardless of
|
|
531
|
-
* query (TIM-1465).
|
|
532
|
-
*
|
|
533
|
-
* When `url` has a search string, only the exact URL is evicted.
|
|
534
|
-
*
|
|
535
|
-
* Also bumps the eviction generation so in-flight singleflight
|
|
536
|
-
* flights for the invalidated URL(s) discard their results on
|
|
537
|
-
* completion (TIM-1464).
|
|
538
|
-
*/
|
|
539
|
-
invalidateUrl(url: string): void {
|
|
540
|
-
const hasSearch = url.includes('?');
|
|
541
|
-
const pathname = hasSearch ? null : url;
|
|
542
|
-
|
|
543
|
-
for (const [mapKey] of this.entries) {
|
|
544
|
-
// The map key is `${from.length}:${from}:${scope.length}:${scope}:${url}`.
|
|
545
|
-
// The destination URL is the suffix after the last unescaped colon pair.
|
|
546
|
-
if (hasSearch) {
|
|
547
|
-
if (mapKey.endsWith(':' + url)) {
|
|
548
|
-
this.entries.delete(mapKey);
|
|
549
|
-
}
|
|
550
|
-
} else {
|
|
551
|
-
// Extract the URL suffix — everything after the scope block.
|
|
552
|
-
// Match any entry whose URL pathname matches.
|
|
553
|
-
const entryUrl = extractUrlFromMapKey(mapKey);
|
|
554
|
-
if (entryUrl !== null) {
|
|
555
|
-
const qIndex = entryUrl.indexOf('?');
|
|
556
|
-
const entryPathname = qIndex === -1 ? entryUrl : entryUrl.slice(0, qIndex);
|
|
557
|
-
if (entryPathname === pathname) {
|
|
558
|
-
this.entries.delete(mapKey);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Fence in-flight flights so a prefetch that started before
|
|
565
|
-
// invalidation cannot store its stale result (TIM-1464).
|
|
566
|
-
this.evictionGen++;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
504
|
/**
|
|
570
505
|
* Evict all ready entries and fence in-flight singleflights (TIM-1476).
|
|
571
506
|
* Called after a server action that revalidated data — the mutation may
|
package/src/client/use-router.ts
CHANGED
|
@@ -26,11 +26,23 @@
|
|
|
26
26
|
import { getRouterOrNull } from './router-ref.ts';
|
|
27
27
|
import { validateNavigationHref } from '../shared/href-validation.ts';
|
|
28
28
|
|
|
29
|
+
/** Options for `push` and `replace`. */
|
|
30
|
+
export interface NavigateOptions {
|
|
31
|
+
/** Set to false to keep the scroll position instead of scrolling to top. */
|
|
32
|
+
scroll?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* View transition types to add to this navigation, beside the router's own
|
|
35
|
+
* `navigation-forward`. See `NavigationOptions.transitionTypes` — they must
|
|
36
|
+
* go here rather than in a `startTransition` around the call.
|
|
37
|
+
*/
|
|
38
|
+
transitionTypes?: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
export interface AppRouterInstance {
|
|
30
42
|
/** Navigate to a URL, pushing a new history entry */
|
|
31
|
-
push(href: string, options?:
|
|
43
|
+
push(href: string, options?: NavigateOptions): void;
|
|
32
44
|
/** Navigate to a URL, replacing the current history entry */
|
|
33
|
-
replace(href: string, options?:
|
|
45
|
+
replace(href: string, options?: NavigateOptions): void;
|
|
34
46
|
/** Refresh the current page (re-fetch RSC payload) */
|
|
35
47
|
refresh(): void;
|
|
36
48
|
/** Navigate back in history */
|
|
@@ -60,7 +72,7 @@ export interface AppRouterInstance {
|
|
|
60
72
|
*/
|
|
61
73
|
export function useRouter(): AppRouterInstance {
|
|
62
74
|
return {
|
|
63
|
-
push(href: string, options?:
|
|
75
|
+
push(href: string, options?: NavigateOptions) {
|
|
64
76
|
validateNavigationHref(href);
|
|
65
77
|
const router = getRouterOrNull();
|
|
66
78
|
if (!router) {
|
|
@@ -71,9 +83,12 @@ export function useRouter(): AppRouterInstance {
|
|
|
71
83
|
}
|
|
72
84
|
return;
|
|
73
85
|
}
|
|
74
|
-
void router.navigate(href, {
|
|
86
|
+
void router.navigate(href, {
|
|
87
|
+
scroll: options?.scroll,
|
|
88
|
+
transitionTypes: options?.transitionTypes,
|
|
89
|
+
});
|
|
75
90
|
},
|
|
76
|
-
replace(href: string, options?:
|
|
91
|
+
replace(href: string, options?: NavigateOptions) {
|
|
77
92
|
validateNavigationHref(href);
|
|
78
93
|
const router = getRouterOrNull();
|
|
79
94
|
if (!router) {
|
|
@@ -82,7 +97,11 @@ export function useRouter(): AppRouterInstance {
|
|
|
82
97
|
}
|
|
83
98
|
return;
|
|
84
99
|
}
|
|
85
|
-
void router.navigate(href, {
|
|
100
|
+
void router.navigate(href, {
|
|
101
|
+
scroll: options?.scroll,
|
|
102
|
+
replace: true,
|
|
103
|
+
transitionTypes: options?.transitionTypes,
|
|
104
|
+
});
|
|
86
105
|
},
|
|
87
106
|
refresh() {
|
|
88
107
|
const router = getRouterOrNull();
|
package/src/config-validation.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface ConfigError {
|
|
|
19
19
|
message: string;
|
|
20
20
|
value?: unknown;
|
|
21
21
|
suggestion?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Refuse to start rather than log. Most config errors only warn — Vite may
|
|
24
|
+
* still work partially — but a fatal one would leave a security check
|
|
25
|
+
* running on a value the developer did not mean (`allowedOrigins`).
|
|
26
|
+
*/
|
|
27
|
+
fatal?: true;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
// ─── Known config keys ──────────────────────────────────────────────────────
|
|
@@ -108,6 +114,24 @@ export function validateConfig(config: TimberUserConfig): ConfigError[] {
|
|
|
108
114
|
}
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
// allowedOrigins
|
|
118
|
+
if (config.allowedOrigins !== undefined) {
|
|
119
|
+
if (!Array.isArray(config.allowedOrigins)) {
|
|
120
|
+
errors.push({
|
|
121
|
+
field: 'allowedOrigins',
|
|
122
|
+
message: 'allowedOrigins must be an array of origin strings.',
|
|
123
|
+
value: config.allowedOrigins,
|
|
124
|
+
fatal: true,
|
|
125
|
+
suggestion: 'Example: allowedOrigins: ["https://app.example.com"]',
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
for (const entry of config.allowedOrigins) {
|
|
129
|
+
const error = allowedOriginError(entry);
|
|
130
|
+
if (error) errors.push(error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
111
135
|
// slowRequestMs
|
|
112
136
|
if (config.slowRequestMs !== undefined) {
|
|
113
137
|
if (typeof config.slowRequestMs !== 'number' || config.slowRequestMs < 0) {
|
|
@@ -230,6 +254,76 @@ export function validateConfig(config: TimberUserConfig): ConfigError[] {
|
|
|
230
254
|
return errors;
|
|
231
255
|
}
|
|
232
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Why one `allowedOrigins` entry can never match, or null when it can.
|
|
259
|
+
*
|
|
260
|
+
* The CSRF check compares entries to the request's `Origin` header by
|
|
261
|
+
* exact string, and browsers send only the canonical form — scheme, host,
|
|
262
|
+
* and non-default port, lowercased, no path. So an entry that is not its
|
|
263
|
+
* own `new URL(x).origin` (`https://example.com/`, `https://Example.com`,
|
|
264
|
+
* `example.com`) silently matches nothing. `"null"` is refused outright:
|
|
265
|
+
* it is the `Origin` of every opaque context at once.
|
|
266
|
+
*/
|
|
267
|
+
function allowedOriginError(entry: unknown): ConfigError | null {
|
|
268
|
+
const field = 'allowedOrigins';
|
|
269
|
+
const fatal = true;
|
|
270
|
+
if (typeof entry !== 'string') {
|
|
271
|
+
return {
|
|
272
|
+
field,
|
|
273
|
+
message: `allowedOrigins contains a non-string value: ${JSON.stringify(entry)}.`,
|
|
274
|
+
value: entry,
|
|
275
|
+
fatal,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (entry === 'null') {
|
|
279
|
+
return {
|
|
280
|
+
field,
|
|
281
|
+
message:
|
|
282
|
+
'allowedOrigins cannot contain "null". Browsers send Origin: null from every opaque ' +
|
|
283
|
+
"context — sandboxed iframes (an attacker's <iframe sandbox> included), POSTs " +
|
|
284
|
+
'redirected across origins, and file: pages — so allowing it allows all of them.',
|
|
285
|
+
value: entry,
|
|
286
|
+
suggestion:
|
|
287
|
+
'To accept a cross-site POST on one endpoint, handle it in a route.ts that exports ' +
|
|
288
|
+
'`const csrf = false` instead.',
|
|
289
|
+
fatal,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// `new URL('https://*.example.com').origin` round-trips, so the canonical
|
|
293
|
+
// check alone would accept a wildcard that matches no real Origin.
|
|
294
|
+
if (entry.includes('*')) {
|
|
295
|
+
return {
|
|
296
|
+
field,
|
|
297
|
+
message: `allowedOrigins entry ${JSON.stringify(entry)} uses a wildcard. Matching is exact; wildcards are not supported.`,
|
|
298
|
+
value: entry,
|
|
299
|
+
suggestion:
|
|
300
|
+
'List each origin, e.g. ["https://app.example.com", "https://admin.example.com"].',
|
|
301
|
+
fatal,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const canonical = canonicalOrigin(entry) ?? canonicalOrigin(`https://${entry}`);
|
|
305
|
+
if (canonical === entry) return null;
|
|
306
|
+
return {
|
|
307
|
+
field,
|
|
308
|
+
message: `allowedOrigins entry ${JSON.stringify(entry)} is not a canonical origin, so it can never match a request's Origin header.`,
|
|
309
|
+
value: entry,
|
|
310
|
+
suggestion: canonical
|
|
311
|
+
? `Did you mean "${canonical}"? An origin is scheme://host[:port] — lowercase, no path or trailing slash.`
|
|
312
|
+
: 'An origin is scheme://host[:port], e.g. "https://app.example.com".',
|
|
313
|
+
fatal,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** `new URL(x).origin` for an http(s) URL, or null. Opaque origins are not origins here. */
|
|
318
|
+
function canonicalOrigin(value: string): string | null {
|
|
319
|
+
try {
|
|
320
|
+
const url = new URL(value);
|
|
321
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : null;
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
233
327
|
// ─── Formatting ─────────────────────────────────────────────────────────────
|
|
234
328
|
|
|
235
329
|
const RED = '\x1b[31m';
|
|
@@ -238,27 +332,49 @@ const DIM = '\x1b[2m';
|
|
|
238
332
|
const RESET = '\x1b[0m';
|
|
239
333
|
|
|
240
334
|
/**
|
|
241
|
-
* Format config errors for terminal output.
|
|
335
|
+
* Format config errors for terminal output. Pass `color: false` for text
|
|
336
|
+
* that goes into a thrown `Error` — escapes there reach CI logs and the
|
|
337
|
+
* Vite overlay as raw bytes.
|
|
242
338
|
*/
|
|
243
|
-
export function formatConfigErrors(
|
|
339
|
+
export function formatConfigErrors(
|
|
340
|
+
errors: ConfigError[],
|
|
341
|
+
{ color = true }: { color?: boolean } = {}
|
|
342
|
+
): string {
|
|
244
343
|
if (errors.length === 0) return '';
|
|
344
|
+
const [red, bold, dim, reset] = color ? [RED, BOLD, DIM, RESET] : ['', '', '', ''];
|
|
245
345
|
|
|
246
346
|
const lines: string[] = [];
|
|
247
347
|
lines.push(
|
|
248
|
-
`${
|
|
348
|
+
`${red}${bold}[timber]${reset} ${red}${errors.length} config error${errors.length !== 1 ? 's' : ''} in timber.config.ts:${reset}`
|
|
249
349
|
);
|
|
250
350
|
|
|
251
351
|
for (const err of errors) {
|
|
252
352
|
lines.push('');
|
|
253
|
-
lines.push(` ${
|
|
353
|
+
lines.push(` ${red}✗${reset} ${bold}${err.field}${reset}: ${err.message}`);
|
|
254
354
|
if (err.suggestion) {
|
|
255
|
-
lines.push(` ${
|
|
355
|
+
lines.push(` ${dim}${err.suggestion}${reset}`);
|
|
256
356
|
}
|
|
257
357
|
}
|
|
258
358
|
|
|
259
359
|
return lines.join('\n');
|
|
260
360
|
}
|
|
261
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Surface config errors at startup: non-fatal ones are written to stderr
|
|
364
|
+
* (Vite may still work partially, and the message guides the fix); fatal
|
|
365
|
+
* ones then stop startup with a thrown error. Non-fatal errors are written
|
|
366
|
+
* first so a fatal one does not hide them.
|
|
367
|
+
*/
|
|
368
|
+
export function reportConfigErrors(
|
|
369
|
+
errors: ConfigError[],
|
|
370
|
+
write: (text: string) => void = (text) => process.stderr.write(text)
|
|
371
|
+
): void {
|
|
372
|
+
const warnings = errors.filter((e) => !e.fatal);
|
|
373
|
+
if (warnings.length > 0) write(`${formatConfigErrors(warnings)}\n\n`);
|
|
374
|
+
const fatal = errors.filter((e) => e.fatal);
|
|
375
|
+
if (fatal.length > 0) throw new Error(formatConfigErrors(fatal, { color: false }));
|
|
376
|
+
}
|
|
377
|
+
|
|
262
378
|
// ─── Virtual Module Name Mapping ────────────────────────────────────────────
|
|
263
379
|
|
|
264
380
|
const VIRTUAL_MODULE_NAMES: Record<string, string> = {
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ import { timberBuildReport } from './plugins/build-report.ts';
|
|
|
42
42
|
import { createNoopTimer } from './utils/startup-timer.ts';
|
|
43
43
|
import {
|
|
44
44
|
validateConfig,
|
|
45
|
-
|
|
45
|
+
reportConfigErrors,
|
|
46
46
|
checkPeerDependencies,
|
|
47
47
|
formatMissingPeers,
|
|
48
48
|
} from './config-validation.ts';
|
|
@@ -513,13 +513,10 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
513
513
|
ctx.timer.start('dev-server-setup');
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
-
// Validate config and check peer dependencies at startup.
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
if (configErrors.length > 0) {
|
|
521
|
-
process.stderr.write(`${formatConfigErrors(configErrors)}\n\n`);
|
|
522
|
-
}
|
|
516
|
+
// Validate config and check peer dependencies at startup. Most errors
|
|
517
|
+
// are logged and don't block startup; fatal ones (a security setting
|
|
518
|
+
// that would not mean what it says) stop it. See reportConfigErrors.
|
|
519
|
+
reportConfigErrors(validateConfig(ctx.config));
|
|
523
520
|
|
|
524
521
|
const peerResults = checkPeerDependencies(ctx.root);
|
|
525
522
|
const peerWarning = formatMissingPeers(peerResults);
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
fileHasDefaultExport,
|
|
27
27
|
fileHasDirective,
|
|
28
28
|
fileHasStarExport,
|
|
29
|
+
getCsrfExport,
|
|
29
30
|
} from './export-detect.ts';
|
|
30
31
|
|
|
31
32
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
@@ -82,6 +83,12 @@ export function lintConventions(tree: RouteTree, appDir: string): ConventionWarn
|
|
|
82
83
|
// Check 8: middleware.ts inside a parallel slot subtree (silently dead)
|
|
83
84
|
checkSlotMiddleware(tree.root, warnings);
|
|
84
85
|
|
|
86
|
+
// Check 9: `export const csrf` anywhere but route.ts, or in a form the
|
|
87
|
+
// scanner cannot read (TIM-1543)
|
|
88
|
+
checkCsrfExports(tree.root, warnings);
|
|
89
|
+
if (tree.proxy) checkCsrfExport(tree.proxy, 'proxy.ts', warnings);
|
|
90
|
+
if (tree.globalError) checkCsrfExport(tree.globalError, 'global-error', warnings);
|
|
91
|
+
|
|
85
92
|
return warnings;
|
|
86
93
|
}
|
|
87
94
|
|
|
@@ -116,6 +123,74 @@ function checkClientLayouts(node: SegmentNode, warnings: ConventionWarning[]): v
|
|
|
116
123
|
for (const slot of Object.values(node.slots)) checkClientLayouts(slot, warnings);
|
|
117
124
|
}
|
|
118
125
|
|
|
126
|
+
// ─── Check: CSRF Exemption Export ──────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/** Single-file segment conventions other than route.ts. */
|
|
129
|
+
const NON_ROUTE_FILE_KEYS = [
|
|
130
|
+
'page',
|
|
131
|
+
'layout',
|
|
132
|
+
'middleware',
|
|
133
|
+
'access',
|
|
134
|
+
'error',
|
|
135
|
+
'default',
|
|
136
|
+
'denied',
|
|
137
|
+
] as const satisfies readonly (keyof SegmentNode)[];
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fail the build on a `csrf` export the CSRF gate would not honor.
|
|
141
|
+
*
|
|
142
|
+
* Both findings are proven from source, and both are fatal in the sense
|
|
143
|
+
* the error level requires: the developer wrote an exemption that silently
|
|
144
|
+
* does nothing, so a legitimate cross-site POST they meant to accept gets
|
|
145
|
+
* a 403 in production, or they believe a page or action is exempt when
|
|
146
|
+
* the gate still protects it. The exemption exists only on route.ts —
|
|
147
|
+
* server actions always get the check (design/08 §"Per-route exemption").
|
|
148
|
+
* A `'use server'` module cannot export it at all: plugin-rsc rejects every
|
|
149
|
+
* non-async-function export there.
|
|
150
|
+
*/
|
|
151
|
+
function checkCsrfExports(node: SegmentNode, warnings: ConventionWarning[]): void {
|
|
152
|
+
if (node.route) checkCsrfExport(node.route, 'route.ts', warnings);
|
|
153
|
+
for (const key of NON_ROUTE_FILE_KEYS) {
|
|
154
|
+
const file = node[key];
|
|
155
|
+
if (file) checkCsrfExport(file, key, warnings);
|
|
156
|
+
}
|
|
157
|
+
// Status files and metadata routes (sitemap.ts, opengraph-image.tsx, …).
|
|
158
|
+
// JSON status files are skipped by the script-extension check.
|
|
159
|
+
for (const [name, file] of Object.entries(node.statusFiles ?? {})) {
|
|
160
|
+
checkCsrfExport(file, name, warnings);
|
|
161
|
+
}
|
|
162
|
+
for (const [name, file] of Object.entries(node.metadataRoutes ?? {})) {
|
|
163
|
+
checkCsrfExport(file, name, warnings);
|
|
164
|
+
}
|
|
165
|
+
for (const child of node.children) checkCsrfExports(child, warnings);
|
|
166
|
+
for (const slot of Object.values(node.slots)) checkCsrfExports(slot, warnings);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function checkCsrfExport(file: RouteFile, convention: string, warnings: ConventionWarning[]): void {
|
|
170
|
+
if (!isScriptExtension(file.extension)) return;
|
|
171
|
+
const found = getCsrfExport(file.filePath);
|
|
172
|
+
if (found === 'absent') return;
|
|
173
|
+
const isRoute = convention === 'route.ts';
|
|
174
|
+
if (isRoute && found === 'exempt') return;
|
|
175
|
+
warnings.push({
|
|
176
|
+
id: `CSRF_EXPORT_INVALID:${file.filePath}`,
|
|
177
|
+
summary: isRoute
|
|
178
|
+
? `route.ts csrf export must be exactly \`export const csrf = false\`: ${file.filePath}`
|
|
179
|
+
: `\`csrf\` can only be exported from route.ts, not ${convention}: ${file.filePath}`,
|
|
180
|
+
details:
|
|
181
|
+
` File: ${file.filePath}\n\n` +
|
|
182
|
+
(isRoute
|
|
183
|
+
? ' The CSRF gate reads this export from source before any module loads, so only\n' +
|
|
184
|
+
' the literal form counts. Other values, `let`, and re-exports are not read.\n\n' +
|
|
185
|
+
' To fix: write `export const csrf = false;` in this file, or remove the export.\n'
|
|
186
|
+
: ' The per-route CSRF exemption applies only to route.ts handlers. Pages, layouts\n' +
|
|
187
|
+
' and server actions always get the CSRF check.\n\n' +
|
|
188
|
+
' To fix: remove the export. To accept a cross-site POST, handle it in a route.ts\n' +
|
|
189
|
+
' that exports `const csrf = false`.\n'),
|
|
190
|
+
level: 'error',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
119
194
|
// ─── Check: Empty App ───────────────────────────────────────────────────────
|
|
120
195
|
|
|
121
196
|
/**
|