@timber-js/app 0.2.0-alpha.213 → 0.2.0-alpha.214

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.
Files changed (57) hide show
  1. package/dist/_chunks/{actions-CEootpB1.js → actions-CUh3cClk.js} +2 -2
  2. package/dist/_chunks/{actions-CEootpB1.js.map → actions-CUh3cClk.js.map} +1 -1
  3. package/dist/_chunks/{canonicalize-CgHoscYO.js → canonicalize-BAWkWiKK.js} +28 -2
  4. package/dist/_chunks/{canonicalize-CgHoscYO.js.map → canonicalize-BAWkWiKK.js.map} +1 -1
  5. package/dist/_chunks/{chains-BfoPFraI.js → chains-CjK1Eu6a.js} +2 -2
  6. package/dist/_chunks/{chains-BfoPFraI.js.map → chains-CjK1Eu6a.js.map} +1 -1
  7. package/dist/_chunks/{classify-BT66U83D.js → classify-GAdt6aiA.js} +2 -2
  8. package/dist/_chunks/{classify-BT66U83D.js.map → classify-GAdt6aiA.js.map} +1 -1
  9. package/dist/_chunks/{cli-check-BfQ54-UJ.js → cli-check-DOzjlycg.js} +3 -3
  10. package/dist/_chunks/{cli-check-BfQ54-UJ.js.map → cli-check-DOzjlycg.js.map} +1 -1
  11. package/dist/_chunks/{cli-schema-sync-czh2dsLs.js → cli-schema-sync-B9wDaQvW.js} +2 -2
  12. package/dist/_chunks/{cli-schema-sync-czh2dsLs.js.map → cli-schema-sync-B9wDaQvW.js.map} +1 -1
  13. package/dist/_chunks/{client-dep-entries-CQwpb8dI.js → client-dep-entries-DyDqXOF9.js} +2 -2
  14. package/dist/_chunks/{client-dep-entries-CQwpb8dI.js.map → client-dep-entries-DyDqXOF9.js.map} +1 -1
  15. package/dist/_chunks/{convention-lint-BEVW4EID.js → convention-lint-B3QEGJX7.js} +2 -2
  16. package/dist/_chunks/{convention-lint-BEVW4EID.js.map → convention-lint-B3QEGJX7.js.map} +1 -1
  17. package/dist/_chunks/{dev-server-FKxptbnI.js → dev-server-_9L4KWC-.js} +3 -3
  18. package/dist/_chunks/{dev-server-FKxptbnI.js.map → dev-server-_9L4KWC-.js.map} +1 -1
  19. package/dist/_chunks/{error-boundary-9g_Lb2na.js → error-boundary-xxxLtXt6.js} +38 -5
  20. package/dist/_chunks/{error-boundary-9g_Lb2na.js.map → error-boundary-xxxLtXt6.js.map} +1 -1
  21. package/dist/_chunks/{live-graph-C_4v-fHv.js → live-graph-Cd3YHvrH.js} +4 -4
  22. package/dist/_chunks/{live-graph-C_4v-fHv.js.map → live-graph-Cd3YHvrH.js.map} +1 -1
  23. package/dist/_chunks/{poison-scan-C92liMAr.js → poison-scan-BnJjBOkn.js} +2 -2
  24. package/dist/_chunks/{poison-scan-C92liMAr.js.map → poison-scan-BnJjBOkn.js.map} +1 -1
  25. package/dist/_chunks/{scanner-CQt12vE2.js → scanner-CKAT5gRx.js} +2 -2
  26. package/dist/_chunks/{scanner-CQt12vE2.js.map → scanner-CKAT5gRx.js.map} +1 -1
  27. package/dist/_chunks/{walkers-DAT4avhZ.js → walkers-DwCXEyRu.js} +3 -3
  28. package/dist/_chunks/{walkers-DAT4avhZ.js.map → walkers-DwCXEyRu.js.map} +1 -1
  29. package/dist/adapters/nitro-preview.d.ts.map +1 -1
  30. package/dist/adapters/nitro.js +11 -2
  31. package/dist/adapters/nitro.js.map +1 -1
  32. package/dist/analyze/crawl-entry.js +3 -3
  33. package/dist/analyze/graph-command.js +2 -2
  34. package/dist/cli.js +3 -3
  35. package/dist/client/error-boundary.js +1 -1
  36. package/dist/client/internal.js +22 -7
  37. package/dist/client/internal.js.map +1 -1
  38. package/dist/client/router-effects.d.ts +70 -12
  39. package/dist/client/router-effects.d.ts.map +1 -1
  40. package/dist/client/router.d.ts.map +1 -1
  41. package/dist/index.js +5 -5
  42. package/dist/routing/index.js +2 -2
  43. package/dist/server/canonicalize.d.ts +22 -0
  44. package/dist/server/canonicalize.d.ts.map +1 -1
  45. package/dist/server/index.js +1 -1
  46. package/dist/server/internal.js +54 -3
  47. package/dist/server/internal.js.map +1 -1
  48. package/dist/server/pipeline-helpers.d.ts +31 -0
  49. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  50. package/dist/server/pipeline.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/adapters/nitro-preview.ts +10 -1
  53. package/src/client/router-effects.ts +99 -14
  54. package/src/client/router.ts +29 -13
  55. package/src/server/canonicalize.ts +29 -0
  56. package/src/server/pipeline-helpers.ts +61 -0
  57. package/src/server/pipeline.ts +13 -1
