bosia 1.1.4 → 1.1.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.
@@ -8,8 +8,14 @@
8
8
  // App.svelte), mirroring the modern SvelteKit `$app/state` direction. Route
9
9
  // components should destructure `params` from `$props()`. `page.params` was
10
10
  // removed in 1.1.0.
11
+ //
12
+ // `page.route.id` is the matched route's folder path with groups kept
13
+ // (`/(private)/dashboard/[id]`), the same id hooks and loaders get. `null` when
14
+ // no route matches, e.g. on the 404 page.
11
15
 
12
16
  import { router } from "./router.svelte.ts";
17
+ import { findMatch } from "../matcher.ts";
18
+ import { clientRoutes } from "bosia:routes";
13
19
 
14
20
  class Page {
15
21
  // Real on the server too: the renderer seeds `router.currentRoute`/`.origin`
@@ -18,12 +24,21 @@ class Page {
18
24
  // render that seeded nothing at all — a bare unit test, never a served request.
19
25
  #url = $derived.by(() => new URL(router.currentRoute, router.origin || "http://localhost/"));
20
26
 
27
+ // clientRoutes carry the BASE_PATH prefix, and so does `router.currentRoute`
28
+ // (the renderer puts it back before SSR), so the pathname matches as-is.
29
+ #route = $derived.by(() => ({
30
+ id: findMatch(clientRoutes, this.#url.pathname)?.route.id ?? null,
31
+ }));
32
+
21
33
  get url() {
22
34
  return this.#url;
23
35
  }
24
36
  get pathname() {
25
37
  return this.#url.pathname;
26
38
  }
39
+ get route() {
40
+ return this.#route;
41
+ }
27
42
  }
28
43
 
29
44
  export const page = new Page();
@@ -10,8 +10,9 @@ import { base } from "./base.ts";
10
10
 
11
11
  /**
12
12
  * Build the `_invalidated` mask bits for a target path using the current
13
- * client loader cache. Char 0 = page, char i+1 = layout depth i; '1' = run,
14
- * '0' = skip. Returns `null` when the route cannot be matched.
13
+ * client loader cache. Char 0 = page, char i+1 = layout depth i; '1' = send,
14
+ * '0' = the client already holds it (the server still runs layout loaders,
15
+ * since they may be guards). Returns `null` when the route cannot be matched.
15
16
  */
