bosia 0.8.4 → 0.8.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.8.4",
3
+ "version": "0.8.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
@@ -10,6 +10,7 @@ import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
10
10
  import { loadEnv, classifyEnvVars } from "./env.ts";
11
11
  import { generateEnvModules } from "./envCodegen.ts";
12
12
  import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin } from "./paths.ts";
13
+ import { finalizeTailwindCss, TW_TEMP_BASENAME } from "./twHash.ts";
13
14
  import { loadPlugins } from "./config.ts";
14
15
  import type { BuildContext } from "./types/plugin.ts";
15
16
  import { loadAppHtmlTemplate, writeAppHtmlSegments } from "./appHtml.ts";
@@ -113,15 +114,21 @@ ensureRootDirs();
113
114
  // 2d. Generate .bosia/env.server.ts, .bosia/env.client.ts, .bosia/types/env.d.ts
114
115
  generateEnvModules(classifiedEnv);
115
116
 
116
- // 3. Start Tailwind CSS (async — runs concurrently with client+server builds)
117
+ // 3. Start Tailwind CSS (async — runs concurrently with client+server builds).
118
+ // Output goes to a temp name in dist/client; after the build we content-hash it
119
+ // and rename to bosia-tw-<hash>.css so it gets immutable caching and rebuilds
120
+ // bust browser caches only when the CSS actually changed. mkdir up front —
121
+ // Bun.build writes the same dir concurrently and mkdir is idempotent.
122
+ mkdirSync(join(OUT_DIR, "client"), { recursive: true });
117
123
  const tailwindBin = resolveBosiaBin("tailwindcss");