@@ -31,6 +31,37 @@ import type { PhaseName } from './pipeline-outcome.ts';
31
31
  * See design/08-forms-and-actions.md §"CSRF Protection".
32
32
  */
33
33
  export declare function csrfGate(req: Request, canonicalPath: string, csrf: CsrfConfig, matchRoute: RouteMatcher): Response | null;
34
+ /**
35
+ * The redirect for a safe request whose URL is not canonical, or null to
36
+ * serve it.
37
+ *
38
+ * A GET for `/admin/` used to render with a 200 at that URL. The server
39
+ * rendered with the canonical `/admin` while the browser hydrated with the
40
+ * raw `/admin/`, so anything branching on `usePathname()` hydrated
41
+ * mismatched (TIM-1571). Redirecting makes the address bar — the client's
42
+ * copy of the URL — the canonical one, and leaves one URL per page for
43
+ * caches and crawlers.
44
+ *
45
+ * Only GET and HEAD are redirected. Every other method — an action's POST,
46
+ * a CORS preflight — is served at the canonical path, as before: a
47
+ * redirected POST is re-sent by the browser, which an action must not
48
+ * depend on.
49
+ *
50
+ * Runs inside proxy.ts's `next()`, not before it, so proxy.ts still runs
51
+ * on every request and its headers land on the redirect. A cross-origin
52
+ * `GET /api/items/` needs proxy.ts's CORS headers on the 308 itself:
53
+ * browsers apply the CORS check to a redirect response.
54
+ *
55
+ * A document request gets a relative 308 (never built from `Host`). A page
56
+ * navigation gets the router's soft redirect, decided exactly as for a
57
+ * `redirect()` outcome: an RSC request that does not match a route.ts
58
+ * handler (`buildRedirectResponse`). The framework's `_rsc` cache key is
59
+ * dropped from the soft target; the router appends a fresh one when it
60
+ * fetches the canonical URL.
61
+ *
62
+ * See design/07-routing.md §"Non-Canonical URLs Redirect".
63
+ */
64
+ export declare function canonicalRedirect(req: Request, canonicalPath: string, stripTrailingSlash: boolean, matchRoute: RouteMatcher): Response | null;
34
65
  /**
35
66
  * Resolver closure produced once at pipeline construction. The lazy variant
36
67
  * still calls `loader()` per-request (HMR relies on re-importing), but the
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline-helpers.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-helpers.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pipeline-helpers.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-helpers.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.213",
3
+ "version": "0.2.0-alpha.214",
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",
@@ -326,7 +326,16 @@ const server = createServer(async (req, res) => {
326
326
  // connection it would be attacker-controlled. The CSRF layer honors it
327
327
  // separately for reverse-proxy deployments.
328
328
  const scheme = req.socket?.encrypted ? 'https' : 'http';
329
- const url = new URL(req.url || '/', \`\${scheme}://\${host}:\${boundPort}\`);
329
+ // An origin-form target ('/path') is concatenated, not resolved against
330
+ // the base: resolved as a relative reference, '//evil.com/' names another
331
+ // host, and the pipeline would see '/' instead of the path it must
332
+ // redirect to '/evil.com' (TIM-1571). Any other form — 'OPTIONS *', an
333
+ // absolute-form target — is resolved as before: concatenating '*' makes
334
+ // an invalid URL, and this callback has no catch around it, so the
335
+ // rejection would take the preview process down.
336
+ const origin = \`\${scheme}://\${host}:\${boundPort}\`;
337
+ const target = req.url || '/';
338
+ const url = target.startsWith('/') ? new URL(origin + target) : new URL(target, origin);
330
339
 
331
340
  // The runtime's own request-boundary canonicalizer (_canonicalize.mjs,
332
341
  // compiled from server/canonicalize.ts): one decode, and the same 400s for
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { setHardNavigating } from './navigation-root.tsx';
13
- import type { RenderOwner } from './navigation-transition.ts';
13
+ import type { CommitOutcome, RenderOwner } from './navigation-transition.ts';
14
14
  import { RedirectError, ServerErrorResponse, NonRscResponse } from './rsc-fetch.ts';
15
15
  import { recordSkew } from './router-skew.ts';
16
16
 
@@ -147,14 +147,96 @@ export interface NavigationRecoveryDeps {
147
147
  currentOwner: () => RenderOwner | null;
148
148
  leaveSpaIfOwned: SpaExits['leaveSpaIfOwned'];
149
149
  /**
150
- * Replace the current entry with `url` — the router's own `navigate()`,
151
- * rendering with `types`: the view transition types of the render the
152
- * redirect interrupted, so a redirected Back still animates as Back and a
153
- * link's own `transitionTypes` survive the hop (TIM-1471). `onHandOff`
154
- * survives it too: a server action redirect waits for the tree that is
155
- * finally handed to React, which is the redirected one (TIM-1573).
150
+ * Follow a redirect to `url` — the router's own `navigate()`, carrying
151
+ * the interrupted navigation's {@link RedirectHop}.
156
152
  */
