bosia 0.8.1 → 0.8.3

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.1",
3
+ "version": "0.8.3",
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/cli/dev.ts CHANGED
@@ -8,5 +8,9 @@ export async function runDev() {
8
8
  stderr: "inherit",
9
9
  cwd: process.cwd(),
10
10
  });
11
+ // Survive ^C so we keep waiting for the child instead of orphaning it
12
+ // mid-shutdown. The terminal delivers SIGINT to the whole process group,
13
+ // so the child already gets the signal — no forwarding needed.
14
+ for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => {});
11
15
  await proc.exited;
12
16
  }
package/src/cli/start.ts CHANGED
@@ -22,5 +22,9 @@ export async function runStart() {
22
22
  },
23
23
  });
24
24
 
25
+ // Survive ^C so we keep waiting for the child instead of orphaning it
26
+ // mid-drain. The terminal delivers SIGINT to the whole process group,
27
+ // so the child already gets the signal — no forwarding needed.
28
+ for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => {});
25
29
  await proc.exited;
26
30
  }
package/src/cli/sync.ts CHANGED
@@ -3,6 +3,7 @@ import { generateRoutesFile } from "../core/routeFile.ts";
3
3
  import { generateRouteTypes, ensureRootDirs } from "../core/routeTypes.ts";
4
4
  import { loadEnv, classifyEnvVars } from "../core/env.ts";
5
5
  import { generateEnvModules } from "../core/envCodegen.ts";
6
+ import { findBrandPlaceholders, BRAND_SENTINEL } from "../core/brandGuard.ts";
6
7
 