16
17
  export function buildMaskBits(path: string): string | null {
17
18
  const url = new URL(path, window.location.origin);
@@ -42,37 +43,10 @@ export function buildMaskBits(path: string): string | null {
42
43
  }
43
44
 
44
45
  /**
45
- * Build `parentSnapshots` (layout depth → cached data) for a target path from
46
- * the current loader cache, given the mask bits from `buildMaskBits`. For each
47
- * layout depth whose mask bit is '0' (skipped) and whose cached entry exists,
48
- * forward that layer's data so server-side downstream loaders see real
49
- * `parent()` data instead of `{}`. Returns `{}` when nothing to carry.
50
- *
51
- * Client-supplied perf hint only — the server never trusts it for authz.
46
+ * Builds the `/__bosia/data/…` URL for a given client path. `fresh` marks a
47
+ * fetch that follows an `invalidate()`, so the server skips its cached copy.
52
48
  */
53
- export function buildParentSnapshots(
54
- path: string,
55
- maskBits: string,
56
- ): Record<number, Record<string, any>> {
57
- const snapshots: Record<number, Record<string, any>> = {};
58
- const url = new URL(path, window.location.origin);
59
- const match = findMatch(clientRoutes, url.pathname);
60
- if (!match) return snapshots;
61
- const layoutIds = (match.route as any).layoutIds as (string | null)[];
62
-
63
- layoutIds.forEach((id, depth) => {
64
- // maskBits char 0 = page, char depth+1 = layout depth. '0' = skipped.
65
- if (maskBits[depth + 1] !== "0") return;
66
- if (id === null) return;
67
- const entry = appState.loaderCache.layouts[id];
68
- if (entry) snapshots[depth] = entry.data;
69
- });
70
-
71
- return snapshots;
72
- }
73
-
74
- /** Builds the `/__bosia/data/…` URL for a given client path. */
75
- export function dataUrl(path: string, invalidatedBits?: string): string {
49
+ export function dataUrl(path: string, invalidatedBits?: string, fresh = false): string {
76
50
  const url = new URL(path, window.location.origin);
77
51
  let p = url.pathname.replace(/\/$/, "");
78
52
  let qs = url.search;
@@ -80,6 +54,7 @@ export function dataUrl(path: string, invalidatedBits?: string): string {
80
54
  const sep = qs ? "&" : "?";
81
55
  qs = `${qs}${sep}_invalidated=${invalidatedBits}`;
82
56
  }
57
+ if (fresh) qs = `${qs}${qs ? "&" : "?"}_fresh=1`;
83
58
  // The one place a path is still taken apart, and it is URL construction rather
84
59
  // than navigation: the data endpoint is `<base>/__bosia/data` + the *app* path,
85
60
  // so the mount prefix moves from the front of the route to the front of the
@@ -151,35 +126,39 @@ export function consumePrefetch(path: string): any | null {
151
126
 
152
127
  /** Prefetches data for a path and stores in cache. No-op if already cached/in-flight. */
153
128
  export async function prefetchPath(path: string): Promise<void> {
129
+ // Nothing to fetch for the page we're on. A click also focuses its link, so
130
+ // the focusin trigger fires ~100ms after the navigation it belongs to.
131
+ if (path === window.location.pathname + window.location.search) return;
154
132
  // Warm the route's +loading.svelte chunk alongside its data, so the skeleton
155
133
  // paints instantly on click instead of cold-importing after the old page lingers.
156
134
  const warmMatch = findMatch(clientRoutes, new URL(path, window.location.origin).pathname);
157
- (warmMatch?.route as { loading?: (() => Promise<unknown>) | null } | undefined)?.loading?.();
135
+ // Also warm the page and layout chunks: a route not visited yet otherwise
136
+ // downloads its code only after the click. Errors are swallowed — a stale
137
+ // chunk is the click's to report, not the hover's (hydrate.ts reloads on it).
138
+ if (warmMatch) {
139
+ const route = warmMatch.route as typeof warmMatch.route & {
140
+ loading?: (() => Promise<unknown>) | null;
141
+ };
142
+ for (const load of [route.loading, route.page, ...route.layouts]) {
143
+ load?.().catch(() => {});
144
+ }
145
+ }
158
146
 
159
147
  const existing = prefetchCache.get(path);
160
148
  if (existing && Date.now() - existing.ts <= 30_000) return;
161
149
  if (existing) prefetchCache.delete(path);
162
150
  if (pending.has(path)) return;
163
151
 
152
+ // A route without server data never fetches on click either.
153
+ if (!warmMatch?.route.hasServerData) return;
154
+
164
155
  pending.add(path);
165
156
  try {
166
- // Send the same mask as a real client nav would so the server can skip
167
- // loaders whose tracked inputs haven't changed. Falls back to running
168
- // everything when the route can't be matched (e.g. external/unknown URL).
157
+ // Send the same mask as a real client nav would so the server leaves out
158
+ // layers the client already holds. Falls back to sending everything when
159
+ // the route can't be matched (e.g. external/unknown URL).
169
160
  const maskBits = buildMaskBits(path) ?? undefined;
170
- // Forward cached parent data for skipped layers so a prefetched response
171
- // is computed with real parent() data, not {}. POST only when there's
172
- // something to carry — keeps the no-skip case a cacheable/dedupable GET.
173
- const snapshots = maskBits ? buildParentSnapshots(path, maskBits) : {};
174
- const init: RequestInit =
175
- Object.keys(snapshots).length > 0
176
- ? {
177
- method: "POST",
178
- headers: { "Content-Type": "application/json" },
179
- body: JSON.stringify({ parentSnapshots: snapshots }),
180
- }
181
- : {};
182
- const res = await fetch(dataUrl(path, maskBits), init);
161
+ const res = await fetch(dataUrl(path, maskBits));
183
162
  // `ok` alone would cache a guard's login page (200 after the redirect was
184
163
  // followed) as if it were this route's data.
185
164
  if (res.ok && !res.redirected && isJsonResponse(res)) {
@@ -228,23 +207,39 @@ function observeViewportLinks(container: Element | Document = document) {
228
207
  return observer;
229
208
  }
230
209
 
210
+ /** The link a hover/touch/focus event is on, when it sits under `data-bosia-preload="hover"`. */
211
+ function hoverLinkHref(target: EventTarget | null): string | null {
212
+ if (!(target instanceof Element)) return null;
213
+ // Early exit: skip if no [data-bosia-preload="hover"] ancestor exists
214
+ const preloadEl = target.closest("[data-bosia-preload]");
215
+ if (!preloadEl || preloadEl.getAttribute("data-bosia-preload") !== "hover") return null;
216
+ const anchor = target.closest("a") as HTMLAnchorElement | null;
217
+ return anchor ? getLinkHref(anchor) : null;
218
+ }
219
+
231
220
  export function initPrefetch(): void {
232
- // ── Hover strategy (event delegation, 20ms debounce) ─────
221
+ // ── Hover strategy (event delegation, 100ms debounce) ────
233
222
  let hoverTimer: ReturnType<typeof setTimeout> | null = null;
234
223
 
235
- document.addEventListener("mouseover", (e) => {
236
- if (!(e.target instanceof Element)) return;
237
- // Early exit: skip if no [data-bosia-preload="hover"] ancestor exists
238
- const preloadEl = e.target.closest("[data-bosia-preload]");
239
- if (!preloadEl || preloadEl.getAttribute("data-bosia-preload") !== "hover") return;
240
- const anchor = e.target.closest("a") as HTMLAnchorElement | null;
241
- if (!anchor) return;
242
- const href = getLinkHref(anchor);
224
+ const onIntent = (e: Event) => {
225
+ const href = hoverLinkHref(e.target);
243
226
  if (!href) return;
244
-
245
227
  if (hoverTimer) clearTimeout(hoverTimer);
246
228
  hoverTimer = setTimeout(() => prefetchPath(href), 100);
247
- });
229
+ };
230
+ document.addEventListener("mouseover", onIntent);
231
+ // Keyboard users tab onto a link before pressing Enter — same signal.
232
+ document.addEventListener("focusin", onIntent);
233
+ // Touch screens have no hover; a touch is followed by the click within
234
+ // ~100ms or not at all, so start right away instead of debouncing.
235
+ document.addEventListener(
236
+ "touchstart",
237
+ (e) => {
238
+ const href = hoverLinkHref(e.target);
239
+ if (href) prefetchPath(href);
240
+ },
241
+ { passive: true },
242
+ );
248
243
 
249
244
  document.addEventListener("mouseout", () => {
250
245
  if (hoverTimer) {
package/src/core/dev.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
- import { readdirSync, statSync, watch, type Dirent } from "fs";
2
+ import { readdirSync, rmSync, statSync, watch, type Dirent } from "fs";
3
3
  import { join } from "path";
4
4
  import { loadEnv } from "./env.ts";
5
5
  import { BOSIA_NODE_PATH } from "./paths.ts";
@@ -12,6 +12,12 @@ import { affectsRouteManifest, shouldIgnoreForRebuild } from "./devWatch.ts";
12
12
  // but dev.ts itself never reads it.
13
13
  const DEV_OUT_DIR = ".bosia/dev";
14
14
 
15
+ // Compiled-component cache shared by every rebuild in this session (see
16
+ // svelteCompiler.ts). Outside DEV_OUT_DIR, which each build clears. Emptied
17
+ // here, once per `bosia dev`, so it only ever holds this session's entries.
18
+ const SVELTE_CACHE_DIR = ".bosia/cache/svelte";
19
+ rmSync(SVELTE_CACHE_DIR, { recursive: true, force: true });
20
+
15
21
  // Snapshot pure shell env BEFORE any loadEnv call pollutes process.env.
16
22
  // On `.env*` change we restore from this snapshot, then re-run loadEnv,
17
23
  // so removed/renamed keys no longer linger in the dev process.
@@ -142,6 +148,10 @@ async function runBuild(): Promise<boolean> {
142
148
  ...process.env,
143
149
  BOSIA_OUT_DIR: DEV_OUT_DIR,
144
150
  BOSIA_SKIP_ROUTE_SCAN: skipScan ? "1" : "0",
151
+ // The dev server never serves prerendered pages or dist/static, so the
152
+ // build skips producing them (~25% of a rebuild on the demo).
153
+ BOSIA_DEV_BUILD: "1",
154
+ BOSIA_SVELTE_CACHE_DIR: SVELTE_CACHE_DIR,
145
155
  },
146
156
  });
147
157
  return (await proc.exited) === 0;
@@ -0,0 +1,13 @@
1
+ // ─── ETag matching ───────────────────────────────────────
2
+ // Shared by static files (staticManifest.ts) and cached responses (cache.ts).
3
+ // No runtime imports: cache.ts also runs on Cloudflare Workers.
4
+
5
+ /** True when an `If-None-Match` header names `etag` (or `*`). Weak tags match too. */
6
+ export function matchesEtag(header: string | null, etag: string): boolean {
7
+ if (!header) return false;
8
+ for (const raw of header.split(",")) {
9
+ const t = raw.trim();
10
+ if (t === "*" || t === etag || (t.startsWith("W/") && t.slice(2) === etag)) return true;
11
+ }
12
+ return false;
13
+ }
package/src/core/hooks.ts CHANGED
@@ -58,6 +58,19 @@ export type RequestEvent = {
58
58
  */
59
59
  locals: Record<string, any> & { nonce?: string };
60
60
  params: Record<string, string>;
61
+ /**
62
+ * The matched route. `id` is its folder under `src/routes` with groups kept,
63
+ * e.g. `"/(private)/dashboard/[id]"`; `null` when nothing matched. Set before
64
+ * `handle` runs, for page, client-navigation data, form and API requests
65
+ * alike, so one check guards a whole group:
66
+ *
67
+ * ```ts
68
+ * if (event.route.id?.startsWith("/(private)") && !event.locals.user) {
69
+ * throw redirect(303, "/login");
70
+ * }
71
+ * ```
72
+ */
73
+ route: { id: string | null };
61
74
  cookies: Cookies;
62
75
  /**
63
76
  * True when the client router is fetching this page's loader data for a
@@ -79,6 +92,8 @@ export type RequestEvent = {
79
92
  export type LoadEvent = {
80
93
  url: URL;
81
94
  params: Record<string, string>;
95
+ /** The route being rendered; see `RequestEvent.route`. */
96
+ route: { id: string };
82
97
  locals: Record<string, any>;
83
98
  cookies: Cookies;
84
99
  fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -169,6 +184,8 @@ export type Handle = (input: {
169
184
 
170
185
  export type MetadataEvent = {
171
186
  params: Record<string, string>;
187
+ /** The route being rendered; see `RequestEvent.route`. */
188
+ route: { id: string };
172
189
  url: URL;
173
190
  locals: Record<string, any>;
174
191
  cookies: Cookies;
package/src/core/html.ts CHANGED
@@ -62,18 +62,84 @@ export function baseScript(nonce?: string): string {
62
62
  * no buster (same reason as ENTRY). Unknown pattern or an older dist/ without
63
63
  * the `preload` field → nothing, and the page loads as it did before. */
64
64
  export function routePreloadLinks(pattern?: string): string {
65
- if (!pattern) return "";
66
- return (distManifest.preload?.[pattern] ?? [])
67
- .map((f) => `\n <link rel="modulepreload" href="${DIST}/${f}">`)
68
- .join("");
65
+ const preload = distManifest.preload;
66
+ if (!pattern || !preload) return "";
67
+ let byPattern = preloadLinks.get(preload);
68
+ if (!byPattern) preloadLinks.set(preload, (byPattern = new Map()));
69
+ let links = byPattern.get(pattern);
70
+ if (links === undefined) {
71
+ links = (preload[pattern] ?? [])
72
+ .map((f) => `\n <link rel="modulepreload" href="${DIST}/${f}">`)
73
+ .join("");
74
+ byPattern.set(pattern, links);
75
+ }
76
+ return links;
77
+ }
78
+
79
+ // One header value per route pattern (plus "" for no pattern), built on first
80
+ // use. Bounded by the number of routes.
81
+ const linkHeaders = new Map<string, string>();
82
+
83
+ /**
84
+ * `Link` response header naming the stylesheets and, when the page hydrates,
85
+ * the client entry and the route's chunks. The document lists the same files,
86
+ * but a CDN that sends 103 Early Hints (Cloudflare) reads them off this header
87
+ * and lets the browser start downloading while the loaders still run.
88
+ * Prod only — dev URLs carry a cache buster and nothing sits in front.
89
+ */
90
+ export function preloadLinkHeader(pattern: string | undefined, csr: boolean): string | null {
91
+ if (isDev) return null;
92
+ const key = `${csr ? "1" : "0"}${pattern ?? ""}`;
93
+ let value = linkHeaders.get(key);
94
+ if (value === undefined) {
95
+ const tw = distManifest.tw ? `${DIST}/${distManifest.tw}` : TW_CSS;
96
+ const parts = [tw, ...(distManifest.css ?? []).map((f) => `${DIST}/${f}`)].map(
97
+ (href) => `<${href}>; rel=preload; as=style`,
98
+ );
99
+ if (csr) {
100
+ const scripts = [
101
+ ENTRY,
102
+ ...(distManifest.preload?.[pattern ?? ""] ?? []).map((f) => `${DIST}/${f}`),
103
+ ];
104
+ for (const href of scripts) parts.push(`<${href}>; rel=modulepreload`);
105
+ }
106
+ value = parts.join(", ");
107
+ linkHeaders.set(key, value);
108
+ }
109
+ return value;
69
110
  }
70
111
 
112
+ /** `extra` with the preload `Link` header added — after any `link` a loader set. */
113
+ export function withPreloadLink(
114
+ extra: Record<string, string> | undefined,
115
+ pattern: string | undefined,
116
+ csr: boolean,
117
+ ): Record<string, string> | undefined {
118
+ const link = preloadLinkHeader(pattern, csr);
119
+ if (!link) return extra;
120
+ const own = extra?.["link"];
121
+ return { ...extra, link: own ? `${own}, ${link}` : link };
122
+ }
123
+
124
+ // The head fragments below are the same on every request, so each is built
125
+ // once and reused for as long as the manifest field it reads stays the same
126
+ // object (tests swap them out).
127
+ const preloadLinks = new WeakMap<object, Map<string, string>>();
128
+
71
129
  /** Tailwind stylesheet link. Content-hashed name needs no cache buster — the
72
130
  * hash IS the buster. Fallback keeps older dist/ artifacts (no `tw` field) styled. */
131
+ let twLink: { tw: string | undefined; html: string } | null = null;
73
132
  function twCssLink(): string {
74
- return distManifest.tw
75
- ? `<link rel="stylesheet" href="${DIST}/${distManifest.tw}">`
76
- : `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`;
133
+ const tw = distManifest.tw;
134
+ if (!twLink || twLink.tw !== tw) {
135
+ twLink = {
136
+ tw,
137
+ html: tw
138
+ ? `<link rel="stylesheet" href="${DIST}/${tw}">`
139
+ : `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`,
140
+ };
141
+ }
142
+ return twLink.html;
77
143
  }
78
144
 
79
145
  /** The build-time component stylesheet (scoped `<style>` blocks, concatenated).
@@ -83,10 +149,18 @@ function twCssLink(): string {
83
149
  * is settled on source order. Linking before Tailwind would silently flip
84
150
  * which one wins. Each entry carries its own indent and newline, so an app with
85
151
  * no scoped styles at all contributes nothing rather than a blank line. */
152
+ let cssLinks: { css: string[] | undefined; html: string } | null = null;
86
153
  function componentCssLinks(): string {
87
- return (distManifest.css ?? [])
88
- .map((f: string) => ` <link rel="stylesheet" href="${DIST}/${f}">\n`)
89
- .join("");
154
+ const css = distManifest.css;
155
+ if (!cssLinks || cssLinks.css !== css) {
156
+ cssLinks = {
157
+ css,
158
+ html: (css ?? [])
159
+ .map((f: string) => ` <link rel="stylesheet" href="${DIST}/${f}">\n`)
160
+ .join(""),
161
+ };
162
+ }
163
+ return cssLinks.html;
90
164
  }
91
165
 
92
166
  /** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
@@ -464,17 +538,28 @@ export function disableCompression(): void {
464
538
  // Shared, stateless — one instance instead of a fresh allocation per response.
465
539
  const textEncoder = new TextEncoder();
466
540
 
467
- export type Encoding = "br" | "gzip";
541
+ /** Encodings stored ahead of time — cache entries and build-time static files. */
542
+ export type StoredEncoding = "br" | "gzip";
543
+ export type Encoding = StoredEncoding | "zstd";
468
544
 
469
- /** Best encoding the client accepts — brotli over gzip, null for identity.
545
+ /** Best stored encoding the client accepts — brotli over gzip, null for identity.
470
546
  * A substring check, not q-value parsing: no browser sends `br;q=0`. */
471
- export function pickEncoding(accept: string | null): Encoding | null {
547
+ export function pickEncoding(accept: string | null): StoredEncoding | null {
472
548
  if (!accept) return null;
473
549
  if (accept.includes("br")) return "br";
474
550
  if (accept.includes("gzip")) return "gzip";
475
551
  return null;
476
552
  }
477
553
 
554
+ /** Best encoding for a body compressed on the spot. zstd comes first: at
555
+ * level 3 it matches brotli q3's size on an HTML page in about half the CPU
556
+ * (55µs vs 95µs for 7KB). Current Chrome and Firefox send it; everyone else
557
+ * falls back to `pickEncoding`. */
558
+ export function pickRequestEncoding(accept: string | null): Encoding | null {
559
+ if (accept?.includes("zstd")) return "zstd";
560
+ return pickEncoding(accept);
561
+ }
562
+
478
563
  // Per-request brotli runs once per response, so it favors speed: q3 is ~2x
479
564
  // faster than q5 for ~5–25% bigger output. Cached variants are built once and
480
565
  // served many times, so they take q5. 11 (the default) would block the event
@@ -485,6 +570,10 @@ const BROTLI = {
485
570
  cache: { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } },
486
571
  };
487
572
 
573
+ // Bun's native gzip is ~40% faster than node:zlib's at the same level and
574
+ // size. Workers has no `Bun`, but never compresses (see compressionOn).
575
+ const hasBun = typeof Bun !== "undefined";
576
+
488
577
  /** The one place runtime compression quality is set — cache.ts builds its
489
578
  * stored variants through here too. gzip keeps zlib's default level. */
490
579
  export function encodeBytes(
@@ -492,6 +581,9 @@ export function encodeBytes(
492
581
  enc: Encoding,
493
582
  quality: Quality = "request",
494
583
  ): Uint8Array<ArrayBuffer> {
584
+ if (enc === "zstd") return Bun.zstdCompressSync(bytes, { level: 3 }) as Uint8Array<ArrayBuffer>;
585
+ if (enc === "gzip" && hasBun)
586
+ return Bun.gzipSync(bytes as Uint8Array<ArrayBuffer>) as Uint8Array<ArrayBuffer>;
495
587
  const out = enc === "br" ? brotliCompressSync(bytes, BROTLI[quality]) : gzipSync(bytes);
496
588
  return new Uint8Array(out) as Uint8Array<ArrayBuffer>;
497
589
  }
@@ -504,7 +596,10 @@ export function encodeForRequest(
504
596
  req: Request,
505
597
  quality: Quality = "request",
506
598
  ): Encoded | null {
507
- const enc = pickEncoding(req.headers.get("accept-encoding"));
599
+ const accept = req.headers.get("accept-encoding");
600
+ // A cache-quality body is also stored in the cache entry, which only keeps
601
+ // brotli and gzip copies — so it never picks zstd.
602
+ const enc = quality === "cache" ? pickEncoding(accept) : pickRequestEncoding(accept);
508
603
  // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
509
604
  // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
510
605
  if (!compressionOn || isDev || !enc || bytes.length <= GZIP_MIN_BYTES) return null;
@@ -2,7 +2,7 @@ import { parse, compile } from "svelte/compiler";
2
2
  import MagicString from "magic-string";
3
3
  import { relative } from "node:path";
4
4
  import type { BunPlugin } from "bun";
5
- import { svelteMapCache } from "../../svelteCompiler.ts";
5
+ import { cachedCompile, svelteMapCache } from "../../svelteCompiler.ts";
6
6
  import { lineColFromOffset } from "../../sourceLoc.ts";
7
7
  import { toPosix } from "../../paths.ts";
8
8
  import { collectComponentCss } from "../../componentCss.ts";
@@ -133,21 +133,35 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
133
133
  const rel = toPosix(relative(cwd, args.path));
134
134
  const transformed = injectLocs(source, rel);
135
135
 
136
- const result = compile(transformed, {
137
- filename: args.path,
138
- generate,
139
- dev,
140
- hmr: dev,
141
- // Mirror the prod compiler (svelteCompiler.ts): external on both
142
- // targets, with the client's rules harvested into one stylesheet
143
- // that the head links. This plugin registers ahead of the main
144
- // compiler and its onLoad wins in dev, so letting the two drift
145
- // here is how dev and prod stop agreeing about first paint.
146
- css: "external",
147
- preserveWhitespace: dev,
148
- preserveComments: dev,
149
- cssHash: ({ css }) => `svelte-${fnv(css)}`,
150
- });
136
+ // Reused across `bosia dev` rebuilds when nothing it depends on changed
137
+ // (see cachedCompile in svelteCompiler.ts).
138
+ const { value: result } = await cachedCompile(
139
+ ["inspector", args.path, generate, dev, transformed],
140
+ () => {
141
+ const out = compile(transformed, {
142
+ filename: args.path,
143
+ generate,
144
+ dev,
145
+ hmr: dev,
146
+ // Mirror the prod compiler (svelteCompiler.ts): external on both
147
+ // targets, with the client's rules harvested into one stylesheet
148
+ // that the head links. This plugin registers ahead of the main
149
+ // compiler and its onLoad wins in dev, so letting the two drift
150
+ // here is how dev and prod stop agreeing about first paint.
151
+ css: "external",
152
+ preserveWhitespace: dev,
153
+ preserveComments: dev,
154
+ cssHash: ({ css }) => `svelte-${fnv(css)}`,
155
+ });
156
+ return {
157
+ js: {
158
+ code: out.js.code,
159
+ map: typeof out.js.map === "string" ? JSON.parse(out.js.map) : out.js.map,
160
+ },
161
+ css: out.css ? { code: out.css.code } : null,
162
+ };
163
+ },
164
+ );
151
165
 
152
166
  // Store Svelte's compile-step map. Bun's bundler doesn't chain
153
167
  // onLoad sourcemaps, so the final bundle map resolves stack frames
@@ -164,8 +178,7 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
164
178
  // line numbers differ. The resolver translates browser-side stack
165
179
  // frames (delivered via SSE), which run client code.
166
180
  if (dev && generate === "client" && result.js.map) {
167
- const m = typeof result.js.map === "string" ? JSON.parse(result.js.map) : result.js.map;
168
- svelteMapCache.set(args.path, m);
181
+ svelteMapCache.set(args.path, result.js.map);
169
182
  }
170
183
 
171
184
  // Client only — see the same guard in svelteCompiler.ts.