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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
@@ -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 { consumePrefetch, prefetchCache, dataUrl, buildParentSnapshots } from "./prefetch.ts";
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((r) => r.json())
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
- if (res.ok) {
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);
@@ -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;
@@ -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 instanceof Redirect) throw err;
453
- if (err instanceof HttpError) {
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 instanceof Redirect) throw err;
517
- if (err instanceof HttpError) {
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 instanceof Redirect || err instanceof HttpError) throw 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 instanceof Redirect) {
654
+ if (isRedirect(err)) {
655
655
  return Response.redirect(err.location, err.status);
656
656
  }
657
- if (err instanceof HttpError) {
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 instanceof Redirect) return Response.redirect(err.location, err.status);
694
- if (err instanceof HttpError) {
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 instanceof Redirect) return Response.redirect(err.location, err.status);
954
- if (err instanceof HttpError) {
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,
@@ -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
- if (path.startsWith("/__bosia/data/")) {
235
- const routePathStr =
236
- path
237
- .slice("/__bosia/data".length)
238
- .replace(/\.json$/, "")
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 instanceof Redirect) {
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 instanceof HttpError) {
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 instanceof Redirect) {
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 instanceof Redirect) {
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 instanceof HttpError) {
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 instanceof Redirect) {
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 instanceof HttpError) {
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 instanceof Redirect) {
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 instanceof Redirect) {
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 instanceof HttpError) {
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
- const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isHttps);
941
- const nonce = CSP_ENABLED ? generateNonce() : "";
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
- const response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
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);