7
8
  export async function runSync() {
8
9
  const envMode = process.env.NODE_ENV === "production" ? "production" : "development";
@@ -13,4 +14,14 @@ export async function runSync() {
13
14
  ensureRootDirs();
14
15
  generateEnvModules(classifiedEnv);
15
16
  console.log("✅ Bosia codegen ready (.bosia/routes.ts, types, env modules)");
17
+
18
+ const brandHits = findBrandPlaceholders();
19
+ if (brandHits.length > 0) {
20
+ console.error(
21
+ `\n❌ ${BRAND_SENTINEL} placeholder left un-replaced in ${brandHits.length} spot(s):`,
22
+ );
23
+ for (const { file, line } of brandHits) console.error(` • ${file}:${line}`);
24
+ console.error(`\nReplace ${BRAND_SENTINEL} with the app's name (nav/header/footer).\n`);
25
+ process.exit(1);
26
+ }
16
27
  }
@@ -0,0 +1,36 @@
1
+ import { readdirSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ // Registry blocks (navbars, storefront header/footer) ship the brand name as the
5
+ // literal `__BRAND__` sentinel so it's unmistakably a placeholder. This guard fails
6
+ // `bosia sync` (and therefore `bun run check`) if any survive un-replaced in an app —
7
+ // otherwise a fresh scaffold ships someone else's brand in its nav/header/footer.
8
+ export const BRAND_SENTINEL = "__BRAND__";
9
+
10
+ const SRC_DIR = "./src";
11
+ const TEXT_EXT = /\.(svelte|ts|js|html|md|json)$/;
12
+
13
+ export function findBrandPlaceholders(srcDir = SRC_DIR): { file: string; line: number }[] {
14
+ const hits: { file: string; line: number }[] = [];
15
+ let entries: string[];
16
+ try {
17
+ entries = readdirSync(srcDir, { recursive: true }) as string[];
18
+ } catch {
19
+ return hits; // no src/ (e.g. non-app cwd) → nothing to guard
20
+ }
21
+ for (const rel of entries) {
22
+ if (!TEXT_EXT.test(rel)) continue;
23
+ const path = join(srcDir, rel);
24
+ let contents: string;
25
+ try {
26
+ contents = readFileSync(path, "utf-8");
27
+ } catch {
28
+ continue; // directory or unreadable
29
+ }
30
+ if (!contents.includes(BRAND_SENTINEL)) continue;
31
+ contents.split("\n").forEach((l, i) => {
32
+ if (l.includes(BRAND_SENTINEL)) hits.push({ file: path, line: i + 1 });
33
+ });
34
+ }
35
+ return hits;
36
+ }
package/src/core/dev.ts CHANGED
@@ -17,6 +17,13 @@ const SHELL_ENV_SNAPSHOT: Record<string, string | undefined> = { ...process.env
17
17
 
18
18
  loadEnv("development");
19
19
 
20
+ // Host-managed mode: when a host (rukoku) sets BOSIA_DEV_MANAGED=1 in the unit
21
+ // env, this dev server never self-triggers builds on file change — the host is
22
+ // the single clock and drives one rebuild per turn via POST /__bosia/rebuild
23
+ // (avoids the watcher compiling a half-written file mid-edit). Unset → normal
24
+ // `bosia dev` is byte-for-byte unchanged.
25
+ const MANAGED = process.env.BOSIA_DEV_MANAGED === "1";
26
+
20
27
  function reloadEnv() {
21
28
  for (const k of Object.keys(process.env)) delete process.env[k];
22
29
  for (const [k, v] of Object.entries(SHELL_ENV_SNAPSHOT)) {
@@ -216,25 +223,30 @@ let buildTimer: ReturnType<typeof setTimeout> | null = null;
216
223
  let building = false;
217
224
  let buildPending = false;
218
225
 
219
- async function buildAndRestart() {
226
+ async function buildAndRestart(): Promise<boolean> {
220
227
  if (building) {
221
228
  buildPending = true;
222
- return;
229
+ // ponytail: coalesced into the in-flight build. Managed mode fires one
230
+ // rebuild per turn so overlap is rare; report optimistic rather than a
231
+ // false build failure to the host.
232
+ return true;
223
233
  }
224
234
  building = true;
225
235
  try {
236
+ let ok = true;
226
237
  do {
227
238
  buildPending = false;
228
- const ok = await runBuild();
239
+ ok = await runBuild();
229
240
  if (!ok) {
230
241
  console.error("❌ Build failed — fix errors and save again");
231
- return;
242
+ return false;
232
243
  }
233
244
  await startAppServer();
234
245
  // Give the app server a moment to bind its port
235
246
  await Bun.sleep(200);
236
247
  broadcastReload();
237
248
  } while (buildPending);
249
+ return ok;
238
250
  } finally {
239
251
  building = false;
240
252
  }
@@ -331,6 +343,17 @@ const devServer = Bun.serve({
331
343
  return Response.json({ ok: true, held: false, flushed });
332
344
  }
333
345
 
346
+ // Host-managed rebuild trigger. In managed mode the watcher is off, so the
347
+ // host (rukoku) POSTs here once per turn after all file writes are done.
348
+ // Reuses the single-flight building/buildPending guards. The POST awaits the
349
+ // full build; `{ ok }` is the real build result (compiler errors stream to
350
+ // stdout/stderr → journald, which the host tails on ok:false). Exists in
351
+ // both modes; simply unused when the watcher is on.
352
+ if (url.pathname === "/__bosia/rebuild" && req.method === "POST") {
353
+ const ok = await buildAndRestart();
354
+ return Response.json({ ok });
355
+ }
356
+
334
357
  // Proxy everything else to the app server. Inject X-Forwarded-Host/Proto so
335
358
  // the app's CSRF origin check (gated behind TRUST_PROXY=true, also set in the
336
359
  // app env above) reconstructs the public-facing origin from the dev proxy
@@ -426,18 +449,27 @@ const SRC_DIR = join(process.cwd(), "src");
426
449
  const MTIME_POLL_MS = 5_000;
427
450
  const mtimes = new Map<string, number>();
428
451
 
429
- const srcWatcher = watch(join(process.cwd(), "src"), { recursive: true }, (_event, filename) => {
430
- if (!filename) return;
431
- const abs = join(process.cwd(), "src", filename);
432
- if (isGenerated(abs)) return;
433
- console.log(`[watch] changed: ${filename}`);
434
- try {
435
- mtimes.set(abs, statSync(abs).mtimeMs);
436
- } catch {
437
- mtimes.delete(abs);
438
- }
439
- scheduleBuild();
440
- });
452
+ // Managed mode: the host is the single clock, so never self-trigger on file
453
+ // change. Leave both watchers unstarted (null) — boot build + startAppServer
454
+ // still run, so the server builds once and serves; it just waits for the host's
455
+ // POST /__bosia/rebuild instead of watching.
456
+ let srcWatcher: ReturnType<typeof watch> | null = null;
457
+ let mtimePoll: ReturnType<typeof setInterval> | null = null;
458
+
459
+ if (!MANAGED) {
460
+ srcWatcher = watch(join(process.cwd(), "src"), { recursive: true }, (_event, filename) => {
461
+ if (!filename) return;
462
+ const abs = join(process.cwd(), "src", filename);
463
+ if (isGenerated(abs)) return;
464
+ console.log(`[watch] changed: ${filename}`);
465
+ try {
466
+ mtimes.set(abs, statSync(abs).mtimeMs);
467
+ } catch {
468
+ mtimes.delete(abs);
469
+ }
470
+ scheduleBuild();
471
+ });
472
+ }
441
473
 
442
474
  function walkSrc(out: Map<string, number>): void {
443
475
  const stack: string[] = [SRC_DIR];
@@ -471,37 +503,39 @@ function walkSrc(out: Map<string, number>): void {
471
503
  // Seed the map without firing — first sweep just records existing mtimes.
472
504
  walkSrc(mtimes);
473
505
 
474
- const mtimePoll = setInterval(() => {
475
- const fresh = new Map<string, number>();
476
- walkSrc(fresh);
506
+ if (!MANAGED) {
507
+ mtimePoll = setInterval(() => {
508
+ const fresh = new Map<string, number>();
509
+ walkSrc(fresh);
477
510
 
478
- let changed: string | null = null;
511
+ let changed: string | null = null;
479
512
 
480
- for (const [path, ts] of fresh) {
481
- const prev = mtimes.get(path);
482
- if (prev === undefined || prev !== ts) {
483
- changed = path;
484
- break;
485
- }
486
- }
487
- if (!changed) {
488
- for (const path of mtimes.keys()) {
489
- if (!fresh.has(path)) {
513
+ for (const [path, ts] of fresh) {
514
+ const prev = mtimes.get(path);
515
+ if (prev === undefined || prev !== ts) {
490
516
  changed = path;
491
517
  break;
492
518
  }
493
519
  }
494
- }
520
+ if (!changed) {
521
+ for (const path of mtimes.keys()) {
522
+ if (!fresh.has(path)) {
523
+ changed = path;
524
+ break;
525
+ }
526
+ }
527
+ }
495
528
 
496
- if (changed) {
497
- const rel = changed.startsWith(SRC_DIR) ? changed.slice(SRC_DIR.length + 1) : changed;
498
- console.log(`[poll] changed: ${rel}`);
499
- mtimes.clear();
500
- for (const [p, t] of fresh) mtimes.set(p, t);
501
- scheduleBuild();
502
- }
503
- }, MTIME_POLL_MS);
504
- mtimePoll.unref?.();
529
+ if (changed) {
530
+ const rel = changed.startsWith(SRC_DIR) ? changed.slice(SRC_DIR.length + 1) : changed;
531
+ console.log(`[poll] changed: ${rel}`);
532
+ mtimes.clear();
533
+ for (const [p, t] of fresh) mtimes.set(p, t);
534
+ scheduleBuild();
535
+ }
536
+ }, MTIME_POLL_MS);
537
+ mtimePoll.unref?.();
538
+ }
505
539
 
506
540
  // ─── .env Watcher ─────────────────────────────────────────
507
541
  // Reset to shell-env snapshot and re-run loadEnv so removed/renamed
@@ -517,7 +551,11 @@ const envWatcher = watch(process.cwd(), { recursive: false }, (_event, filename)
517
551
  scheduleBuild();
518
552
  });
519
553
 
520
- console.log("👀 Watching src/ for changes...\n");
554
+ console.log(
555
+ MANAGED
556
+ ? "🛰️ Host-managed mode — builds driven by POST /__bosia/rebuild\n"
557
+ : "👀 Watching src/ for changes...\n",
558
+ );
521
559
 
522
560
  // ─── Shutdown ─────────────────────────────────────────────
523
561
  // Own SIGINT/SIGTERM so we can cleanly stop the child app server.
@@ -526,16 +564,23 @@ console.log("👀 Watching src/ for changes...\n");
526
564
  // a second ^C.
527
565
 
528
566
  let shuttingDown = false;
567
+ let firstSignalAt = 0;
529
568
  async function shutdown() {
530
- if (shuttingDown) return; // re-entry from process-group signals or impatient ^C — drain is already running
569
+ if (shuttingDown) {
570
+ // One ^C arrives multiple times (process group + `bun run` forwarding
571
+ // to its child) — only a genuinely later signal is a second ^C.
572
+ if (Date.now() - firstSignalAt > 200) process.exit(130); // second ^C = force quit
573
+ return;
574
+ }
531
575
  shuttingDown = true;
576
+ firstSignalAt = Date.now();
532
577
  intentionalKill = true;
533
578
 
534
579
  if (buildTimer) clearTimeout(buildTimer);
535
580
  if (restartTimer) clearTimeout(restartTimer);
536
581
  if (healthyTimer) clearTimeout(healthyTimer);
537
- clearInterval(mtimePoll);
538
- srcWatcher.close();
582
+ if (mtimePoll) clearInterval(mtimePoll);
583
+ srcWatcher?.close();
539
584
  envWatcher.close();
540
585
  devServer.stop(true); // closes SSE conns → abort listeners clear ping intervals
541
586
 
@@ -544,9 +589,8 @@ async function shutdown() {
544
589
  await Promise.race([appProcess.exited, Bun.sleep(2_500)]);
545
590
  }
546
591
 
547
- // Safety net: if any stray handle still holds the loop, force clean exit.
548
- // .unref() so the timer itself doesn't keep the loop alive when drain succeeds.
549
- setTimeout(() => process.exit(0), 1_500).unref();
592
+ // Everything is stopped exit now rather than waiting for the loop to drain.
593
+ process.exit(0);
550
594
  }
551
595
 
552
596
  process.on("SIGINT", shutdown);
@@ -978,6 +978,7 @@ if (Number.isFinite(MAX_INFLIGHT)) {
978
978
  // ─── Graceful Shutdown State ──────────────────────────────
979
979
 
980
980
  let shuttingDown = false;
981
+ let firstSignalAt = 0;
981
982
  let inFlight = 0;
982
983
  let drainResolve: (() => void) | null = null;
983
984
 
@@ -1097,8 +1098,16 @@ app.listen(PORT, () => {
1097
1098
  });
1098
1099
 
1099
1100
  async function shutdown() {
1100
- if (shuttingDown) return;
1101
+ if (shuttingDown) {
1102
+ // One ^C arrives multiple times (process group + `bun run` forwarding
1103
+ // to its child) — only a genuinely later signal is a second ^C.
1104
+ if (Date.now() - firstSignalAt > 200) process.exit(130); // second ^C = force quit
1105
+ return;
1106
+ }
1101
1107
  shuttingDown = true;
1108
+ firstSignalAt = Date.now();
1109
+ // Dev: nothing worth draining — exit instantly so ^C feels immediate.
1110
+ if (isDev) process.exit(0);
1102
1111
  console.log("⏳ Shutting down — draining in-flight requests...");
1103
1112
 
1104
1113
  if (inFlight > 0) {