bosia 0.9.4 → 0.9.5
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/package.json +1 -1
- package/src/core/client/App.svelte +10 -2
- package/src/core/client/prefetch.ts +49 -1
- package/src/core/errors.ts +24 -0
- package/src/core/hooks.ts +20 -0
- package/src/core/renderer.ts +12 -12
- package/src/core/server.ts +150 -37
package/package.json
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
import { router, scrollToHash } from "./router.svelte.ts";
|
|
4
4
|
import { findMatch } from "../matcher.ts";
|
|
5
5
|
import { clientRoutes } from "bosia:routes";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
consumePrefetch,
|
|
8
|
+
prefetchCache,
|
|
9
|
+
dataUrl,
|
|
10
|
+
buildParentSnapshots,
|
|
11
|
+
readDataResponse,
|
|
12
|
+
} from "./prefetch.ts";
|
|
7
13
|
import { appState, clearDirty } from "./appState.svelte.ts";
|
|
8
14
|
import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
|
|
9
15
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
@@ -226,7 +232,9 @@
|
|
|
226
232
|
? Promise.resolve(cached)
|
|
227
233
|
: match.route.hasServerData
|
|
228
234
|
? fetch(dataUrl(path, maskBits), dataInit)
|
|
229
|
-
.then(
|
|
235
|
+
.then(readDataResponse)
|
|
236
|
+
// Only a failed request reaches here now — offline, DNS, aborted.
|
|
237
|
+
// A response that arrived is read for what it says, not discarded.
|
|
230
238
|
.catch(() => null)
|
|
231
239
|
: Promise.resolve(null);
|
|
232
240
|
|
|
@@ -86,6 +86,52 @@ export function dataUrl(path: string, invalidatedBits?: string): string {
|
|
|
86
86
|
return `${base}/__bosia/data${p || "/index"}.json${qs}`;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** True when the body is JSON we can parse — not a redirect target's HTML. */
|
|
90
|
+
function isJsonResponse(res: Response): boolean {
|
|
91
|
+
return (res.headers.get("content-type") ?? "").includes("application/json");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read a `/__bosia/data/…` response into the payload the router consumes.
|
|
96
|
+
*
|
|
97
|
+
* Anything that is not JSON used to collapse to `null`, and `null` means "the
|
|
98
|
+
* loader crashed" one branch later — so a hook redirecting an unauthenticated
|
|
99
|
+
* visitor to /login rendered a 500 that no server ever sent. The response says
|
|
100
|
+
* exactly what happened; this reads it instead of discarding it.
|
|
101
|
+
*/
|
|
102
|
+
export async function readDataResponse(res: Response): Promise<any> {
|
|
103
|
+
// `fetch` follows redirects, so a hook's 303 arrives as the login page's HTML
|
|
104
|
+
// at status 200. `redirected` is the only surviving trace of the redirect.
|
|
105
|
+
if (res.redirected) {
|
|
106
|
+
const target = new URL(res.url, window.location.origin);
|
|
107
|
+
return {
|
|
108
|
+
redirect:
|
|
109
|
+
target.origin === window.location.origin
|
|
110
|
+
? target.pathname + target.search + target.hash
|
|
111
|
+
: target.href,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (isJsonResponse(res)) {
|
|
115
|
+
try {
|
|
116
|
+
return await res.json();
|
|
117
|
+
} catch {
|
|
118
|
+
// Claimed JSON, wasn't — a truncated or proxy-mangled body.
|
|
119
|
+
return { error: { status: errorStatus(res), message: errorMessage(res) } };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// A non-JSON body the router can't use: a hook answering with text/plain 404,
|
|
123
|
+
// an HTML error page from a proxy. Report the status the server actually sent.
|
|
124
|
+
return { error: { status: errorStatus(res), message: errorMessage(res) } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function errorStatus(res: Response): number {
|
|
128
|
+
return res.status >= 400 ? res.status : 500;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function errorMessage(res: Response): string {
|
|
132
|
+
return res.statusText || "Internal Server Error";
|
|
133
|
+
}
|
|
134
|
+
|
|
89
135
|
export const prefetchCache = new Map<string, { data: any; ts: number }>();
|
|
90
136
|
const MAX_PREFETCH_ENTRIES = 50;
|
|
91
137
|
|
|
@@ -132,7 +178,9 @@ export async function prefetchPath(path: string): Promise<void> {
|
|
|
132
178
|
}
|
|
133
179
|
: {};
|
|
134
180
|
const res = await fetch(dataUrl(path, maskBits), init);
|
|
135
|
-
|
|
181
|
+
// `ok` alone would cache a guard's login page (200 after the redirect was
|
|
182
|
+
// followed) as if it were this route's data.
|
|
183
|
+
if (res.ok && !res.redirected && isJsonResponse(res)) {
|
|
136
184
|
if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
|
|
137
185
|
const oldest = prefetchCache.keys().next().value;
|
|
138
186
|
if (oldest !== undefined) prefetchCache.delete(oldest);
|
package/src/core/errors.ts
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
import { withBase } from "./basePath.ts";
|
|
5
5
|
import { currentBase } from "./appBase.ts";
|
|
6
6
|
|
|
7
|
+
// Identity across bundle boundaries. `dist/hooks.server.js` keeps "bosia"
|
|
8
|
+
// external (build.ts BOSIA_RUNTIME_EXTERNALS), so a hook's `redirect()` builds
|
|
9
|
+
// its Redirect from the app's node_modules while the server bundle carries its
|
|
10
|
+
// own copy of this file. Two class objects, one `instanceof` — always false, so
|
|
11
|
+
// a hook throwing redirect() or error() fell through to a 500 no matter how many
|
|
12
|
+
// catch branches were added. `Symbol.for` lives in a process-wide registry, so
|
|
13
|
+
// the brand is the same value in both copies. Use isRedirect()/isHttpError()
|
|
14
|
+
// rather than `instanceof` for anything that can cross that boundary.
|
|
15
|
+
export const REDIRECT_BRAND = Symbol.for("bosia.Redirect");
|
|
16
|
+
export const HTTP_ERROR_BRAND = Symbol.for("bosia.HttpError");
|
|
17
|
+
|
|
7
18
|
export class HttpError extends Error {
|
|
8
19
|
constructor(
|
|
9
20
|
public status: number,
|
|
@@ -14,6 +25,14 @@ export class HttpError extends Error {
|
|
|
14
25
|
}
|
|
15
26
|
}
|
|
16
27
|
|
|
28
|
+
export function isHttpError(err: unknown): err is HttpError {
|
|
29
|
+
return typeof err === "object" && err !== null && (err as any)[HTTP_ERROR_BRAND] === true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isRedirect(err: unknown): err is Redirect {
|
|
33
|
+
return typeof err === "object" && err !== null && (err as any)[REDIRECT_BRAND] === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
export interface RedirectOptions {
|
|
18
37
|
/** Set to `true` to allow redirects to external origins (e.g. OAuth providers). */
|
|
19
38
|
allowExternal?: boolean;
|
|
@@ -35,6 +54,11 @@ export class Redirect {
|
|
|
35
54
|
}
|
|
36
55
|
}
|
|
37
56
|
|
|
57
|
+
// Stamped on the prototypes rather than declared as class fields: a plain
|
|
58
|
+
// assignment needs no `unique symbol` gymnastics and covers subclasses too.
|
|
59
|
+
(HttpError.prototype as any)[HTTP_ERROR_BRAND] = true;
|
|
60
|
+
(Redirect.prototype as any)[REDIRECT_BRAND] = true;
|
|
61
|
+
|
|
38
62
|
const DANGEROUS_SCHEMES = /^(javascript|data|vbscript):/i;
|
|
39
63
|
|
|
40
64
|
function validateRedirectLocation(location: string, options?: RedirectOptions): void {
|
package/src/core/hooks.ts
CHANGED
|
@@ -46,6 +46,16 @@ export type RequestEvent = {
|
|
|
46
46
|
locals: Record<string, any> & { nonce?: string };
|
|
47
47
|
params: Record<string, string>;
|
|
48
48
|
cookies: Cookies;
|
|
49
|
+
/**
|
|
50
|
+
* True when the client router is fetching this page's loader data for a
|
|
51
|
+
* client-side navigation instead of the browser loading the page itself.
|
|
52
|
+
*
|
|
53
|
+
* `url` is the page URL either way — a guard never has to know which kind of
|
|
54
|
+
* request it is looking at. This is here for the cases that genuinely differ
|
|
55
|
+
* (skipping work that only matters for a full document render), not for
|
|
56
|
+
* authorization: a check that runs on one kind and not the other is a hole.
|
|
57
|
+
*/
|
|
58
|
+
isDataRequest: boolean;
|
|
49
59
|
};
|
|
50
60
|
|
|
51
61
|
export type LoadEvent = {
|
|
@@ -117,6 +127,16 @@ export type LoaderDeps = {
|
|
|
117
127
|
|
|
118
128
|
export type ResolveFunction = (event: RequestEvent) => MaybePromise<Response>;
|
|
119
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Middleware wrapping every request. Mutate `event.locals`, short-circuit with
|
|
132
|
+
* a `Response` / `throw redirect()` / `throw error()`, or call `resolve(event)`
|
|
133
|
+
* to continue.
|
|
134
|
+
*
|
|
135
|
+
* Pass on the `event.request` you were given. The framework keys per-request
|
|
136
|
+
* state off that exact `Request` instance, so handing `resolve()` an event
|
|
137
|
+
* carrying a freshly constructed `Request` detaches it from that state and a
|
|
138
|
+
* client-navigation data fetch comes back as page HTML.
|
|
139
|
+
*/
|
|
120
140
|
export type Handle = (input: {
|
|
121
141
|
event: RequestEvent;
|
|
122
142
|
resolve: ResolveFunction;
|
package/src/core/renderer.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
serveCached,
|
|
20
20
|
} from "./cache.ts";
|
|
21
21
|
import type { CookieJar } from "./cookies.ts";
|
|
22
|
-
import { HttpError, Redirect } from "./errors.ts";
|
|
22
|
+
import { HttpError, Redirect, isHttpError, isRedirect } from "./errors.ts";
|
|
23
23
|
import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
|
|
24
24
|
import App from "./client/App.svelte";
|
|
25
25
|
import { router } from "./client/router.svelte.ts";
|
|
@@ -449,8 +449,8 @@ export async function loadRouteData(
|
|
|
449
449
|
layoutDeps[ls.depth] = emptyDeps();
|
|
450
450
|
}
|
|
451
451
|
} catch (err) {
|
|
452
|
-
if (err
|
|
453
|
-
if (err
|
|
452
|
+
if (isRedirect(err)) throw err;
|
|
453
|
+
if (isHttpError(err)) {
|
|
454
454
|
stampErrorContext(
|
|
455
455
|
err,
|
|
456
456
|
ls.depth,
|
|
@@ -513,8 +513,8 @@ export async function loadRouteData(
|
|
|
513
513
|
pageDeps = emptyDeps();
|
|
514
514
|
}
|
|
515
515
|
} catch (err) {
|
|
516
|
-
if (err
|
|
517
|
-
if (err
|
|
516
|
+
if (isRedirect(err)) throw err;
|
|
517
|
+
if (isHttpError(err)) {
|
|
518
518
|
stampErrorContext(
|
|
519
519
|
err,
|
|
520
520
|
route.layoutModules.length,
|
|
@@ -573,7 +573,7 @@ export async function loadMetadata(
|
|
|
573
573
|
} catch (err) {
|
|
574
574
|
// Control flow thrown from metadata() is intent, not failure — swallowing it
|
|
575
575
|
// here made every caller's Redirect/HttpError branch dead code.
|
|
576
|
-
if (err
|
|
576
|
+
if (isRedirect(err) || isHttpError(err)) throw err;
|
|
577
577
|
if (isDev) console.error("Metadata load error:", err);
|
|
578
578
|
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
579
579
|
if (isDev) reportDevErrorFromCatch(err);
|
|
@@ -651,10 +651,10 @@ export async function renderSSRStream(
|
|
|
651
651
|
try {
|
|
652
652
|
metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
653
653
|
} catch (err) {
|
|
654
|
-
if (err
|
|
654
|
+
if (isRedirect(err)) {
|
|
655
655
|
return Response.redirect(err.location, err.status);
|
|
656
656
|
}
|
|
657
|
-
if (err
|
|
657
|
+
if (isHttpError(err)) {
|
|
658
658
|
return renderErrorPage(
|
|
659
659
|
err.status,
|
|
660
660
|
err.message,
|
|
@@ -690,8 +690,8 @@ export async function renderSSRStream(
|
|
|
690
690
|
]);
|
|
691
691
|
pageMod = pm;
|
|
692
692
|
} catch (err) {
|
|
693
|
-
if (err
|
|
694
|
-
if (err
|
|
693
|
+
if (isRedirect(err)) return Response.redirect(err.location, err.status);
|
|
694
|
+
if (isHttpError(err)) {
|
|
695
695
|
const e = err as HttpError & {
|
|
696
696
|
errorDepth?: number;
|
|
697
697
|
errorOrigin?: ErrorOrigin;
|
|
@@ -950,8 +950,8 @@ export async function renderPageWithFormData(
|
|
|
950
950
|
try {
|
|
951
951
|
metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
952
952
|
} catch (err) {
|
|
953
|
-
if (err
|
|
954
|
-
if (err
|
|
953
|
+
if (isRedirect(err)) return Response.redirect(err.location, err.status);
|
|
954
|
+
if (isHttpError(err)) {
|
|
955
955
|
return renderErrorPage(
|
|
956
956
|
err.status,
|
|
957
957
|
err.message,
|
package/src/core/server.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { RouteManifest } from "./types.ts";
|
|
|
13
13
|
compileRoutes(apiRoutes);
|
|
14
14
|
compileRoutes(serverRoutes);
|
|
15
15
|
import { NO_FRAME_GUARD_HEADER, type Handle, type RequestEvent } from "./hooks.ts";
|
|
16
|
-
import { HttpError, Redirect, ActionFailure } from "./errors.ts";
|
|
16
|
+
import { HttpError, Redirect, ActionFailure, isHttpError, isRedirect } from "./errors.ts";
|
|
17
17
|
import { CookieJar } from "./cookies.ts";
|
|
18
18
|
import { safePath } from "./safePath.ts";
|
|
19
19
|
import { checkCsrf } from "./csrf.ts";
|
|
@@ -184,6 +184,54 @@ function isValidRoutePath(path: string, origin: string): boolean {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
type DataRequest = { routeUrl: URL; invalidatedBits: string | null };
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Decode `/__bosia/data/<route>.json` into the page URL it stands for.
|
|
191
|
+
* `null` = not a data request, `"invalid"` = 400.
|
|
192
|
+
*
|
|
193
|
+
* Called before the hooks run, not inside `resolve()`, so `event.url` is the
|
|
194
|
+
* page the visitor asked for no matter how the request arrived. A guard reading
|
|
195
|
+
* `event.url.pathname` sees `/admin` for a link click and for an address-bar
|
|
196
|
+
* load alike; when it only saw the transport path on one of them, the loaders
|
|
197
|
+
* ran unguarded for every client navigation.
|
|
198
|
+
*/
|
|
199
|
+
function parseDataRequest(url: URL): DataRequest | "invalid" | null {
|
|
200
|
+
if (!url.pathname.startsWith("/__bosia/data/")) return null;
|
|
201
|
+
|
|
202
|
+
const routePathStr =
|
|
203
|
+
url.pathname
|
|
204
|
+
.slice("/__bosia/data".length)
|
|
205
|
+
.replace(/\.json$/, "")
|
|
206
|
+
.replace(/^\/index$/, "/") || "/";
|
|
207
|
+
|
|
208
|
+
if (!isValidRoutePath(routePathStr, url.origin)) return "invalid";
|
|
209
|
+
|
|
210
|
+
const routeUrl = new URL(routePathStr, url.origin);
|
|
211
|
+
let invalidatedBits: string | null = null;
|
|
212
|
+
for (const [key, val] of url.searchParams.entries()) {
|
|
213
|
+
if (key === "_invalidated") {
|
|
214
|
+
invalidatedBits = val;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
routeUrl.searchParams.append(key, val);
|
|
218
|
+
}
|
|
219
|
+
return { routeUrl, invalidatedBits };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Per-request parse, parked here because `resolve()` can no longer recover it:
|
|
224
|
+
* `event.url` is the page URL by then, and hooks call `resolve(event)`
|
|
225
|
+
* themselves so there is no parameter to thread it through. `event.locals` is
|
|
226
|
+
* user scratch space and off limits for framework state.
|
|
227
|
+
*
|
|
228
|
+
* Keyed on the incoming `Request`, which is the one object that stays identical
|
|
229
|
+
* across the whole chain. A hook that swaps in a fabricated `Request` detaches
|
|
230
|
+
* its event from this record — documented on `Handle`, pinned by
|
|
231
|
+
* `test/hooks-redirect.test.ts`.
|
|
232
|
+
*/
|
|
233
|
+
const dataRequests = new WeakMap<Request, DataRequest>();
|
|
234
|
+
|
|
187
235
|
/**
|
|
188
236
|
* Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
|
|
189
237
|
* depth i, '1' = run, '0' = skip. Missing/extra chars default to run.
|
|
@@ -230,28 +278,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
230
278
|
return Response.json({ status: "ok", timestamp, timezone });
|
|
231
279
|
}
|
|
232
280
|
|
|
233
|
-
// Data endpoint — returns server loader data as JSON for client-side navigation
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
.replace(/^\/index$/, "/") || "/";
|
|
240
|
-
|
|
241
|
-
if (!isValidRoutePath(routePathStr, url.origin)) {
|
|
242
|
-
return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
|
|
243
|
-
}
|
|
244
|
-
const routeUrl = new URL(routePathStr, url.origin);
|
|
245
|
-
let invalidatedBits: string | null = null;
|
|
246
|
-
for (const [key, val] of url.searchParams.entries()) {
|
|
247
|
-
if (key === "_invalidated") {
|
|
248
|
-
invalidatedBits = val;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
routeUrl.searchParams.append(key, val);
|
|
252
|
-
}
|
|
253
|
-
// Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
|
|
254
|
-
event.url = routeUrl;
|
|
281
|
+
// Data endpoint — returns server loader data as JSON for client-side navigation.
|
|
282
|
+
// The URL no longer says so (it is the page URL, for the hooks' benefit), so
|
|
283
|
+
// the parse handleRequest parked before the hooks ran is what identifies it.
|
|
284
|
+
const dataReq = dataRequests.get(request);
|
|
285
|
+
if (dataReq) {
|
|
286
|
+
const { routeUrl, invalidatedBits } = dataReq;
|
|
255
287
|
try {
|
|
256
288
|
const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
|
|
257
289
|
// Build mask from `?_invalidated=<bits>` where char 0 = page,
|
|
@@ -360,14 +392,14 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
360
392
|
extra,
|
|
361
393
|
);
|
|
362
394
|
} catch (err) {
|
|
363
|
-
if (err
|
|
395
|
+
if (isRedirect(err)) {
|
|
364
396
|
return compress(
|
|
365
397
|
JSON.stringify({ redirect: err.location, status: err.status }),
|
|
366
398
|
"application/json",
|
|
367
399
|
request,
|
|
368
400
|
);
|
|
369
401
|
}
|
|
370
|
-
if (err
|
|
402
|
+
if (isHttpError(err)) {
|
|
371
403
|
const e = err as HttpError & {
|
|
372
404
|
errorDepth?: number;
|
|
373
405
|
errorOrigin?: "page" | "layout";
|
|
@@ -471,10 +503,13 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
471
503
|
url,
|
|
472
504
|
locals,
|
|
473
505
|
cookies,
|
|
506
|
+
// An API route is reached through its own URL, never through the
|
|
507
|
+
// client router's data endpoint.
|
|
508
|
+
isDataRequest: false,
|
|
474
509
|
});
|
|
475
510
|
|
|
476
511
|
// Redirect returned (not thrown) — convert to a 303 Response.
|
|
477
|
-
if (handlerResult
|
|
512
|
+
if (isRedirect(handlerResult)) {
|
|
478
513
|
return new Response(null, {
|
|
479
514
|
status: handlerResult.status,
|
|
480
515
|
headers: { Location: handlerResult.location },
|
|
@@ -546,13 +581,13 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
546
581
|
} catch (err) {
|
|
547
582
|
// `throw redirect(303, "/")` from a +server.ts handler — turn it into
|
|
548
583
|
// a real 303 instead of a 500. Mirrors the page-action handler below.
|
|
549
|
-
if (err
|
|
584
|
+
if (isRedirect(err)) {
|
|
550
585
|
return new Response(null, {
|
|
551
586
|
status: err.status,
|
|
552
587
|
headers: { Location: err.location },
|
|
553
588
|
});
|
|
554
589
|
}
|
|
555
|
-
if (err
|
|
590
|
+
if (isHttpError(err)) {
|
|
556
591
|
return Response.json({ error: err.message }, { status: err.status });
|
|
557
592
|
}
|
|
558
593
|
if (isDev) console.error("API route error:", err);
|
|
@@ -704,7 +739,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
704
739
|
try {
|
|
705
740
|
result = await action(event);
|
|
706
741
|
} catch (err) {
|
|
707
|
-
if (err
|
|
742
|
+
if (isRedirect(err)) {
|
|
708
743
|
if (isEnhanced) {
|
|
709
744
|
return Response.json({
|
|
710
745
|
type: "redirect",
|
|
@@ -717,7 +752,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
717
752
|
headers: { Location: err.location },
|
|
718
753
|
});
|
|
719
754
|
}
|
|
720
|
-
if (err
|
|
755
|
+
if (isHttpError(err)) {
|
|
721
756
|
if (isEnhanced) {
|
|
722
757
|
return Response.json(
|
|
723
758
|
{ type: "error", status: err.status, message: err.message },
|
|
@@ -740,7 +775,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
740
775
|
}
|
|
741
776
|
|
|
742
777
|
// Redirect returned (not thrown)
|
|
743
|
-
if (result
|
|
778
|
+
if (isRedirect(result)) {
|
|
744
779
|
if (isEnhanced) {
|
|
745
780
|
return Response.json({
|
|
746
781
|
type: "redirect",
|
|
@@ -792,7 +827,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
792
827
|
);
|
|
793
828
|
}
|
|
794
829
|
} catch (err) {
|
|
795
|
-
if (err
|
|
830
|
+
if (isRedirect(err)) {
|
|
796
831
|
if (isEnhanced) {
|
|
797
832
|
return Response.json({
|
|
798
833
|
type: "redirect",
|
|
@@ -805,7 +840,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
805
840
|
headers: { Location: err.location },
|
|
806
841
|
});
|
|
807
842
|
}
|
|
808
|
-
if (err
|
|
843
|
+
if (isHttpError(err)) {
|
|
809
844
|
if (isEnhanced) {
|
|
810
845
|
return Response.json(
|
|
811
846
|
{ type: "error", status: err.status, message: err.message },
|
|
@@ -921,6 +956,11 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
921
956
|
}
|
|
922
957
|
|
|
923
958
|
inFlight++;
|
|
959
|
+
// Hoisted so the catch below can tell a data request from a page request and
|
|
960
|
+
// reuse the same nonce when it renders an error page.
|
|
961
|
+
let dataReq: DataRequest | null = null;
|
|
962
|
+
let nonce = "";
|
|
963
|
+
let cookieJar: CookieJar | null = null;
|
|
924
964
|
try {
|
|
925
965
|
// Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
|
|
926
966
|
if (CORS_CONFIG && request.method === "OPTIONS") {
|
|
@@ -937,16 +977,50 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
937
977
|
const isHttps =
|
|
938
978
|
(TRUST_PROXY && request.headers.get("x-forwarded-proto") === "https") ||
|
|
939
979
|
url.protocol === "https:";
|
|
940
|
-
|
|
941
|
-
|
|
980
|
+
cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isHttps);
|
|
981
|
+
nonce = CSP_ENABLED ? generateNonce() : "";
|
|
982
|
+
|
|
983
|
+
// Decode the data endpoint before the hooks, not inside resolve(): a guard
|
|
984
|
+
// runs *before* `await resolve(event)`, so a rewrite in there reaches
|
|
985
|
+
// logging middleware and never reaches the check that gates the route.
|
|
986
|
+
const parsed = parseDataRequest(url);
|
|
987
|
+
if (parsed === "invalid") {
|
|
988
|
+
return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
|
|
989
|
+
}
|
|
990
|
+
dataReq = parsed;
|
|
991
|
+
if (dataReq) dataRequests.set(request, dataReq);
|
|
992
|
+
|
|
942
993
|
const event: RequestEvent = {
|
|
943
994
|
request,
|
|
944
|
-
url,
|
|
995
|
+
url: dataReq ? dataReq.routeUrl : url,
|
|
945
996
|
locals: { nonce },
|
|
946
997
|
params: {},
|
|
947
998
|
cookies: cookieJar,
|
|
999
|
+
isDataRequest: dataReq !== null,
|
|
948
1000
|
};
|
|
949
|
-
|
|
1001
|
+
let response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
|
|
1002
|
+
|
|
1003
|
+
// A hook that short-circuits a data request with a redirect is answering
|
|
1004
|
+
// the client router, which speaks JSON — an unconverted 3xx is followed by
|
|
1005
|
+
// `fetch` and the router receives the redirect target's HTML instead.
|
|
1006
|
+
// `Location` is copied verbatim: `redirect()` already rebased it through
|
|
1007
|
+
// `withBase()`, and a raw `Response.redirect` under a BASE_PATH carries the
|
|
1008
|
+
// base by hand, so rebasing here would double the prefix on both.
|
|
1009
|
+
if (dataReq && response.status >= 300 && response.status < 400) {
|
|
1010
|
+
const location = response.headers.get("location");
|
|
1011
|
+
if (location) {
|
|
1012
|
+
const carried = new Headers(response.headers);
|
|
1013
|
+
carried.delete("location");
|
|
1014
|
+
carried.delete("content-type");
|
|
1015
|
+
carried.delete("content-length");
|
|
1016
|
+
carried.delete("content-encoding");
|
|
1017
|
+
carried.set("content-type", "application/json");
|
|
1018
|
+
response = new Response(JSON.stringify({ redirect: location, status: response.status }), {
|
|
1019
|
+
status: 200,
|
|
1020
|
+
headers: carried,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
950
1024
|
|
|
951
1025
|
const headers = new Headers(response.headers);
|
|
952
1026
|
// A handle can mark a response (e.g. a proxied embeddable preview) to opt
|
|
@@ -979,6 +1053,45 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
979
1053
|
headers,
|
|
980
1054
|
});
|
|
981
1055
|
} catch (err) {
|
|
1056
|
+
// `throw redirect()` / `throw error()` from a hook lands here — the same
|
|
1057
|
+
// escape hatch loaders have always had. Without these branches both fall
|
|
1058
|
+
// through to the 500 below, which is why the docs could only ever suggest
|
|
1059
|
+
// returning a raw `Response.redirect`.
|
|
1060
|
+
if (isRedirect(err) || isHttpError(err)) {
|
|
1061
|
+
const out = isRedirect(err)
|
|
1062
|
+
? dataReq
|
|
1063
|
+
? // Shape-identical to the loader conversion below, so the
|
|
1064
|
+
// router has one payload contract regardless of who redirected.
|
|
1065
|
+
Response.json({ redirect: err.location, status: err.status })
|
|
1066
|
+
: Response.redirect(err.location, err.status)
|
|
1067
|
+
: dataReq
|
|
1068
|
+
? Response.json(
|
|
1069
|
+
{
|
|
1070
|
+
error: { status: err.status, message: err.message },
|
|
1071
|
+
errorDepth: null,
|
|
1072
|
+
errorOrigin: null,
|
|
1073
|
+
},
|
|
1074
|
+
{ status: err.status },
|
|
1075
|
+
)
|
|
1076
|
+
: await renderErrorPage(
|
|
1077
|
+
err.status,
|
|
1078
|
+
err.message,
|
|
1079
|
+
url,
|
|
1080
|
+
request,
|
|
1081
|
+
undefined,
|
|
1082
|
+
undefined,
|
|
1083
|
+
undefined,
|
|
1084
|
+
undefined,
|
|
1085
|
+
nonce,
|
|
1086
|
+
);
|
|
1087
|
+
// A hook that expires the session before throwing must not lose the
|
|
1088
|
+
// Set-Cookie that does it — `Response.redirect` builds a fresh Response,
|
|
1089
|
+
// so the jar is re-applied by hand here.
|
|
1090
|
+
if (cookieJar) {
|
|
1091
|
+
for (const cookie of cookieJar.outgoing) out.headers.append("Set-Cookie", cookie);
|
|
1092
|
+
}
|
|
1093
|
+
return out;
|
|
1094
|
+
}
|
|
982
1095
|
if (isDev) console.error("Unhandled request error:", err);
|
|
983
1096
|
else console.error("Unhandled request error:", (err as Error).message ?? err);
|
|
984
1097
|
if (isDev) reportDevErrorFromCatch(err);
|