157
- navigate: (url: string, types: readonly string[], onHandOff?: () => void) => Promise<void>;
153
+ navigate: (url: string, hop: RedirectHop) => Promise<void>;
154
+ }
155
+
156
+ /**
157
+ * What a redirect hop inherits from the navigation the redirect interrupted.
158
+ */
159
+ export interface RedirectHop {
160
+ /**
161
+ * The view transition types of the interrupted render, so a redirected Back
162
+ * still animates as Back and a link's own `transitionTypes` survive the
163
+ * hop (TIM-1471).
164
+ */
165
+ types: readonly string[];
166
+ /**
167
+ * The interrupted navigation's history mode. A push stays a push: the
168
+ * address bar moves only when a destination commits, so the interrupted
169
+ * navigation wrote no entry, and replacing would overwrite the page being
170
+ * left — Back would skip it (TIM-1571). A refresh or a traversal replaces:
171
+ * the browser is already on the entry being redirected.
172
+ */
173
+ replace: boolean;
174
+ /**
175
+ * The interrupted navigation's scroll mode. `<Link scroll={false}>` to a
176
+ * URL that redirects — a non-canonical `/x/` among them — still keeps the
177
+ * scroll position; the hop would otherwise default to scrolling to the top.
178
+ */
179
+ scroll: boolean;
180
+ /**
181
+ * The interrupted navigation's `onCommit`, as {@link HeldCommit.settle}.
182
+ * A followed redirect is one logical navigation: the hop's commit is the
183
+ * one the caller is waiting for. `<Link>` holds `isPending` until it, and
184
+ * a `'failed'` from the interrupted render would release it on the hop's
185
+ * decode, ahead of a commit React is still holding (TIM-1571). Recovery
186
+ * delivers it itself on every path that does not follow the redirect.
187
+ */
188
+ onCommit?: (outcome: CommitOutcome) => void;
189
+ /**
190
+ * The interrupted navigation's `onHandOff`: a server action redirect
191
+ * waits for the tree that is finally handed to React, which is the
192
+ * redirected one (TIM-1573).
193
+ */
194
+ onHandOff?: () => void;
195
+ }
196
+
197
+ /**
198
+ * A navigation's `onCommit`, split around its failure.
199
+ *
200
+ * `navigateTransition` settles `onCommit` `'failed'` as soon as `perform`
201
+ * throws — before the router has seen the error, so before it knows the
202
+ * failure is a redirect it will follow. `transition` is what the render
203
+ * gets: every outcome but `'failed'` reaches the caller at once, and
204
+ * `'failed'` is left to recovery, which either hands `settle` to the
205
+ * redirect hop or delivers it. `settle` runs the caller's callback at most
206
+ * once, so a commit or supersession already delivered wins over a later
207
+ * `'failed'`.
208
+ */
209
+ export interface HeldCommit {
210
+ transition: ((outcome: CommitOutcome) => void) | undefined;
211
+ settle: ((outcome: CommitOutcome) => void) | undefined;
212
+ }
213
+
214
+ export function holdCommit(onCommit?: (outcome: CommitOutcome) => void): HeldCommit {
215
+ if (!onCommit) return { transition: undefined, settle: undefined };
216
+ let settled = false;
217
+ const settle = (outcome: CommitOutcome): void => {
218
+ if (settled) return;
219
+ settled = true;
220
+ onCommit(outcome);
221
+ };
222
+ return {
223
+ transition: (outcome) => {
224
+ if (outcome !== 'failed') settle(outcome);
225
+ },
226
+ settle,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * The URL a redirect hop navigates to. A redirect target without a fragment
232
+ * keeps the one the navigation asked for, as a browser does for a 3xx
233
+ * (RFC 9110 §10.2.2): the server never sees the fragment, so a soft redirect
234
+ * — `<Link href="/docs/#install">` answered with `/docs` — cannot carry it.
235
+ */
236
+ export function redirectHopUrl(redirectUrl: string, requestedUrl: string): string {
237
+ if (redirectUrl.includes('#')) return redirectUrl;
238
+ const hashIndex = requestedUrl.indexOf('#');
239
+ return hashIndex === -1 ? redirectUrl : redirectUrl + requestedUrl.slice(hashIndex);
158
240
  }
159
241
 
160
242
  /**
@@ -189,18 +271,21 @@ export function createNavigationRecovery({
189
271
  owner: RenderOwner,
190
272
  url: string,
191
273
  fromUrl: string,
192
- /** The view transition types of the render that failed. */
193
- types: readonly string[],
194
- /** The failed navigation's `NavigationOptions.onHandOff`, for the hop. */
195
- onHandOff?: () => void
274
+ /** What a redirect hop inherits from the navigation that failed. */
275
+ hop: RedirectHop
196
276
  ): Promise<boolean> {
197
277
  if (error instanceof RedirectError) {
198
278
  // Same ownership rule as leaving the SPA: a superseded navigation must
199
279
  // not steer the document to the destination it was abandoned for.
200
- if (currentOwner() !== owner) return true;
201
- await navigate(error.redirectUrl, types, onHandOff);
280
+ if (currentOwner() !== owner) {
281
+ hop.onCommit?.('superseded');
282
+ return true;
283
+ }
284
+ await navigate(redirectHopUrl(error.redirectUrl, url), hop);
202
285
  return true;
203
286
  }
287
+ // Anything else ends this navigation here: its held `'failed'`.
288
+ hop.onCommit?.('failed');
204
289
  // A server error, a non-RSC response and a version skew all end the same
205
290
  // way: hand `url` back to the server as a document load, so it can render
206
291
  // the outcome as HTML (design/10-error-handling.md §"Error Page Rendering
@@ -18,7 +18,12 @@ import { createNavigationCommitter } from './navigation-commit.ts';
18
18
  import { fetchRscPayload, NonRscResponse } from './rsc-fetch.ts';
19
19
  import { readPayloadTree, readPublishedParams } from '../shared/payload-root.ts';
20
20
  import { isClientStale } from './stale-client.ts';
21
- import { createScrollEffects, createSpaExits, createNavigationRecovery } from './router-effects.ts';
21
+ import {
22
+ createScrollEffects,
23
+ createSpaExits,
24
+ createNavigationRecovery,
25
+ holdCommit,
26
+ } from './router-effects.ts';
22
27
  import { createNavigationLifecycle } from './router-lifecycle.ts';
23
28
  import { createNavigationPipeline, prefetchKeyFor } from './router-pipeline.ts';
24
29
  import { recordSkew } from './router-skew.ts';
@@ -102,8 +107,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
102
107
  currentOwner,
103
108
  leaveSpaIfOwned,
104
109
  // Hoisted — `navigate` is a function declaration below.
105
- navigate: (url, types, onHandOff) =>
106
- navigate(url, { replace: true, _renderTypes: types, onHandOff }),
110
+ navigate: (url, { types, replace, scroll, onCommit, onHandOff }) =>
111
+ navigate(url, { replace, scroll, _renderTypes: types, onCommit, onHandOff }),
107
112
  });
