bosia 0.9.3 → 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.3",
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
 
@@ -59,7 +59,6 @@ async function main() {
59
59
  ]);
60
60
  ssrPageComponent = pageMod.default;
61
61
  ssrLayoutComponents = layoutMods.map((m) => m.default);
62
- router.params = match.params;
63
62
  }
64
63
 
65
64
  const ssrPageData = readJsonScript<Record<string, any>>("__bosia-page-data__") ?? {};
@@ -17,10 +17,11 @@ import { router } from "./router.svelte.ts";
17
17
  let paramsWarned = false;
18
18
 
19
19
  class Page {
20
- #url = $derived.by(() => {
21
- if (typeof window === "undefined") return new URL("http://localhost/");
22
- return new URL(router.currentRoute, window.location.origin);
23
- });
20
+ // Real on the server too: the renderer seeds `router.currentRoute`/`.origin`
21
+ // from the request immediately before each render (see `renderWithPageContext`
22
+ // in core/renderer.ts). The `localhost` fallback is now reachable only from a
23
+ // render that seeded nothing at all — a bare unit test, never a served request.
24
+ #url = $derived.by(() => new URL(router.currentRoute, router.origin || "http://localhost/"));
24
25
 
25
26
  get url() {
26
27
  return this.#url;
@@ -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);
@@ -77,7 +77,9 @@ export const router = new (class Router {
77
77
  ? window.location.pathname + window.location.search + window.location.hash
78
78
  : "/",
79
79
  );
80
- params = $state<Record<string, string>>({});
80
+ /** Origin half of `page.url`. Empty on the server until the renderer seeds it
81
+ * per request — see `renderWithPageContext` in core/renderer.ts. */
82
+ origin = $state(typeof window !== "undefined" ? window.location.origin : "");
81
83
  /** True when navigation was triggered by a link click / navigate() call, false on popstate (back/forward). */
82
84
  isPush = $state(true);
83
85
  /** Source of the most recent navigation — feeds the Navigation object passed to lifecycle hooks. */
@@ -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,9 +19,13 @@ 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
+ import { router } from "./client/router.svelte.ts";
26
+ import { appState } from "./client/appState.svelte.ts";
27
+ import { withBase } from "./basePath.ts";
28
+ import { currentBase } from "./appBase.ts";
25
29
  import {
26
30
  buildHtml,
27
31
  buildHtmlShellOpen,
@@ -41,6 +45,64 @@ import type { AppHtmlSegments } from "./appHtml.ts";
41
45
  // Shared, stateless — one instance instead of a fresh allocation per stream.
42
46
  const enc = new TextEncoder();
43
47
 
48
+ // ─── Per-request seed for `page` ──────────────────────────
49
+ // `page` (bosia/client) is imported directly by user components, so per-request
50
+ // values cannot reach it as props the way pageData/layoutData do — see the rule
51
+ // stated in App.svelte and appState.svelte.ts. The singletons it reads from are
52
+ // therefore seeded per request, which is a deliberate exception to that rule.
53
+ //
54
+ // This is safe only because bosia consumes render() synchronously: every call
55
+ // site destructures `{ body, head }` straight off the return value. That is
56
+ // bosia's own commitment, not svelte's guarantee — the declared type is
57
+ // `SyncRenderOutput & PromiseLike<SyncRenderOutput>` and svelte's server
58
+ // renderer has an await path. If async SSR is ever enabled, the existing
59
+ // destructuring and this seeding break together, and must be fixed together.
60
+ // Seeding lives in this wrapper so that stays true by construction: nothing can
61
+ // assign here and then await before rendering, which would make two concurrent
62
+ // requests render each other's URLs — a bug that reads as a flaky cache.
63
+ //
64
+ // `url` is app-space (server.ts strips BASE_PATH exactly once, at the top of
65
+ // handleRequest), but every value the client half writes to these fields is
66
+ // browser-space: hydrate.ts assigns raw window.location.pathname, and
67
+ // clientRoutes are generated with the prefix already in them. So the base goes
68
+ // back on before seeding, or a mounted app server-renders `/admin/audit` and
69
+ // hydration flips it to `/sso/admin/audit`.
70
+ //
71
+ // Assigning unconditionally is the point. Seeding only the success paths would
72
+ // let an error render inherit the previous request's URL — real cross-request
73
+ // leakage, strictly worse than a wrong-but-deterministic default.
74
+ //
75
+ // Two limits worth knowing before leaning on this:
76
+ //
77
+ // 1. The seed lands here, at render time — after every `load()` and
78
+ // `metadata()` has already run. `page` still holds the previous request's
79
+ // values throughout those, and the only reason that is not a live bug is
80
+ // that they live in `+page.server.ts` / `+layout.server.ts`, where
81
+ // `bosia/client` is not importable (see the header of lib/client.ts). Server
82
+ // loaders get the real URL as the `url` argument; they must never read
83
+ // `page`.
84
+ // 2. `url.origin` is the public origin only when TRUST_PROXY=true —
85
+ // server.ts rebuilds host/proto from X-Forwarded-* under that flag alone,
86
+ // because the headers are client-spoofable otherwise. Behind an untrusted
87
+ // proxy the seeded origin is the inner hop (http://localhost:PORT) while the
88
+ // browser hydrates with the public https:// origin, so `page.url.origin`
89
+ // disagrees across the boundary. `page.url.pathname` is unaffected.
90
+ //
91
+ // `render`'s own signature is conditional on the component's prop type, so it
92
+ // collapses to `never` behind a generic wrapper — hence `any` on both, matching
93
+ // how the call sites already type `pageMod.default` / `layoutMods`.
94
+ function renderWithPageContext(
95
+ component: any,
96
+ options: { props?: Record<string, any> },
97
+ url: URL,
98
+ params: Record<string, string> = {},
99
+ ) {
100
+ router.origin = url.origin;
101
+ router.currentRoute = withBase(currentBase(), url.pathname) + url.search + url.hash;
102
+ appState.routeParams = params;
103
+ return render(component, options as any);
104
+ }
105
+
44
106
  // Plugins are loaded once per process at module init via top-level await elsewhere
45
107
  // (server.ts), but renderer is also reachable from build/prerender contexts where
46
108
  // loadPlugins() may not have been called yet. The function is cached, so awaiting
@@ -387,8 +449,8 @@ export async function loadRouteData(
387
449
  layoutDeps[ls.depth] = emptyDeps();
388
450
  }
389
451
  } catch (err) {
390
- if (err instanceof Redirect) throw err;
391
- if (err instanceof HttpError) {
452
+ if (isRedirect(err)) throw err;
453
+ if (isHttpError(err)) {
392
454
  stampErrorContext(
393
455
  err,
394
456
  ls.depth,
@@ -451,8 +513,8 @@ export async function loadRouteData(
451
513
  pageDeps = emptyDeps();
452
514
  }
453
515
  } catch (err) {
454
- if (err instanceof Redirect) throw err;
455
- if (err instanceof HttpError) {
516
+ if (isRedirect(err)) throw err;
517
+ if (isHttpError(err)) {
456
518
  stampErrorContext(
457
519
  err,
458
520
  route.layoutModules.length,
@@ -511,7 +573,7 @@ export async function loadMetadata(
511
573
  } catch (err) {
512
574
  // Control flow thrown from metadata() is intent, not failure — swallowing it
513
575
  // here made every caller's Redirect/HttpError branch dead code.
514
- if (err instanceof Redirect || err instanceof HttpError) throw err;
576
+ if (isRedirect(err) || isHttpError(err)) throw err;
515
577
  if (isDev) console.error("Metadata load error:", err);
516
578
  else console.error("Metadata load error:", (err as Error).message ?? err);
517
579
  if (isDev) reportDevErrorFromCatch(err);
@@ -589,10 +651,10 @@ export async function renderSSRStream(
589
651
  try {
590
652
  metadata = await loadMetadata(route, params, url, locals, cookies, req);
591
653
  } catch (err) {
592
- if (err instanceof Redirect) {
654
+ if (isRedirect(err)) {
593
655
  return Response.redirect(err.location, err.status);
594
656
  }
595
- if (err instanceof HttpError) {
657
+ if (isHttpError(err)) {
596
658
  return renderErrorPage(
597
659
  err.status,
598
660
  err.message,
@@ -628,8 +690,8 @@ export async function renderSSRStream(
628
690
  ]);
629
691
  pageMod = pm;
630
692
  } catch (err) {
631
- if (err instanceof Redirect) return Response.redirect(err.location, err.status);
632
- if (err instanceof HttpError) {
693
+ if (isRedirect(err)) return Response.redirect(err.location, err.status);
694
+ if (isHttpError(err)) {
633
695
  const e = err as HttpError & {
634
696
  errorDepth?: number;
635
697
  errorOrigin?: ErrorOrigin;
@@ -725,15 +787,20 @@ export async function renderSSRStream(
725
787
  // with correct status code, instead of a bare <p> mixed into an already-flushed shell.
726
788
  let body: string, head: string;
727
789
  try {
728
- ({ body, head } = render(App, {
729
- props: {
730
- ssrMode: true,
731
- ssrPageComponent: pageMod.default,
732
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
733
- ssrPageData: pageDataFull,
734
- ssrLayoutData: layoutDataFull,
790
+ ({ body, head } = renderWithPageContext(
791
+ App,
792
+ {
793
+ props: {
794
+ ssrMode: true,
795
+ ssrPageComponent: pageMod.default,
796
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
797
+ ssrPageData: pageDataFull,
798
+ ssrLayoutData: layoutDataFull,
799
+ },
735
800
  },
736
- }));
801
+ url,
802
+ params,
803
+ ));
737
804
  } catch (err) {
738
805
  if (isDev) console.error("SSR render error:", err);
739
806
  else console.error("SSR render error:", (err as Error).message ?? err);
@@ -883,8 +950,8 @@ export async function renderPageWithFormData(
883
950
  try {
884
951
  metadata = await loadMetadata(route, params, url, locals, cookies, req);
885
952
  } catch (err) {
886
- if (err instanceof Redirect) return Response.redirect(err.location, err.status);
887
- if (err instanceof HttpError) {
953
+ if (isRedirect(err)) return Response.redirect(err.location, err.status);
954
+ if (isHttpError(err)) {
888
955
  return renderErrorPage(
889
956
  err.status,
890
957
  err.message,
@@ -969,16 +1036,21 @@ export async function renderPageWithFormData(
969
1036
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
970
1037
  }
971
1038
 
972
- const { body, head } = render(App, {
973
- props: {
974
- ssrMode: true,
975
- ssrPageComponent: pageMod.default,
976
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
977
- ssrPageData: pageDataFull,
978
- ssrLayoutData: layoutDataFull,
979
- ssrFormData: formData,
1039
+ const { body, head } = renderWithPageContext(
1040
+ App,
1041
+ {
1042
+ props: {
1043
+ ssrMode: true,
1044
+ ssrPageComponent: pageMod.default,
1045
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
1046
+ ssrPageData: pageDataFull,
1047
+ ssrLayoutData: layoutDataFull,
1048
+ ssrFormData: formData,
1049
+ },
980
1050
  },
981
- });
1051
+ url,
1052
+ params,
1053
+ );
982
1054
 
983
1055
  const html = buildHtml(
984
1056
  body,
@@ -1038,6 +1110,12 @@ export async function renderErrorPage(
1038
1110
  };
1039
1111
  const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
1040
1112
 
1113
+ // The failing route's own params, so a nested +error.svelte under
1114
+ // /blog/[slug] still sees its `slug`. A 404 matches nothing and yields {},
1115
+ // which is also what has to be seeded rather than left holding the previous
1116
+ // request's values.
1117
+ const errorParams = findMatch(serverRoutes, url.pathname)?.params ?? {};
1118
+
1041
1119
  // 1. Nested boundary
1042
1120
  if (route && errorDepth !== undefined && route.errorPages?.length) {
1043
1121
  const origin = errorOrigin ?? "page";
@@ -1055,16 +1133,21 @@ export async function renderErrorPage(
1055
1133
  ]);
1056
1134
  const layoutData: Record<string, any>[] = [];
1057
1135
  for (let i = 0; i < K; i++) layoutData.push(partialLayoutData?.[i] ?? {});
1058
- const { body, head } = render(App, {
1059
- props: {
1060
- ssrMode: true,
1061
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
1062
- ssrLayoutData: layoutData,
1063
- ssrErrorComponent: errorMod.default,
1064
- ssrErrorProps: { error: { status, message } },
1065
- ssrErrorDepth: K,
1136
+ const { body, head } = renderWithPageContext(
1137
+ App,
1138
+ {
1139
+ props: {
1140
+ ssrMode: true,
1141
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
1142
+ ssrLayoutData: layoutData,
1143
+ ssrErrorComponent: errorMod.default,
1144
+ ssrErrorProps: { error: { status, message } },
1145
+ ssrErrorDepth: K,
1146
+ },
1066
1147
  },
1067
- });
1148
+ url,
1149
+ errorParams,
1150
+ );
1068
1151
  // csr=false: no client hydration on the error page itself.
1069
1152
  const html = buildHtml(
1070
1153
  body,
@@ -1099,9 +1182,12 @@ export async function renderErrorPage(
1099
1182
  // Render the error component directly — NOT through App.svelte.
1100
1183
  // App.svelte remaps ssrPageData to a `data` prop, but +error.svelte
1101
1184
  // expects `error` as a direct prop: `let { error } = $props()`.
1102
- const { body, head } = render(mod.default, {
1103
- props: { error: { status, message } },
1104
- });
1185
+ const { body, head } = renderWithPageContext(
1186
+ mod.default,
1187
+ { props: { error: { status, message } } },
1188
+ url,
1189
+ errorParams,
1190
+ );
1105
1191
  const html = buildHtml(
1106
1192
  body,
1107
1193
  head,
@@ -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);