bosia 0.8.4 → 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/package.json +1 -1
- package/src/core/build.ts +12 -3
- package/src/core/cache.ts +12 -6
- 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/server.ts +4 -1
- package/src/core/twHash.ts +18 -0
package/package.json
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
|
@@ -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
|
|
262
|
-
|
|
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) {
|
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
|
}
|
package/src/core/server.ts
CHANGED
|
@@ -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
|
+
}
|