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 +14 -0
- package/package.json +1 -1
- package/src/ambient.d.ts +0 -1
- package/src/core/build.ts +12 -3
- package/src/core/cache.ts +106 -34
- package/src/core/cookies.ts +18 -0
- package/src/core/dedup.ts +7 -7
- package/src/core/dev.ts +52 -4
- package/src/core/html.ts +13 -5
- package/src/core/plugin.ts +2 -2
- package/src/core/prerender.ts +4 -3
- package/src/core/renderer.ts +247 -208
- package/src/core/routeFile.ts +0 -2
- package/src/core/scanner.ts +1 -7
- package/src/core/server.ts +96 -44
- package/src/core/staticManifest.ts +35 -1
- package/src/core/twHash.ts +18 -0
- package/src/core/types.ts +0 -6
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
package/src/ambient.d.ts
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
/**
|
|
115
|
-
function
|
|
116
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
144
|
+
return identityDigest(parts.join("&"));
|
|
136
145
|
}
|
|
137
146
|
|
|
138
|
-
export function computeCacheKey(url: URL, req: Request, cookies:
|
|
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
|
-
|
|
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
|
|
225
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 {
|
package/src/core/cookies.ts
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
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")
|
|
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([
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
);
|
package/src/core/plugin.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/core/prerender.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
314
|
+
// 1. Public assets (favicon, etc.) — preserves absolute /... paths
|
|
314
315
|
if (hasPublic) {
|
|
315
316
|
cpSync("./public", `${OUT_DIR}/static`, { recursive: true });
|
|
316
317
|
}
|