108
113
 
109
114
  async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
@@ -152,6 +157,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
152
157
  await leaveSpaSuperseding(url, departingUrl);
153
158
  }
154
159
 
160
+ // A redirect this navigation follows inherits its onCommit (RedirectHop).
161
+ const heldCommit = holdCommit(options.onCommit);
162
+
155
163
  await runNavigation(url, async (owner) => {
156
164
  try {
157
165
  await renderViaTransition(
@@ -165,7 +173,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
165
173
  signal: owner.fetchAbort.signal,
166
174
  departingUrl,
167
175
  }),
168
- options.onCommit,
176
+ heldCommit.transition,
169
177
  options.onHandOff
170
178
  );
171
179
 
@@ -183,14 +191,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
183
191
  // instead would rebuild the page they were *leaving* and discard
184
192
  // the click (TIM-1275).
185
193
  if (
186
- await recoverFromNavigationError(
187
- error,
188
- owner,
189
- url,
190
- departingUrl,
194
+ await recoverFromNavigationError(error, owner, url, departingUrl, {
191
195
  types,
192
- options.onHandOff
193
- )
196
+ replace,
197
+ scroll,
198
+ onCommit: heldCommit.settle,
199
+ onHandOff: options.onHandOff,
200
+ })
194
201
  ) {
195
202
  return;
196
203
  }
@@ -222,6 +229,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
222
229
  onCommit?: (outcome: CommitOutcome) => void;
223
230
  } = {}
