bosia 0.9.4 → 0.9.6

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.6",
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": [
package/src/core/build.ts CHANGED
@@ -7,6 +7,7 @@ import { generateRoutesFile } from "./routeFile.ts";
7
7
  import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
8
8
  import { makeBosiaPlugin } from "./plugin.ts";
9
9
  import { makeBosiaSvelteCompiler, svelteMapCache } from "./svelteCompiler.ts";
10
+ import { finalizeComponentCss } from "./componentCss.ts";
10
11
  import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
11
12
  import { loadEnv, classifyEnvVars } from "./env.ts";
12
13
  import { generateEnvModules } from "./envCodegen.ts";
@@ -258,6 +259,17 @@ for (const output of clientResult.outputs) {
258
259
  }
259
260
  }
260
261
 
262
+ // Scoped component `<style>` blocks, harvested during the client compile and
263
+ // written as one stylesheet the head can link. Before this they rode inside the
264
+ // JS bundle, so every SSR'd page painted unstyled until hydration. Must land
265
+ // before the manifest write below: prerenderStaticRoutes() boots the built
266
+ // server, which reads manifest.json once at startup.
267
+ const componentCssFile = finalizeComponentCss(`${OUT_DIR}/client`);
268
+ if (componentCssFile) {
269
+ cssFiles.push(componentCssFile);
270
+ console.log(`✅ Component CSS built: ${OUT_DIR}/client/${componentCssFile}`);
271
+ }
272
+
261
273
  // Entry is always "index.js" due to naming: { entry: "index.[ext]" }
262
274
  const serverEntry =
263
275
  serverResult.outputs
@@ -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);
@@ -0,0 +1,54 @@
1
+ import { writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { rebaseCssUrls } from "./basePath.ts";
4
+ import { currentBase } from "./appBase.ts";
5
+
6
+ /**
7
+ * Scoped `<style>` blocks harvested from every `.svelte` file the client build
8
+ * touches, keyed by absolute source path so a recompile of the same file
9
+ * replaces its rules rather than appending a second copy.
10
+ *
11
+ * Why a hand-rolled collector rather than letting Bun emit the CSS: with
12
+ * `splitting: true` a `css: "external"` compile makes Bun write one CSS sidecar
13
+ * per dynamic-imported chunk, which is the "Multiple files share the same
14
+ * output path" failure that 0.4.4 fought (see `test/svelte-build.test.ts`).
15
+ * Collecting here keeps the client build's CSS-output count at zero — the
16
+ * invariant that test pins — while still producing a real stylesheet.
17
+ */
18
+ const collected = new Map<string, string>();
19
+
20
+ export function collectComponentCss(filePath: string, css: string): void {
21
+ collected.set(filePath, css);
22
+ }
23
+
24
+ /**
25
+ * Concatenate everything collected into one content-hashed stylesheet in
26
+ * `clientDir`, and return its basename — or `null` when the app has no scoped
27
+ * styles at all, in which case nothing is written and nothing is linked.
28
+ *
29
+ * Mirrors `finalizeTailwindCss` (twHash.ts) deliberately: same rebase-then-hash
30
+ * order, same hash length, same `-<hash>.css` shape that staticManifest's
31
+ * HASHED_BASENAME rule reads as immutable.
32
+ */
33
+ export function finalizeComponentCss(clientDir: string): string | null {
34
+ if (collected.size === 0) return null;
35
+
36
+ // Sorted by path: the bundler visits modules in whatever order resolution
37
+ // happens to take, and an unstable order means an unstable hash means every
38
+ // build busts a cache that did not need busting.
39
+ const css = [...collected.keys()]
40
+ .sort()
41
+ .map((k) => collected.get(k)!)
42
+ .join("\n");
43
+
44
+ // Rebase before hashing, so the hash describes the bytes actually served —
45
+ // same reasoning as twHash: a `url(/img/x.png)` inside a component's
46
+ // `<style>` resolves against the origin and would land outside the mount.
47
+ const base = currentBase();
48
+ const bytes = base ? rebaseCssUrls(base, css) : css;
49
+
50
+ const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 10);
51
+ const name = `bosia-css-${hash}.css`;
52
+ writeFileSync(join(clientDir, name), bytes);
53
+ return name;
54
+ }
@@ -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/html.ts CHANGED
@@ -56,6 +56,19 @@ function twCssLink(): string {
56
56
  : `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`;
57
57
  }
58
58
 
