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 +1 -1
- package/src/cli/dev.ts +4 -0
- package/src/cli/start.ts +4 -0
- package/src/cli/sync.ts +11 -0
- package/src/core/brandGuard.ts +36 -0
- package/src/core/dev.ts +91 -47
- package/src/core/server.ts +10 -1
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
506
|
+
if (!MANAGED) {
|
|
507
|
+
mtimePoll = setInterval(() => {
|
|
508
|
+
const fresh = new Map<string, number>();
|
|
509
|
+
walkSrc(fresh);
|
|
477
510
|
|
|
478
|
-
|
|
511
|
+
let changed: string | null = null;
|
|
479
512
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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(
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
548
|
-
|
|
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);
|
package/src/core/server.ts
CHANGED
|
@@ -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)
|
|
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) {
|