224
231
  ): Promise<void> {
232
+ const heldCommit = holdCommit(opts.onCommit);
225
233
  await runNavigation(
226
234
  url,
227
235
  async (owner) => {
@@ -247,7 +255,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
247
255
  });
248
256
  return { ...result, params, navState, commit };
249
257
  },
250
- opts.onCommit
258
+ heldCommit.transition
251
259
  );
252
260
  } catch (error) {
253
261
  // Neither path is a navigate(), and neither has a caller that
@@ -261,7 +269,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
261
269
  // browser has already traversed, and `refresh()` is by definition
262
270
  // where it already is. So `hardNavigate()` takes its same-document
263
271
  // branch and reloads rather than pushing an entry.
264
- if (await recoverFromNavigationError(error, owner, url, url, [type])) return;
272
+ if (
273
+ await recoverFromNavigationError(error, owner, url, url, {
274
+ types: [type],
275
+ replace: true,
276
+ scroll: true,
277
+ onCommit: heldCommit.settle,
278
+ })
279
+ )
280
+ return;
265
281
  throw error;
266
282
  }
267
283
 
@@ -91,3 +91,32 @@ export function canonicalize(rawPathname: string, stripTrailingSlash = true): Ca
91
91
 
92
92
  return { ok: true, pathname };