124
+ const tailwindTempPath = join(OUT_DIR, "client", TW_TEMP_BASENAME);
118
125
  const tailwindProc = Bun.spawn(
119
126
  [
120
127
  tailwindBin,
121
128
  "-i",
122
129
  "./src/app.css",
123
130
  "-o",
124
- "./public/bosia-tw.css",
131
+ tailwindTempPath,
125
132
  ...(isProduction ? ["--minify"] : []),
126
133
  ],
127
134
  {
@@ -191,7 +198,8 @@ if (tailwindExitCode !== 0) {
191
198
  console.error("❌ Tailwind CSS build failed:\n" + stderr);
192
199
  process.exit(1);
193
200
  }
194
- console.log("✅ Tailwind CSS built: public/bosia-tw.css");
201
+ const twFile = finalizeTailwindCss(tailwindTempPath);
202
+ console.log(`✅ Tailwind CSS built: ${OUT_DIR}/client/${twFile}`);
195
203
 
196
204
  if (!clientResult.success) {
197
205
  console.error("❌ Client build failed:");
@@ -253,6 +261,7 @@ const distManifest = {
253
261
  jsFiles.find((f) => f.startsWith("hydrate")) ??
254
262
  "hydrate.js",
255
263
  serverEntry,
264
+ tw: twFile,
256
265
  };
257
266
  writeFileSync(`${OUT_DIR}/manifest.json`, JSON.stringify(distManifest, null, 2));
258
267
  console.log(`✅ Client bundle: ${jsFiles.join(", ")}`);
package/src/core/cache.ts CHANGED
@@ -88,14 +88,15 @@ class LRU<K, V> {
88
88
  this.map.set(key, v);
89
89
  return v;
90
90
  }
91
- set(key: K, value: V): K | undefined {
91
+ set(key: K, value: V): { key: K; value: V } | undefined {
92
92
  if (this.map.has(key)) this.map.delete(key);
93
93
  this.map.set(key, value);
94
94
  if (this.map.size > this.cap) {
95
95
  const oldest = this.map.keys().next().value as K | undefined;
96
96
  if (oldest !== undefined) {
97
+ const evicted = this.map.get(oldest) as V;
97
98
  this.map.delete(oldest);
98
- return oldest;
99
+ return { key: oldest, value: evicted };
99
100
  }
100
101
  }
101
102
  return undefined;
@@ -217,7 +218,7 @@ export function cacheSet(key: string, entry: CacheEntry, cookies?: Cookies): voi
217
218
  // Drop any existing entry's index pointers first
218
219
  cacheDeleteKey(key);
219
220
  const evicted = htmlCache.set(key, entry);
220
- if (evicted) cacheDeleteIndexOnly(evicted);
221
+ if (evicted) cacheDeleteIndexOnly(evicted.key, evicted.value);
221
222
  for (const tag of entry.tags) {
222
223
  let set = tagIndex.get(tag);
223
224
  if (!set) {
@@ -257,9 +258,14 @@ function cacheDeleteKey(key: string): void {
257
258
  }
258
259
 
259
260
  /** Cleanup index pointers for a key after LRU evicted it. */
260
- function cacheDeleteIndexOnly(key: string): void {
261
- for (const set of tagIndex.values()) set.delete(key);
262
- for (const [tag, set] of tagIndex) if (set.size === 0) tagIndex.delete(tag);
261
+ function cacheDeleteIndexOnly(key: string, entry: CacheEntry): void {
262
+ for (const tag of entry.tags) {
263
+ const set = tagIndex.get(tag);
264
+ if (set) {
265
+ set.delete(key);
266
+ if (set.size === 0) tagIndex.delete(tag);
267
+ }
268
+ }
263
269
  const path = pathOfKey(key);
264
270
  const pset = pathIndex.get(path);
265
271
  if (pset) {
@@ -63,6 +63,26 @@
63
63
  let firstNav = true;
64
64
  let navDoneTimer: ReturnType<typeof setTimeout> | null = null;
65
65
 
66
+ // Scroll after a nav settles: forward navs go to top (or the URL hash);
67
+ // back/forward navs restore the position saved for that history entry.
68
+ // Runs after tick() so the destination page's real height is in the DOM.
69
+ // goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
70
+ function settleScroll() {
71
+ if (router.isPush && !router.suppressScroll) {
72
+ const hash = window.location.hash;
73
+ tick().then(() => {
74
+ if (!scrollToHash(hash)) window.scrollTo(0, 0);
75
+ });
76
+ } else if (!router.isPush) {
77
+ const pos = router.pendingScroll;
78
+ // ponytail: single post-tick restore; content that loads later (images
79
+ // without dimensions) can still shift it. Revisit only if reported.
80
+ if (pos) tick().then(() => window.scrollTo(pos.x, pos.y));
81
+ }
82
+ router.pendingScroll = null;
83
+ router.suppressScroll = false;
84
+ }
85
+
66
86
  $effect(() => {
67
87
  if (ssrMode) return;
68
88
 
@@ -247,13 +267,7 @@
247
267
  appState.errorComponent = errMod.default;
248
268
  appState.errorProps = { error: { status: errStatus, message: errMessage } };
249
269
  appState.errorDepth = K;
250
- if (router.isPush && !router.suppressScroll) {
251
- const hash = window.location.hash;
252
- tick().then(() => {
253
- if (!scrollToHash(hash)) window.scrollTo(0, 0);
254
- });
255
- }
256
- router.suppressScroll = false;
270
+ settleScroll();
257
271
  settle({ url, params: match.params });
258
272
  } catch {
259
273
  window.location.href = path;
@@ -342,16 +356,7 @@
342
356
  appState.errorProps = null;
343
357
  appState.errorDepth = null;
344
358
 
345
- // Scroll to top on forward navigation (not on popstate/back-forward).
346
- // goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
347
- // If the destination URL has a hash, scroll to that element instead.
348
- if (router.isPush && !router.suppressScroll) {
349
- const hash = window.location.hash;
350
- tick().then(() => {
351
- if (!scrollToHash(hash)) window.scrollTo(0, 0);
352
- });
353
- }
354
- router.suppressScroll = false;
359
+ settleScroll();
355
360
 
356
361
  // Update document title and meta description from server metadata
357
362
  if (result?.metadata) {
@@ -16,6 +16,20 @@ function buildTarget(path: string): { url: URL; params: Record<string, string> }
16
16
  return { url, params: match?.params ?? {} };
17
17
  }
18
18
 
19
+ // ─── Scroll restoration ───
20
+ // Positions keyed by a per-entry index stamped into `history.state.bosiaIndex`.
21
+ // `scrollRestoration = "manual"` because the browser restores at popstate time,
22
+ // before the SPA has rendered the destination page — it clamps against the
23
+ // wrong document height. We restore ourselves after the nav settles (App.svelte).
24
+ // Persisted to sessionStorage on unload so reload / back-into-app still restores.
25
+ const SCROLL_KEY = "bosia:scroll";
26
+ let scrollPositions: Record<number, { x: number; y: number }> = {};
27
+ let historyIndex = 0;
28
+
29
+ function saveScroll() {
30
+ scrollPositions[historyIndex] = { x: window.scrollX, y: window.scrollY };
31
+ }
32
+
19
33
  export function scrollToHash(hash: string): boolean {
20
34
  if (typeof document === "undefined" || !hash) return false;
21
35
  const raw = hash.startsWith("#") ? hash.slice(1) : hash;
@@ -45,6 +59,8 @@ export const router = new (class Router {
45
59
  lastNavType: NavType = "enter";
46
60
  /** Set by `goto({ noScroll: true })`; consumed once by App.svelte after the next nav settles. */
47
61
  suppressScroll = false;
62
+ /** Saved scroll position for the entry a popstate landed on; consumed once by App.svelte after the nav settles. */
63
+ pendingScroll: { x: number; y: number } | null = null;
48
64
 
49
65
  navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}) {
50
66
  if (this.currentRoute === path) return;
@@ -74,12 +90,15 @@ export const router = new (class Router {
74
90
 
75
91
  this.lastNavType = navType;
76
92
  this.isPush = true;
93
+ this.pendingScroll = null;
77
94
  this.currentRoute = finalPath;
78
95
  if (typeof history !== "undefined") {
79
96
  if (opts.replace) {
80
- history.replaceState({}, "", finalPath);
97
+ history.replaceState({ bosiaIndex: historyIndex }, "", finalPath);
81
98
  } else {
82
- history.pushState({}, "", finalPath);
99
+ saveScroll();
100
+ historyIndex++;
101
+ history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
83
102
  }
84
103
  }
85
104
  }
@@ -87,6 +106,22 @@ export const router = new (class Router {
87
106
  init() {
88
107
  if (typeof window === "undefined") return;
89
108
 
109
+ history.scrollRestoration = "manual";
110
+ try {
111
+ scrollPositions = JSON.parse(sessionStorage.getItem(SCROLL_KEY) ?? "{}");
112
+ } catch {
113
+ scrollPositions = {};
114
+ }
115
+ const stamped = history.state?.bosiaIndex != null;
116
+ historyIndex = stamped ? history.state.bosiaIndex : 0;
117
+ history.replaceState({ ...history.state, bosiaIndex: historyIndex }, "");
118
+ // Restore after a reload — manual mode means the browser won't. Only for
119
+ // entries we stamped before (a fresh visit has no bosiaIndex in state).
120
+ const initial = scrollPositions[historyIndex];
121
+ if (stamped && initial) {
122
+ requestAnimationFrame(() => window.scrollTo(initial.x, initial.y));
123
+ }
124
+
90
125
  // Intercept <a> clicks for client-side navigation
91
126
  window.addEventListener("click", (e) => {
92
127
  // Let browser handle non-primary buttons, modifier-clicks, already-handled events
@@ -110,7 +145,9 @@ export const router = new (class Router {
110
145
  e.preventDefault();
111
146
  const finalPath = anchor.pathname + anchor.search + anchor.hash;
112
147
  if (this.currentRoute !== finalPath) {
113
- history.pushState({}, "", finalPath);
148
+ saveScroll();
149
+ historyIndex++;
150
+ history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
114
151
  this.currentRoute = finalPath;
115
152
  }
116
153
  scrollToHash(anchor.hash);
@@ -122,8 +159,13 @@ export const router = new (class Router {
122
159
  });
123
160
 
124
161
  // Browser back/forward
125
- window.addEventListener("popstate", () => {
162
+ window.addEventListener("popstate", (e) => {
126
163
  const finalPath = window.location.pathname + window.location.search + window.location.hash;
164
+ // Save the position of the entry we're leaving (historyIndex is still
165
+ // the old entry's), then look up where the landed-on entry was.
166
+ saveScroll();
167
+ historyIndex = e.state?.bosiaIndex ?? 0;
168
+ this.pendingScroll = scrollPositions[historyIndex] ?? null;
127
169
  // Fire beforeNavigate listeners; popstate can't be reliably cancelled
128
170
  // (browser history already advanced), so we surface the event for
129
171
  // observation only — `cancel()` is a no-op for this source.
@@ -147,6 +189,14 @@ export const router = new (class Router {
147
189
  // listeners can warn-on-leave (return value ignored; cancellation here
148
190
  // requires `beforeunload`, not in scope).
149
191
  window.addEventListener("beforeunload", () => {
192
+ // ponytail: beforeunload can be skipped on mobile page-discard; add a
193
+ // visibilitychange persist if restore-after-reload proves flaky there.
194
+ saveScroll();
195
+ try {
196
+ sessionStorage.setItem(SCROLL_KEY, JSON.stringify(scrollPositions));
197
+ } catch {
198
+ // sessionStorage unavailable (private mode / quota) — skip persistence.
199
+ }
150
200
  const fromTarget = buildTarget(this.currentRoute);
151
201
  const nav: Navigation = {
152
202
  from: fromTarget,
package/src/core/dev.ts CHANGED
@@ -138,11 +138,50 @@ async function runBuild(): Promise<boolean> {
138
138
  const DEV_PORT = Number(process.env.PORT) || 9000;
139
139
  const APP_PORT = DEV_PORT + 1; // internal, hidden from user
140
140
 
141
+ // A previous dev session's app-server child can outlive its parent: an unclean
142
+ // stop (SIGKILL, IDE stop button, crash) skips graceful cleanup, or a shutdown
143
+ // slower than the SIGTERM grace window orphans it. The orphan keeps APP_PORT
144
+ // bound and answers the new proxy with bundle hashes that no longer exist on
145
+ // disk → "ENOENT reading +page-<hash>.js" 500s. Reap whatever is listening on
146
+ // APP_PORT before we spawn, so a stale child can never shadow the fresh build.
147
+ async function reapStaleAppServer(port: number) {
148
+ try {
149
+ const proc = spawn(["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"], {
150
+ stdout: "pipe",
151
+ stderr: "ignore",
152
+ });
153
+ const out = await new Response(proc.stdout).text();
154
+ await proc.exited;
155
+ const pids = [...new Set(out.split("\n").map((s) => s.trim()))]
156
+ .map(Number)
157
+ .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
158
+ for (const pid of pids) {
159
+ try {
160
+ process.kill(pid, "SIGKILL");
161
+ console.log(`🧹 Reaped stale app server holding port ${port} (pid ${pid})`);
162
+ } catch {}
163
+ }
164
+ if (pids.length) await Bun.sleep(200); // let the port free before we bind
165
+ } catch {
166
+ // lsof unavailable or errored — fail open; a real conflict surfaces on bind.
167
+ }
168
+ }
169
+
141
170
  async function startAppServer() {
142
171
  if (appProcess) {
143
172
  intentionalKill = true;
144
- appProcess.kill();
145
- await appProcess.exited;
173
+ appProcess.kill("SIGTERM");
174
+ // Escalate to SIGKILL if the child won't stop — otherwise `await exited`
175
+ // hangs the rebuild forever, or (on a slow exit) the child lingers on
176
+ // APP_PORT and shadows the next spawn.
177
+ const exited = await Promise.race([
178
+ appProcess.exited.then(() => true),
179
+ Bun.sleep(3_000).then(() => false),
180
+ ]);
181
+ if (!exited) {
182
+ appProcess.kill("SIGKILL");
183
+ await appProcess.exited;
184
+ }
146
185
  intentionalKill = false;
147
186
  }
148
187
 
@@ -421,6 +460,9 @@ const devServer = Bun.serve({
421
460
 
422
461
  // ─── Initial Build ────────────────────────────────────────
423
462
 
463
+ // Clear any orphaned app server from a previous session before the first spawn.
464
+ await reapStaleAppServer(APP_PORT);
465
+
424
466
  await buildAndRestart();
425
467
 
426
468
  console.log(`\n🌐 Open http://localhost:${DEV_PORT}\n`);
@@ -428,7 +470,7 @@ console.log(`\n🌐 Open http://localhost:${DEV_PORT}\n`);
428
470
  // ─── File Watcher ─────────────────────────────────────────
429
471
  // Watch src/ recursively. Skip generated files to avoid loops.
430
472
 
431
- const GENERATED = [join(process.cwd(), ".bosia"), join(process.cwd(), "public", "bosia-tw.css")];
473
+ const GENERATED = [join(process.cwd(), ".bosia")];
432
474
 
433
475
  function isGenerated(path: string): boolean {
434
476
  return GENERATED.some((g) => path.startsWith(g));
@@ -586,7 +628,13 @@ async function shutdown() {
586
628
 
587
629
  if (appProcess) {
588
630
  appProcess.kill("SIGTERM");
589
- await Promise.race([appProcess.exited, Bun.sleep(2_500)]);
631
+ const exited = await Promise.race([
632
+ appProcess.exited.then(() => true),
633
+ Bun.sleep(2_500).then(() => false),
634
+ ]);
635
+ // Don't orphan a slow-to-stop child (it would keep APP_PORT bound and
636
+ // break the next `bun run dev`). Force it down before we exit.
637
+ if (!exited) appProcess.kill("SIGKILL");
590
638
  }
591
639
 
592
640
  // Everything is stopped — exit now rather than waiting for the loop to drain.
package/src/core/html.ts CHANGED
@@ -9,7 +9,7 @@ import { interpolateSegment } from "./appHtml.ts";
9
9
  // Maps hashed filenames → script/link tags.
10
10
  // Cached at startup; server restarts on rebuild in dev anyway.
11
11
 
12
- export const distManifest: { js: string[]; css: string[]; entry: string } = (() => {
12
+ export const distManifest: { js: string[]; css: string[]; entry: string; tw?: string } = (() => {
13
13
  const p = `${OUT_DIR}/manifest.json`;
14
14
  return existsSync(p)
15
15
  ? JSON.parse(readFileSync(p, "utf-8"))
@@ -19,6 +19,14 @@ export const distManifest: { js: string[]; css: string[]; entry: string } = (()
19
19
  export const isDev = process.env.NODE_ENV !== "production";
20
20
  const cacheBust = isDev ? `?v=${Date.now()}` : "";
21
21
 
22
+ /** Tailwind stylesheet link. Content-hashed name needs no cache buster — the
23
+ * hash IS the buster. Fallback keeps older dist/ artifacts (no `tw` field) styled. */
24
+ function twCssLink(): string {
25
+ return distManifest.tw
26
+ ? `<link rel="stylesheet" href="/dist/client/${distManifest.tw}">`
27
+ : `<link rel="stylesheet" href="/bosia-tw.css${cacheBust}">`;
28
+ }
29
+
22
30
  /** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
23
31
  const THEME_INIT_JS =
24
32
  "try{var t=localStorage.getItem('theme');" +
@@ -161,7 +169,7 @@ export function buildHtml(
161
169
  return (
162
170
  headOpenInterpolated +
163
171
  `\n ${faviconLine}${cssLinks}\n` +
164
- ` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
172
+ ` ${twCssLink()}\n` +
165
173
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
166
174
  ` ${fallbackTitle}${head}` +
167
175
  headCloseInterpolated +
@@ -180,7 +188,7 @@ export function buildHtml(
180
188
  <link rel="icon" type="image/svg+xml" href="/favicon.svg">
181
189
  ${head}
182
190
  ${cssLinks}
183
- <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">
191
+ ${twCssLink()}
184
192
  <script${n}>${THEME_INIT_JS}</script>
185
193
  </head>
186
194
  <body>
@@ -213,7 +221,7 @@ export function buildHtmlShellOpen(
213
221
  return (
214
222
  headOpenInterpolated +
215
223
  `\n ${faviconLine}${cssLinks}\n` +
216
- ` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
224
+ ` ${twCssLink()}\n` +
217
225
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
218
226
  ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
219
227
  );
@@ -225,7 +233,7 @@ export function buildHtmlShellOpen(
225
233
  ` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
226
234
  ` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n` +
227
235
  ` ${cssLinks}\n` +
228
- ` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
236
+ ` ${twCssLink()}\n` +
229
237
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
230
238
  ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
231
239
  );
@@ -82,13 +82,13 @@ export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
82
82
  });
83
83
 
84
84
  // "tailwindcss" inside app.css is a Tailwind CLI directive —
85
- // it's already compiled to public/bosia-tw.css by the CLI step.
85
+ // it's already compiled to dist/client/bosia-tw-<hash>.css by the CLI step.
86
86
  // Return an empty CSS module so Bun's CSS bundler doesn't choke on it.
87
87
  build.onResolve({ filter: /^tailwindcss$/ }, () => ({
88
88
  path: "tailwindcss",
89
89
  namespace: "bosia-empty-css",
90
90
  }));
91
- // app.css is processed by Tailwind CLI into public/bosia-tw.css and
91
+ // app.css is processed by Tailwind CLI into dist/client/bosia-tw-<hash>.css and
92
92
  // loaded via <link> tag in HTML. User layouts often `import "../app.css"`
93
93
  // for IDE/Tailwind tooling — bundle as JS no-op so Bun doesn't emit a
94
94
  // CSS chunk per dynamic-imported route (identical content → output
@@ -1,12 +1,10 @@
1
1
  import { parse, compile } from "svelte/compiler";
2
2
  import MagicString from "magic-string";
3
- import { basename, relative } from "node:path";
3
+ 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
7
 
8
- const VIRTUAL_NS = "bosia-inspector-css";
9
-
10
8
  type AnyNode = {
11
9
  type?: string;
12
10
  name?: string;
@@ -124,7 +122,6 @@ const fnv = (s: string): string => {
124
122
  export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPlugin {
125
123
  const { cwd, target, dev } = opts;
126
124
  const generate: "client" | "server" = target === "browser" ? "client" : "server";
127
- const virtualCss = new Map<string, string>();
128
125
 
129
126
  return {
130
127
  name: "bosia-inspector",
@@ -139,7 +136,11 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
139
136
  generate,
140
137
  dev,
141
138
  hmr: dev,
142
- css: "external",
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",
143
144
  preserveWhitespace: dev,
144
145
  preserveComments: dev,
145
146
  cssHash: ({ css }) => `svelte-${fnv(css)}`,
@@ -164,39 +165,9 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
164
165
  svelteMapCache.set(args.path, m);
165
166
  }
166
167
 
167
- let js = dev ? fixBindShadow(result.js.code) : result.js.code;
168
- // Inject component <style> blocks via runtime JS, not CSS chunks.
169
- // Bun's `splitting: true` names CSS chunks after the importing JS
170
- // chunk's `[name]`, not the virtual module's uid — so when several
171
- // routes (e.g. multiple `+page.svelte`) transitively import the same
172
- // styled component, each route emits its own CSS sidecar named
173
- // `+page-<contentHash>.css`. Identical content → identical hash →
174
- // "Multiple files share the same output path". Runtime injection
175
- // avoids CSS chunking entirely.
176
- if (result.css?.code?.trim() && generate !== "server") {
177
- const safeBase = basename(args.path).replace(/\./g, "_");
178
- const uid = `${safeBase}-${fnv(args.path)}-style.css`;
179
- const virtualName = `${VIRTUAL_NS}:${uid}`;
180
- virtualCss.set(virtualName, result.css.code);
181
- js += `\nimport ${JSON.stringify(virtualName)};`;
182
- }
183
-
168
+ const js = dev ? fixBindShadow(result.js.code) : result.js.code;
184
169
  return { contents: js, loader: "ts" };
185
170
  });
186
-
187
- build.onResolve({ filter: new RegExp(`^${VIRTUAL_NS}:`) }, (args) => ({
188
- path: args.path,
189
- namespace: VIRTUAL_NS,
190
- }));
191
-
192
- build.onLoad({ filter: /.*/, namespace: VIRTUAL_NS }, (args) => {
193
- const css = virtualCss.get(args.path) ?? "";
194
- virtualCss.delete(args.path);
195
- const contents = css
196
- ? `(()=>{const s=document.createElement('style');s.textContent=${JSON.stringify(css)};document.head.appendChild(s);})();`
197
- : "";
198
- return { contents, loader: "js" };
199
- });
200
171
  },
201
172
  };
202
173
  }
@@ -300,8 +300,9 @@ export function generateStaticSite(): void {
300
300
 
301
301
  // Mirror `public/` → `dist/static/` on every build (not only SSG builds) so
302
302
  // production containers can ship dist/ alone. Without this, apps with zero
303
- // prerendered routes (pure SSR) would lose bosia-tw.css and favicons when
304
- // public/ is dropped from the image.
303
+ // prerendered routes (pure SSR) would lose favicons and other public assets
304
+ // when public/ is dropped from the image. (bosia-tw-<hash>.css lives in
305
+ // dist/client/ and is served from the client walk, no mirror needed.)
305
306
  if (!hasPublic && !hasPrerender) {
306
307
  console.log("\n⏭️ No public/ or prerendered pages — skipping static site output");
307
308
  return;
@@ -310,7 +311,7 @@ export function generateStaticSite(): void {
310
311
  console.log("\n📦 Generating static site...");
311
312
  mkdirSync(`${OUT_DIR}/static`, { recursive: true });
312
313
 
313
- // 1. Public assets (bosia-tw.css, favicon, etc.) — preserves /bosia-tw.css path
314
+ // 1. Public assets (favicon, etc.) — preserves absolute /... paths
314
315
  if (hasPublic) {
315
316
  cpSync("./public", `${OUT_DIR}/static`, { recursive: true });
316
317
  }
@@ -1141,7 +1141,10 @@ app.listen(PORT, () => {
1141
1141
  `\n🔑 Response cache tells users apart ONLY by these cookies/headers: [${CACHE_KEYS.join(", ")}]\n` +
1142
1142
  ` Using a different session cookie or auth header? Add its name to CACHE_KEYS,\n` +
1143
1143
  ` or one user's personalised page can be served to another. Routes personalised\n` +
1144
- ` by anything else should set \`export const cache = false\`.\n`,
1144
+ ` by anything else should set \`export const cache = false\`.\n` +
1145
+ ` Note: the runtime auto-warns only on uncovered *cookie* reads — it CANNOT\n` +
1146
+ ` detect header-based personalisation, so custom auth headers (X-Api-Key,\n` +
1147
+ ` X-Auth-Token, …) must be added to CACHE_KEYS by hand.\n`,
1145
1148
  );
1146
1149
  }
1147
1150
  });
@@ -0,0 +1,18 @@
1
+ import { readFileSync, renameSync } from "fs";
2
+ import { join, dirname } from "path";
3
+
4
+ /** Temp filename Tailwind CLI writes to before the content-hash rename. */
5
+ export const TW_TEMP_BASENAME = ".bosia-tw.build.css";
6
+
7
+ /**
8
+ * Content-hash the compiled Tailwind CSS and rename it to its final
9
+ * `bosia-tw-<hash>.css` name (matches staticManifest's HASHED_BASENAME rule,
10
+ * so it gets immutable caching). Returns the final basename.
11
+ */
12
+ export function finalizeTailwindCss(tempPath: string): string {
13
+ const bytes = readFileSync(tempPath);
14
+ const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 10);
15
+ const name = `bosia-tw-${hash}.css`;
16
+ renameSync(tempPath, join(dirname(tempPath), name));
17
+ return name;
18
+ }