@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.
- package/dist/_chunks/{actions-CEootpB1.js → actions-CUh3cClk.js} +2 -2
- package/dist/_chunks/{actions-CEootpB1.js.map → actions-CUh3cClk.js.map} +1 -1
- package/dist/_chunks/{canonicalize-CgHoscYO.js → canonicalize-BAWkWiKK.js} +28 -2
- package/dist/_chunks/{canonicalize-CgHoscYO.js.map → canonicalize-BAWkWiKK.js.map} +1 -1
- package/dist/_chunks/{chains-BfoPFraI.js → chains-CjK1Eu6a.js} +2 -2
- package/dist/_chunks/{chains-BfoPFraI.js.map → chains-CjK1Eu6a.js.map} +1 -1
- package/dist/_chunks/{classify-BT66U83D.js → classify-GAdt6aiA.js} +2 -2
- package/dist/_chunks/{classify-BT66U83D.js.map → classify-GAdt6aiA.js.map} +1 -1
- package/dist/_chunks/{cli-check-BfQ54-UJ.js → cli-check-DOzjlycg.js} +3 -3
- package/dist/_chunks/{cli-check-BfQ54-UJ.js.map → cli-check-DOzjlycg.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-czh2dsLs.js → cli-schema-sync-B9wDaQvW.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-czh2dsLs.js.map → cli-schema-sync-B9wDaQvW.js.map} +1 -1
- package/dist/_chunks/{client-dep-entries-CQwpb8dI.js → client-dep-entries-DyDqXOF9.js} +2 -2
- package/dist/_chunks/{client-dep-entries-CQwpb8dI.js.map → client-dep-entries-DyDqXOF9.js.map} +1 -1
- package/dist/_chunks/{convention-lint-BEVW4EID.js → convention-lint-B3QEGJX7.js} +2 -2
- package/dist/_chunks/{convention-lint-BEVW4EID.js.map → convention-lint-B3QEGJX7.js.map} +1 -1
- package/dist/_chunks/{dev-server-FKxptbnI.js → dev-server-_9L4KWC-.js} +3 -3
- package/dist/_chunks/{dev-server-FKxptbnI.js.map → dev-server-_9L4KWC-.js.map} +1 -1
- package/dist/_chunks/{error-boundary-9g_Lb2na.js → error-boundary-xxxLtXt6.js} +38 -5
- package/dist/_chunks/{error-boundary-9g_Lb2na.js.map → error-boundary-xxxLtXt6.js.map} +1 -1
- package/dist/_chunks/{live-graph-C_4v-fHv.js → live-graph-Cd3YHvrH.js} +4 -4
- package/dist/_chunks/{live-graph-C_4v-fHv.js.map → live-graph-Cd3YHvrH.js.map} +1 -1
- package/dist/_chunks/{poison-scan-C92liMAr.js → poison-scan-BnJjBOkn.js} +2 -2
- package/dist/_chunks/{poison-scan-C92liMAr.js.map → poison-scan-BnJjBOkn.js.map} +1 -1
- package/dist/_chunks/{scanner-CQt12vE2.js → scanner-CKAT5gRx.js} +2 -2
- package/dist/_chunks/{scanner-CQt12vE2.js.map → scanner-CKAT5gRx.js.map} +1 -1
- package/dist/_chunks/{walkers-DAT4avhZ.js → walkers-DwCXEyRu.js} +3 -3
- package/dist/_chunks/{walkers-DAT4avhZ.js.map → walkers-DwCXEyRu.js.map} +1 -1
- package/dist/adapters/nitro-preview.d.ts.map +1 -1
- package/dist/adapters/nitro.js +11 -2
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/analyze/crawl-entry.js +3 -3
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cli.js +3 -3
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/internal.js +22 -7
- package/dist/client/internal.js.map +1 -1
- package/dist/client/router-effects.d.ts +70 -12
- package/dist/client/router-effects.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/routing/index.js +2 -2
- package/dist/server/canonicalize.d.ts +22 -0
- package/dist/server/canonicalize.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +54 -3
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +31 -0
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/nitro-preview.ts +10 -1
- package/src/client/router-effects.ts +99 -14
- package/src/client/router.ts +29 -13
- package/src/server/canonicalize.ts +29 -0
- package/src/server/pipeline-helpers.ts +61 -0
- 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":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAS9C,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAIjD,OAAO,EAA+B,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAElF,OAAO,EAAsC,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAClG,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC/D,OAAO,EAAuC,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
151
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
193
|
-
|
|
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)
|
|
201
|
-
|
|
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
|
package/src/client/router.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
/**
|
package/src/server/pipeline.ts
CHANGED
|
@@ -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,
|