93
93
  }
94
+
95
+ /**
96
+ * The path a safe request for `rawPathname` should be redirected to — or
97
+ * `rawPathname` itself when it is already canonical.
98
+ *
99
+ * Applies the structural steps of `canonicalize()` — collapse `//` (step 3)
100
+ * and strip the trailing slash (step 5) — to the **encoded** path, without
101
+ * decoding. That is what the browser will send back, so the redirect is
102
+ * idempotent: `/%61dmin` differs from its canonical form `/admin` only by
103
+ * percent-encoding, and redirecting it to `/admin` would compare against the
104
+ * decoded form forever. Decoding cannot introduce a `/` (`%2f` is rejected),
105
+ * so the structure is the same before and after decoding.
106
+ *
107
+ * Dot segments are not handled here: the request URL is parsed by the WHATWG
108
+ * URL parser, which has already resolved `.`, `..` and their encoded forms.
109
+ *
110
+ * Only meaningful for a path `canonicalize()` accepted. The collapse also
111
+ * means the result can never start with `//`, so it is never a
112
+ * protocol-relative redirect target.
113
+ *
114
+ * See design/07-routing.md §"Non-Canonical URLs Redirect"
115
+ */
116
+ export function canonicalRequestPath(rawPathname: string, stripTrailingSlash = true): string {
117
+ let pathname = rawPathname.replace(/\/\/+/g, '/');
118
+ if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {
119
+ pathname = pathname.slice(0, -1);
120
+ }
121
+ return pathname;
122
+ }
@@ -28,6 +28,9 @@ import { isApiRouteChain, isCsrfExemptRoute, type ManifestSegmentNode } from './
28
28
  import type { ProxyConfig, RouteMatcher } from './pipeline.ts';
29
29
  import { csrfRejectionResponse, validateCsrf, type CsrfConfig } from './csrf.ts';
30
30
  import type { PhaseName } from './pipeline-outcome.ts';
31
+ import { canonicalRequestPath } from './canonicalize.ts';
32
+ import { isRscRequest } from '../shared/rsc-media-type.ts';
33
+ import { appVisibleSearch } from '../shared/rsc-cache-key.ts';
31
34
 
32
35
  // ─── Prototype-Pollution-Safe Sanitizer ────────────────────────────────────
33
36
 
@@ -66,6 +69,64 @@ export function csrfGate(
66
69
  return exempt ? null : csrfRejectionResponse(req, result);
67
70
  }
68
71
 
