bosia 0.8.3 → 0.8.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/README.md CHANGED
@@ -18,6 +18,20 @@ File-based routing inspired by SvelteKit, built on top of the Bun runtime and El
18
18
  - **Dev server with HMR** — file watcher + SSE browser reload, no page blink
19
19
  - **Tailwind CSS v4** — compiled at build time, shadcn-inspired design tokens out of the box
20
20
  - **CLI** — `bosia create`, `bosia dev`, `bosia build`, `bosia add`, `bosia feat`
21
+ - **UI registry** — shadcn-style, copy-paste source you own: **60 components**, **100+ blocks**, **10 pages**, **19 themes**
22
+
23
+ ## UI Registry
24
+
25
+ `bosia add` copies source directly into your project — no runtime package to import:
26
+
27
+ ```bash
28
+ bun x bosia@latest add button card data-table # 60 primitives
29
+ bun x bosia@latest add block heros/product # 100+ composed sections
30
+ bun x bosia@latest add page storefront/home # 10 ready-to-wire pages
31
+ bun x bosia@latest add theme editorial # 19 token themes
32
+ ```
33
+
34
+ See the [registry docs](https://bosia.dev) for the full catalog.
21
35
 
22
36
  ## Quick Start
23
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.8.3",
3
+ "version": "0.8.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": [
package/src/ambient.d.ts CHANGED
@@ -20,7 +20,6 @@ declare module "bosia:routes" {
20
20
  pageServer: Loader | null;
21
21
  layoutServers: { loader: Loader; depth: number }[];
22
22
  trailingSlash: TrailingSlash;
23
- scope: "public" | "private";
24
23
  }>;
25
24
 
26
25
  export const apiRoutes: Array<{
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
@@ -5,7 +5,9 @@
5
5
  //
6
6
  // See docs/guides/response-cache.md.
7
7
 
8
+ import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
8
9
  import type { Cookies, LoaderDeps } from "./hooks.ts";
10
+ import type { CookieJar } from "./cookies.ts";
9
11
  import { dedupKey } from "./dedup.ts";
10
12
 
11
13
  // ─── Config ──────────────────────────────────────────────
@@ -28,22 +30,31 @@ function parseMaxEntries(raw: string | undefined): number {
28
30
  return n;
29
31
  }
30
32
 
31
- // `invalidate` / `invalidateAll` are re-exported from the public `bosia`
32
- // barrel, so this module also evaluates in the browser bundle. Guard every
33
- // `process.env` read — otherwise hydration throws `ReferenceError: Can't
34
- // find variable: process` (Safari) the moment the barrel is imported.
33
+ function parseMaxBodyBytes(raw: string | undefined): number {
34
+ const DEFAULT = 2_097_152; // 2MB; 0 = unlimited
35
+ if (!raw) return DEFAULT;
36
+ const n = parseInt(raw, 10);
37
+ if (!Number.isFinite(n) || n < 0) return DEFAULT;
38
+ return n;
39
+ }
40
+
41
+ // Server-only module (imported by core/renderer.ts, core/server.ts,
42
+ // lib/server.ts — never the client barrel; client `invalidate` lives in
43
+ // core/client/navigation.ts). The `process` guards below are kept as
44
+ // defense in case a future refactor pulls this into a browser bundle.
35
45
  const env: Record<string, string | undefined> =
36
46
  typeof process !== "undefined" && process.env ? process.env : {};
37
47
  const isServer = typeof process !== "undefined";
38
48
 
39
49
  export const CACHE_KEYS: readonly string[] = parseCacheKeys(env.CACHE_KEYS);
40
50
  export const CACHE_MAX_ENTRIES = parseMaxEntries(env.CACHE_MAX_ENTRIES);
51
+ export const CACHE_MAX_BODY_BYTES = parseMaxBodyBytes(env.CACHE_MAX_BODY_BYTES);
41
52
  export const CACHE_ENABLED = CACHE_MAX_ENTRIES > 0;
42
53
 
43
54
  if (isServer) {
44
55
  if (CACHE_ENABLED) {
45
56
  console.log(
46
- `💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, identity keys [${CACHE_KEYS.join(", ")}]`,
57
+ `💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, max body ${CACHE_MAX_BODY_BYTES === 0 ? "unlimited" : `${CACHE_MAX_BODY_BYTES} bytes`}, identity keys [${CACHE_KEYS.join(", ")}]`,
47
58
  );
48
59
  } else {
49
60
  console.log("💾 Response cache: disabled (CACHE_MAX_ENTRIES=0)");
@@ -77,14 +88,15 @@ class LRU<K, V> {
77
88
  this.map.set(key, v);
78
89
  return v;
79
90
  }
80
- set(key: K, value: V): K | undefined {
91
+ set(key: K, value: V): { key: K; value: V } | undefined {
81
92
  if (this.map.has(key)) this.map.delete(key);
82
93
  this.map.set(key, value);
83
94
  if (this.map.size > this.cap) {
84
95
  const oldest = this.map.keys().next().value as K | undefined;
85
96
  if (oldest !== undefined) {
97
+ const evicted = this.map.get(oldest) as V;
86
98
  this.map.delete(oldest);
87
- return oldest;
99
+ return { key: oldest, value: evicted };
88
100
  }
89
101
  }
90
102
  return undefined;
@@ -111,31 +123,28 @@ const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
111
123
 
112
124
  // ─── Key building ────────────────────────────────────────
113
125
 
114
- /** FNV-1a 32-bit hash. Compact, no dep, collision-tolerant for an identity bucket. */
115
- function fnv1a(s: string): string {
116
- let h = 0x811c9dc5;
117
- for (let i = 0; i < s.length; i++) {
118
- h ^= s.charCodeAt(i);
119
- h = Math.imul(h, 0x01000193);
120
- }
121
- return (h >>> 0).toString(36);
126
+ /** SHA-256 truncated to 64 bits identity buckets must not collide across users. */
127
+ function identityDigest(s: string): string {
128
+ return new Bun.CryptoHasher("sha256").update(s).digest("hex").slice(0, 16);
122
129
  }
123
130
 
124
- export function computeIdentityHash(req: Request, cookies: Cookies): string {
131
+ export function computeIdentityHash(req: Request, cookies: Pick<CookieJar, "peek">): string {
125
132
  const headers = req.headers;
126
133
  const parts: string[] = [];
127
134
  for (const name of CACHE_KEYS) {
128
- const cv = cookies.get(name);
135
+ // peek, not get — building the identity must not count as a cookie read
136
+ // (get flips `accessed`, which forces Cache-Control: private downstream).
137
+ const cv = cookies.peek(name);
129
138
  if (cv) parts.push(`c:${name}=${cv}`);
130
139
  const hv = headers.get(name);
131
140
  if (hv) parts.push(`h:${name}=${hv}`);
132
141
  }
133
142
  if (parts.length === 0) return "0";
134
143
  parts.sort();
135
- return fnv1a(parts.join("&"));
144
+ return identityDigest(parts.join("&"));
136
145
  }
137
146
 
138
- export function computeCacheKey(url: URL, req: Request, cookies: Cookies): string {
147
+ export function computeCacheKey(url: URL, req: Request, cookies: Pick<CookieJar, "peek">): string {
139
148
  return `${dedupKey(url)}|i=${computeIdentityHash(req, cookies)}`;
140
149
  }
141
150
 
@@ -175,12 +184,41 @@ export function cacheGet(key: string): CacheEntry | undefined {
175
184
  return htmlCache.get(key);
176
185
  }
177
186
 
178
- export function cacheSet(key: string, entry: CacheEntry): void {
187
+ // ─── Uncovered-cookie warning ────────────────────────────
188
+ // The identity hash only sees cookies named in CACHE_KEYS. If a loader read a
189
+ // cookie outside that list and the response got cached anyway, the response
190
+ // may be personalised on something the cache key can't distinguish — users
191
+ // could be served each other's pages. Warn loudly (dev AND prod), once per
192
+ // cookie name per process.
193
+
194
+ const warnedUncoveredCookies = new Set<string>();
195
+
196
+ export function warnUncoveredCookies(cookies: Cookies): void {
197
+ const readNames = (cookies as { readNames?: ReadonlySet<string> }).readNames;
198
+ if (!readNames) return;
199
+ for (const name of readNames) {
200
+ if (CACHE_KEYS.includes(name) || warnedUncoveredCookies.has(name)) continue;
201
+ warnedUncoveredCookies.add(name);
202
+ console.warn(
203
+ `\n🚨 [bosia] SECURITY WARNING — possible cross-user cache leak 🚨\n` +
204
+ ` A cached response's loader read the cookie "${name}", which is NOT in CACHE_KEYS.\n` +
205
+ ` The cache key cannot tell users apart by this cookie: if the page content\n` +
206
+ ` depends on it, one user's page can be served to another.\n` +
207
+ ` Fix (pick one):\n` +
208
+ ` - Add it to the identity key list: CACHE_KEYS=${[...CACHE_KEYS, name].join(",")}\n` +
209
+ ` - Or opt the route out of caching: export const cache = false\n`,
210
+ );
211
+ }
212
+ }
213
+
214
+ export function cacheSet(key: string, entry: CacheEntry, cookies?: Cookies): void {
179
215
  if (!CACHE_ENABLED) return;
216
+ if (CACHE_MAX_BODY_BYTES > 0 && entry.raw.length > CACHE_MAX_BODY_BYTES) return;
217
+ if (cookies) warnUncoveredCookies(cookies);
180
218
  // Drop any existing entry's index pointers first
181
219
  cacheDeleteKey(key);
182
220
  const evicted = htmlCache.set(key, entry);
183
- if (evicted) cacheDeleteIndexOnly(evicted);
221
+ if (evicted) cacheDeleteIndexOnly(evicted.key, evicted.value);
184
222
  for (const tag of entry.tags) {
185
223
  let set = tagIndex.get(tag);
186
224
  if (!set) {
@@ -220,9 +258,14 @@ function cacheDeleteKey(key: string): void {
220
258
  }
221
259
 
222
260
  /** Cleanup index pointers for a key after LRU evicted it. */
223
- function cacheDeleteIndexOnly(key: string): void {
224
- for (const set of tagIndex.values()) set.delete(key);
225
- 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
+ }
226
269
  const path = pathOfKey(key);
227
270
  const pset = pathIndex.get(path);
228
271
  if (pset) {
@@ -247,16 +290,16 @@ export function buildCompressedVariants(body: Bytes): {
247
290
  } catch {
248
291
  gzip = null;
249
292
  }
250
- // brotliCompressSync exists in Bun >= 1.1.5 but is missing from older
251
- // @types/bun shipments cast through any so the call stays loose.
252
- const brotliFn = (Bun as unknown as { brotliCompressSync?: (b: Bytes) => Uint8Array })
253
- .brotliCompressSync;
254
- if (brotliFn) {
255
- try {
256
- brotli = brotliFn(body) as Bytes;
257
- } catch {
258
- brotli = null;
259
- }
293
+ try {
294
+ // Quality 5: ~20x faster than the default 11, still beats gzip on size.
295
+ // This runs sync in a microtask 11 would block the event loop ~17ms/500KB.
296
+ brotli = new Uint8Array(
297
+ brotliCompressSync(body, {
298
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 },
299
+ }),
300
+ ) as Bytes;
301
+ } catch {
302
+ brotli = null;
260
303
  }
261
304
  return { gzip, brotli };
262
305
  }
@@ -274,6 +317,35 @@ export function concatChunks(chunks: Uint8Array[]): Bytes {
274
317
  return out;
275
318
  }
276
319
 
320
+ // ─── Miss coalescing ─────────────────────────────────────
321
+ // Stampede protection for cache misses. The first miss on a key becomes the
322
+ // leader (gets `release`); concurrent misses become waiters (get `wait`).
323
+ // Waiters re-check cacheGet after the wait resolves — hit: serve it; miss
324
+ // (leader skipped the write): build independently, no re-leadering. Distinct
325
+ // from dedup(), which shares the *result*; this shares only the *wait*.
326
+ //
327
+ // INVARIANT: the leader must call release() exactly once, after its cacheSet
328
+ // attempt completed or was skipped. A missed release() hangs waiters for the
329
+ // process lifetime — callers guard with try/finally.
330
+
331
+ const missGates = new Map<string, { promise: Promise<void>; release: () => void }>();
332
+
333
+ export function coalesceMiss(
334
+ key: string,
335
+ ): { release: () => void; wait?: undefined } | { wait: Promise<void>; release?: undefined } {
336
+ const existing = missGates.get(key);
337
+ if (existing) return { wait: existing.promise };
338
+ let resolve!: () => void;
339
+ const promise = new Promise<void>((r) => (resolve = r));
340
+ const release = () => {
341
+ // Idempotent, and never deletes a successor leader's gate.
342
+ if (missGates.get(key)?.promise === promise) missGates.delete(key);
343
+ resolve();
344
+ };
345
+ missGates.set(key, { promise, release });
346
+ return { release };
347
+ }
348
+
277
349
  // ─── Serve a cache hit ───────────────────────────────────
278
350
 
279
351
  export function serveCached(entry: CacheEntry, req: Request): Response {
@@ -58,6 +58,7 @@ export class CookieJar implements Cookies {
58
58
  private _outgoing: string[] = [];
59
59
  private _defaults: CookieOptions;
60
60
  private _accessed = false;
61
+ private _readNames = new Set<string>();
61
62
  private _isHttps: boolean;
62
63
 
63
64
  constructor(cookieHeader: string, isHttps = false) {
@@ -70,11 +71,23 @@ export class CookieJar implements Cookies {
70
71
 
71
72
  get(name: string): string | undefined {
72
73
  this._accessed = true;
74
+ if (name in this._incoming) this._readNames.add(name);
75
+ return this._incoming[name];
76
+ }
77
+
78
+ /**
79
+ * Read an incoming cookie WITHOUT flipping `accessed` or recording the name
80
+ * in `readNames`. Framework-internal (identity hashing must not count as a
81
+ * cookie read); deliberately absent from the public `Cookies` interface so
82
+ * loaders can't bypass dependency tracking.
83
+ */
84
+ peek(name: string): string | undefined {
73
85
  return this._incoming[name];
74
86
  }
75
87
 
76
88
  getAll(): Record<string, string> {
77
89
  this._accessed = true;
90
+ for (const name of Object.keys(this._incoming)) this._readNames.add(name);
78
91
  return { ...this._incoming };
79
92
  }
80
93
 
@@ -82,6 +95,11 @@ export class CookieJar implements Cookies {
82
95
  return this._accessed;
83
96
  }
84
97
 
98
+ /** Names of incoming cookies that were actually read (present in the request). */
99
+ get readNames(): ReadonlySet<string> {
100
+ return this._readNames;
101
+ }
102
+
85
103
  set(name: string, value: string, options?: CookieOptions): void {
86
104
  if (!VALID_COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
87
105
  const opts = { ...this._defaults, ...options };
package/src/core/dedup.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // ─── Request Deduplication ──────────────────────────────
2
- // Concurrent in-flight coalescing for public routes. When N parallel requests
3
- // hit the same URL, run the loader once and share the resolved value across
4
- // all N waiters. Settled responses are NOT cached — once the promise resolves,
5
- // the entry is dropped, so the next request runs the loader again.
2
+ // Concurrent in-flight coalescing. When N parallel requests hit the same URL,
3
+ // run the loader once and share the resolved value across all N waiters.
4
+ // Settled responses are NOT cached — once the promise resolves, the entry is
5
+ // dropped, so the next request runs the loader again.
6
6
  //
7
- // Scope decision lives in the scanner: routes under a `(private)` group skip
8
- // dedup entirely. Per-user routes MUST be private sharing a loader result
9
- // across users would leak data. See docs/guides/request-deduplication.md.
7
+ // The caller's key includes the CACHE_KEYS identity hash (see cache.ts), so
8
+ // different users never share a loader result. Custom session cookie names
9
+ // must be listed in CACHE_KEYS. See docs/guides/request-deduplication.md.
10
10
 
11
11
  const inflight = new Map<string, Promise<unknown>>();
12
12
 
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
@@ -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
  }