59
+ /** The build-time component stylesheet (scoped `<style>` blocks, concatenated).
60
+ * Emitted AFTER `twCssLink()` on every path: these rules used to be appended to
61
+ * `document.head` at hydration, i.e. last, and the app stylesheets Tailwind
62
+ * inlines (`tokens.css`, `components.css`) are unlayered, so a tie between them
63
+ * is settled on source order. Linking before Tailwind would silently flip
64
+ * which one wins. Each entry carries its own indent and newline, so an app with
65
+ * no scoped styles at all contributes nothing rather than a blank line. */
66
+ function componentCssLinks(): string {
67
+ return (distManifest.css ?? [])
68
+ .map((f: string) => ` <link rel="stylesheet" href="${DIST}/${f}">\n`)
69
+ .join("");
70
+ }
71
+
59
72
  /** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
60
73
  const THEME_INIT_JS =
61
74
  "try{var t=localStorage.getItem('theme');" +
@@ -153,10 +166,6 @@ export function buildHtml(
153
166
  body = rebaseHtmlAttrs(B, body);
154
167
  head = rebaseHtmlAttrs(B, head);
155
168
 
156
- const cssLinks = (distManifest.css ?? [])
157
- .map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
158
- .join("\n ");
159
-
160
169
  // Metadata goes in before `head`: the first <title> in the document wins, and
161
170
  // the streaming path already puts metadata() ahead of <svelte:head> content
162
171
  // (which arrives later via buildHtmlTail). Same order = same winner on both paths.
@@ -210,8 +219,8 @@ export function buildHtml(
210
219
 
211
220
  return (
212
221
  headOpenInterpolated +
213
- `\n ${faviconLine}${cssLinks}\n` +
214
- ` ${twCssLink()}\n` +
222
+ `\n ${faviconLine}${twCssLink()}\n` +
223
+ componentCssLinks() +
215
224
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
216
225
  ` ${fallbackTitle}${metaTags}${head}` +
217
226
  headCloseInterpolated +
@@ -229,9 +238,8 @@ export function buildHtml(
229
238
  ${fallbackTitle}
230
239
  <link rel="icon" type="image/svg+xml" href="${FAVICON}">
231
240
  ${metaTags} ${head}
232
- ${cssLinks}
233
241
  ${twCssLink()}
234
- <script${n}>${THEME_INIT_JS}</script>
242
+ ${componentCssLinks()} <script${n}>${THEME_INIT_JS}</script>
235
243
  </head>
236
244
  <body>
237
245
  <div id="app">${body}</div>${scripts}${bodyEnd}
@@ -249,10 +257,6 @@ export function buildHtmlShellOpen(
249
257
  ): string {
250
258
  const key = safeLang(lang);
251
259
  const n = nonceAttr(nonce);
252
- const cssLinks = (distManifest.css ?? [])
253
- .map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
254
- .join("\n ");
255
-
256
260
  if (segments) {
257
261
  const headOpenInterpolated = interpolateSegment(segments.headOpen, { lang: key, nonce });
258
262
  const faviconLine = segments.hasCustomFavicon
@@ -260,8 +264,8 @@ export function buildHtmlShellOpen(
260
264
  : ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n`;
261
265
  return (
262
266
  headOpenInterpolated +
263
- `\n ${faviconLine}${cssLinks}\n` +
264
- ` ${twCssLink()}\n` +
267
+ `\n ${faviconLine}${twCssLink()}\n` +
268
+ componentCssLinks() +
265
269
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
266
270
  ` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
267
271
  );
@@ -272,8 +276,8 @@ export function buildHtmlShellOpen(
272
276
  ` <meta charset="UTF-8">\n` +
273
277
  ` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
274
278
  ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n` +
275
- ` ${cssLinks}\n` +
276
279
  ` ${twCssLink()}\n` +
280
+ componentCssLinks() +
277
281
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
278
282
  ` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
279
283
  );
@@ -4,6 +4,7 @@ import { relative } from "node:path";
4
4
  import type { BunPlugin } from "bun";
5
5
  import { svelteMapCache } from "../../svelteCompiler.ts";
6
6
  import { lineColFromOffset } from "../../sourceLoc.ts";
7
+ import { collectComponentCss } from "../../componentCss.ts";
7
8
 
8
9
  type AnyNode = {
9
10
  type?: string;
@@ -136,11 +137,12 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
136
137
  generate,
137
138
  dev,
138
139
  hmr: dev,
139
- // Mirror the prod compiler (svelteCompiler.ts): client injects scoped
140
- // CSS into the JS via `append_styles`, server discards it. No CSS
141
- // chunks means Bun's `splitting:true` output-path collisions can't
142
- // arise, so no runtime-injection workaround is needed.
143
- css: generate === "client" ? "injected" : "external",
140
+ // Mirror the prod compiler (svelteCompiler.ts): external on both
141
+ // targets, with the client's rules harvested into one stylesheet
142
+ // that the head links. This plugin registers ahead of the main
143
+ // compiler and its onLoad wins in dev, so letting the two drift
144
+ // here is how dev and prod stop agreeing about first paint.
145
+ css: "external",
144
146
  preserveWhitespace: dev,
145
147
  preserveComments: dev,
146
148
  cssHash: ({ css }) => `svelte-${fnv(css)}`,
@@ -165,6 +167,11 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
165
167
  svelteMapCache.set(args.path, m);
166
168
  }
167
169
 
170
+ // Client only — see the same guard in svelteCompiler.ts.
171
+ if (generate === "client" && result.css?.code) {
172
+ collectComponentCss(args.path, result.css.code);
173
+ }
174
+
168
175
  const js = dev ? fixBindShadow(result.js.code) : result.js.code;
169
176
  return { contents: js, loader: "ts" };
170
177
  });
@@ -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,
@@ -65,6 +65,7 @@ export function scanRoutes(): RouteManifest {
65
65
  layoutServerChain: { path: string; depth: number }[],
66
66
  errorPageChain: { path: string; depth: number }[],
67
67
  inheritedTrailingSlash: TrailingSlash,
68
+ inheritedLoading: string | null,
68
69
  ) {
69
70
  const fullDir = join(ROUTES_DIR, dir);
70
71
  if (!existsSync(fullDir)) return;
@@ -76,6 +77,12 @@ export function scanRoutes(): RouteManifest {
76
77
  const currentLayoutServers = [...layoutServerChain];
77
78
  const currentErrorPages = [...errorPageChain];
78
79
  let currentTrailingSlash = inheritedTrailingSlash;
80
+ // Cascades to every page below, nearest ancestor winning — the same shape
81
+ // as the layout chain. Without this a section with 40 routes needed 40
82
+ // identical +loading.svelte files to cover its navigations.
83
+ const currentLoading = items.some((i) => i.isFile() && i.name === "+loading.svelte")
84
+ ? join(dir, "+loading.svelte")
85
+ : inheritedLoading;
79
86
 
80
87
  if (items.some((i) => i.isFile() && i.name === "+layout.svelte")) {
81
88
  currentLayouts.push(join(dir, "+layout.svelte"));
@@ -112,10 +119,6 @@ export function scanRoutes(): RouteManifest {
112
119
  ? join(dir, "+page.server.ts")
113
120
  : null;
114
121
 
115
- const loadingFile = items.some((i) => i.isFile() && i.name === "+loading.svelte")
116
- ? join(dir, "+loading.svelte")
117
- : null;
118
-
119
122
  const pageTs = pageServerFile ? readTrailingSlash(join(ROUTES_DIR, pageServerFile)) : null;
120
123
  const effectiveTs: TrailingSlash = pageTs ?? currentTrailingSlash;
121
124
 
@@ -125,7 +128,7 @@ export function scanRoutes(): RouteManifest {
125
128
  page: pageFile,
126
129
  layouts: [...currentLayouts],
127
130
  pageServer: pageServerFile,
128
- loading: loadingFile,
131
+ loading: currentLoading,
129
132
  layoutServers: [...currentLayoutServers],
130
133
  errorPages: [...currentErrorPages],
131
134
  trailingSlash: effectiveTs,
@@ -149,11 +152,12 @@ export function scanRoutes(): RouteManifest {
149
152
  currentLayoutServers,
150
153
  currentErrorPages,
151
154
  currentTrailingSlash,
155
+ currentLoading,
152
156
  );
153
157
  }
154
158
  }
155
159
 
156
- walk("", [], [], [], [], "never");
160
+ walk("", [], [], [], [], "never", null);
157
161
 
158
162
  // Warn when a catch-all exists but no exact route covers its prefix.
159
163
  // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
@@ -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);
@@ -2,6 +2,7 @@ import { compile, compileModule } from "svelte/compiler";
2
2
  import type { BunPlugin } from "bun";
3
3
 
4
4
  import { auditSvelteSource } from "./svelteAudit.ts";
5
+ import { collectComponentCss } from "./componentCss.ts";
5
6
  import { rebaseHtmlAttrs } from "./basePath.ts";
6
7
  import { currentBase } from "./appBase.ts";
7
8
  import { loadBosiaConfig } from "./config.ts";
@@ -110,7 +111,12 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
110
111
  const source = await Bun.file(args.path).text();
111
112
  const result = compile(rebaseSvelteMarkup(source), {
112
113
  generate,
113
- css: target === "browser" ? "injected" : "external",
114
+ // External on both targets. The browser used to get "injected",
115
+ // which put every scoped rule inside the JS bundle — so an
116
+ // SSR'd page painted before its own layout CSS existed and
117
+ // snapped into place at hydration. `collectComponentCss` below
118
+ // gathers the rules into one stylesheet the head can link.
119
+ css: "external",
114
120
  dev,
115
121
  hmr: false,
116
122
  cssHash: ({ css }) => `svelte-${svelteHash(css)}`,
@@ -119,6 +125,12 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
119
125
  // rather than the legacy `html`. The audit walker assumes modern.
120
126
  modernAst: true,
121
127
  });
128
+ // Browser only: both plugin instances share module state and the
129
+ // client and server builds run concurrently, so collecting from
130
+ // each would emit every rule twice.
131
+ if (target === "browser" && result.css?.code) {
132
+ collectComponentCss(args.path, result.css.code);
133
+ }
122
134
  const existing = auditInflight.get(args.path);
123
135
  if (existing) {
124
136
  await existing;