72
+ // ─── Canonical Redirect ────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * The redirect for a safe request whose URL is not canonical, or null to
76
+ * serve it.
77
+ *
78
+ * A GET for `/admin/` used to render with a 200 at that URL. The server
79
+ * rendered with the canonical `/admin` while the browser hydrated with the
80
+ * raw `/admin/`, so anything branching on `usePathname()` hydrated
81
+ * mismatched (TIM-1571). Redirecting makes the address bar — the client's
82
+ * copy of the URL — the canonical one, and leaves one URL per page for
83
+ * caches and crawlers.
84
+ *
85
+ * Only GET and HEAD are redirected. Every other method — an action's POST,
86
+ * a CORS preflight — is served at the canonical path, as before: a
87
+ * redirected POST is re-sent by the browser, which an action must not
88
+ * depend on.
89
+ *
90
+ * Runs inside proxy.ts's `next()`, not before it, so proxy.ts still runs
91
+ * on every request and its headers land on the redirect. A cross-origin
92
+ * `GET /api/items/` needs proxy.ts's CORS headers on the 308 itself:
93
+ * browsers apply the CORS check to a redirect response.
94
+ *
95
+ * A document request gets a relative 308 (never built from `Host`). A page
96
+ * navigation gets the router's soft redirect, decided exactly as for a
97
+ * `redirect()` outcome: an RSC request that does not match a route.ts
98
+ * handler (`buildRedirectResponse`). The framework's `_rsc` cache key is
99
+ * dropped from the soft target; the router appends a fresh one when it
100
+ * fetches the canonical URL.
101
+ *
102
+ * See design/07-routing.md §"Non-Canonical URLs Redirect".
103
+ */
104
+ export function canonicalRedirect(
105
+ req: Request,
106
+ canonicalPath: string,
107
+ stripTrailingSlash: boolean,
108
+ matchRoute: RouteMatcher
109
+ ): Response | null {
110
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null;
111
+ const url = new URL(req.url);
112
+ const target = canonicalRequestPath(url.pathname, stripTrailingSlash);
113
+ if (target === url.pathname) return null;
114
+ const navigation = isRscRequest(req) && !isApiRoutePath(canonicalPath, matchRoute);
115
+ const search = navigation ? appVisibleSearch(url).search : url.search;
116
+ return buildRedirectResponse(new RedirectSignal(target + search, 308), new Headers(), navigation);
117
+ }
118
+
119
+ /** Whether `path` matches a route.ts handler. A matcher that throws: no. */
120
+ function isApiRoutePath(path: string, matchRoute: RouteMatcher): boolean {
121
+ try {
122
+ const match = matchRoute(path);
123
+ return match !== null && isApiRouteChain(match.segments);
124
+ } catch (err) {
125
+ swallow(err, 'canonical redirect: route match for route.ts lookup threw');
126
+ return false;
127
+ }
128
+ }
129
+
69
130
  // ─── Proxy Resolver ────────────────────────────────────────────────────────
70
131
 
71
132
  /**
@@ -32,7 +32,7 @@ import {
32
32
  import { logRequestReceived, logRequestCompleted, logSlowRequest } from './logger.ts';
33
33
  import { DenySignal } from './primitives.ts';
34
34
  import type { ManifestSegmentNode } from './route-matcher.ts';
35
- import { csrfGate, makeProxyResolver } from './pipeline-helpers.ts';
35
+ import { canonicalRedirect, csrfGate, makeProxyResolver } from './pipeline-helpers.ts';
36
36
  import type { CsrfConfig } from './csrf.ts';
37
37
  import { handleRequest, runProxyPhase } from './pipeline-phases.ts';
38
38
  import { outcomeToResponse } from './pipeline-outcome.ts';
@@ -402,6 +402,18 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
402
402
  // TIM-1213: action dispatch is now INSIDE the pipeline so
403
403
  // proxy.ts runs on every request, including action POSTs.
404
404
  const innerHandler = async (): Promise<Response> => {
405
+ // A safe request for a non-canonical URL (`/x/`, `/a//b`) is
406
+ // redirected to the canonical one instead of served, so the
407
+ // client hydrates with the pathname the server renders with
408
+ // (TIM-1571). Inside proxy.ts's next(), so proxy.ts still runs
409
+ // on every request and its headers (CORS) reach the redirect.
410
+ const redirect = canonicalRedirect(
411
+ req,
412
+ canonicalPath,
413
+ stripTrailingSlash,
414
+ config.matchRoute
415
+ );
416
+ if (redirect) return redirect;
405
417
  if (config.dispatchAction) {
406
418
  const actionResult = await config.dispatchAction(
407
419
  canonicalReq,