bosia 0.8.9 → 0.8.10

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.9",
3
+ "version": "0.8.10",
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/core/build.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { writeFileSync, rmSync, mkdirSync, existsSync } from "fs";
1
+ import { writeFileSync, readFileSync, rmSync, mkdirSync, existsSync } from "fs";
2
2
  import { join, relative } from "path";
3
+ import type { RouteManifest } from "./types.ts";
3
4
 
4
5
  import { scanRoutes } from "./scanner.ts";
5
6
  import { generateRoutesFile } from "./routeFile.ts";
@@ -50,6 +51,17 @@ const envMode = isProduction ? "production" : "development";
50
51
  const envVars = loadEnv(envMode);
51
52
  const classifiedEnv = classifyEnvVars(envVars);
52
53
 
54
+ // 0b-pre. Dev fast path: when the dev watcher knows no route file changed
55
+ // (BOSIA_SKIP_ROUTE_SCAN=1), reuse the previous build's route manifest instead
56
+ // of re-walking src/routes. Read before the cleanup below deletes it. Missing
57
+ // or corrupt (e.g. the previous build failed before writing it) → real scan.
58
+ let cachedManifest: RouteManifest | null = null;
59
+ if (process.env.BOSIA_SKIP_ROUTE_SCAN === "1") {
60
+ try {
61
+ cachedManifest = JSON.parse(readFileSync(join(OUT_DIR, "route-manifest.json"), "utf-8"));
62
+ } catch {}
63
+ }
64
+
53
65
  // 0b. Clean generated output. Only OUT_DIR (this build's artifacts) and the
54
66
  // codegen files inside .bosia/ that this build owns. A blanket wipe of .bosia/
55
67
  // would clobber a concurrently-running `bosia dev` whose compiled server lives
@@ -70,10 +82,12 @@ for (const p of [
70
82
  } catch {}
71
83
  }
72
84
 
73
- // 1. Scan routes
74
- const manifest = scanRoutes();
85
+ // 1. Scan routes (or reuse the cached manifest — see 0b-pre)
86
+ const manifest = cachedManifest ?? scanRoutes();
75
87
  buildCtx.manifest = manifest;
76
- console.log(`📂 Found ${manifest.pages.length} page route(s):`);
88
+ console.log(
89
+ `📂 Found ${manifest.pages.length} page route(s)${cachedManifest ? " (cached scan)" : ""}:`,
90
+ );
77
91
  for (const r of manifest.pages) {
78
92
  console.log(` ${r.pattern} → ${r.page}${r.pageServer ? " (server)" : ""}`);
79
93
  }
package/src/core/dev.ts CHANGED
@@ -3,6 +3,7 @@ import { readdirSync, statSync, watch, type Dirent } from "fs";
3
3
  import { join } from "path";
4
4
  import { loadEnv, resetDeclaredKeys } from "./env.ts";
5
5
  import { BOSIA_NODE_PATH } from "./paths.ts";
6
+ import { affectsRouteManifest, shouldIgnoreForRebuild } from "./devWatch.ts";
6
7
 
7
8
  // Dev always writes to .bosia/dev so a parallel `bun run build` (writing to ./dist)
8
9
  // can't clobber the live preview. Hardcoded — BOSIA_OUT_DIR is a build-mode knob,
@@ -122,13 +123,26 @@ const STARTING_PAGE = `<!doctype html>
122
123
 
123
124
  const BUILD_SCRIPT = join(import.meta.dir, "build.ts");
124
125
 
126
+ // True when a change since the last build may have altered the route manifest
127
+ // (any `+` file or directory under src/routes — see devWatch.ts). While false,
128
+ // build.ts reuses the previous route-manifest.json instead of re-walking
129
+ // src/routes. Starts true so the first build always scans.
130
+ let routesDirty = true;
131
+
125
132
  async function runBuild(): Promise<boolean> {
126
133
  console.log("🏗️ Building...");
134
+ // Managed mode has no watcher, so we never know what changed — always scan.
135
+ const skipScan = !MANAGED && !routesDirty;
136
+ routesDirty = false;
127
137
  const proc = spawn(["bun", "run", BUILD_SCRIPT], {
128
138
  stdout: "inherit",
129
139
  stderr: "inherit",
130
140
  cwd: process.cwd(),
131
- env: { ...process.env, BOSIA_OUT_DIR: DEV_OUT_DIR },
141
+ env: {
142
+ ...process.env,
143
+ BOSIA_OUT_DIR: DEV_OUT_DIR,
144
+ BOSIA_SKIP_ROUTE_SCAN: skipScan ? "1" : "0",
145
+ },
132
146
  });
133
147
  return (await proc.exited) === 0;
134
148
  }
@@ -281,8 +295,10 @@ async function buildAndRestart(): Promise<boolean> {
281
295
  return false;
282
296
  }
283
297
  await startAppServer();
284
- // Give the app server a moment to bind its port
285
- await Bun.sleep(200);
298
+ // Wait until the app server actually answers before telling browsers
299
+ // to reload — a fixed sleep was too slow for fast binds and too short
300
+ // for slow ones.
301
+ await waitForAppHealthy();
286
302
  broadcastReload();
287
303
  } while (buildPending);
288
304
  return ok;
@@ -291,6 +307,20 @@ async function buildAndRestart(): Promise<boolean> {
291
307
  }
292
308
  }
293
309
 
310
+ // Poll /_health until the freshly-spawned app server answers. 127.0.0.1, not
311
+ // "localhost" — same IPv6-vs-IPv4 pin as the proxy below. On timeout we give
312
+ // up and broadcast anyway; the STARTING_PAGE retry loop covers stragglers.
313
+ async function waitForAppHealthy(timeoutMs = 10_000): Promise<void> {
314
+ const deadline = Date.now() + timeoutMs;
315
+ while (Date.now() < deadline) {
316
+ try {
317
+ const res = await fetch(`http://127.0.0.1:${APP_PORT}/_health`);
318
+ if (res.ok) return;
319
+ } catch {}
320
+ await Bun.sleep(50);
321
+ }
322
+ }
323
+
294
324
  function scheduleBuild() {
295
325
  if (buildTimer) clearTimeout(buildTimer);
296
326
  buildTimer = setTimeout(buildAndRestart, 300);
@@ -488,6 +518,7 @@ function isGenerated(path: string): boolean {
488
518
  // scheduleBuild() for an edit that was already handled.
489
519
 
490
520
  const SRC_DIR = join(process.cwd(), "src");
521
+ const ROUTES_DIR = join(SRC_DIR, "routes");
491
522
  const MTIME_POLL_MS = 5_000;
492
523
  const mtimes = new Map<string, number>();
493
524
 
@@ -503,6 +534,8 @@ if (!MANAGED) {
503
534
  if (!filename) return;
504
535
  const abs = join(process.cwd(), "src", filename);
505
536
  if (isGenerated(abs)) return;
537
+ if (shouldIgnoreForRebuild(abs)) return;
538
+ if (affectsRouteManifest(abs, ROUTES_DIR)) routesDirty = true;
506
539
  console.log(`[watch] changed: ${filename}`);
507
540
  try {
508
541
  mtimes.set(abs, statSync(abs).mtimeMs);
@@ -533,6 +566,9 @@ function walkSrc(out: Map<string, number>): void {
533
566
  continue;
534
567
  }
535
568
  if (!ent.isFile()) continue;
569
+ // Keep ignored files out of the mtime map entirely, mirroring the
570
+ // fs.watch filter — the poll then can't fire on them either.
571
+ if (shouldIgnoreForRebuild(abs)) continue;
536
572
  try {
537
573
  out.set(abs, statSync(abs).mtimeMs);
538
574
  } catch {
@@ -552,19 +588,20 @@ if (!MANAGED) {
552
588
 
553
589
  let changed: string | null = null;
554
590
 
591
+ // One sweep can absorb several files (agents write in batches) and the
592
+ // whole map is replaced below, so every diff — not just the first —
593
+ // must be checked against the route manifest.
555
594
  for (const [path, ts] of fresh) {
556
595
  const prev = mtimes.get(path);
557
596
  if (prev === undefined || prev !== ts) {
558
- changed = path;
559
- break;
597
+ changed ??= path;
598
+ if (affectsRouteManifest(path, ROUTES_DIR)) routesDirty = true;
560
599
  }
561
600
  }
562
- if (!changed) {
563
- for (const path of mtimes.keys()) {
564
- if (!fresh.has(path)) {
565
- changed = path;
566
- break;
567
- }
601
+ for (const path of mtimes.keys()) {
602
+ if (!fresh.has(path)) {
603
+ changed ??= path;
604
+ if (affectsRouteManifest(path, ROUTES_DIR)) routesDirty = true;
568
605
  }
569
606
  }
570
607
 
@@ -0,0 +1,28 @@
1
+ import { basename, sep } from "path";
2
+
3
+ // ─── Dev Watcher Classification ──────────────────────────
4
+ // Pure helpers for dev.ts (kept separate so they're testable — dev.ts is a
5
+ // side-effecting script that starts servers on import).
6
+
7
+ // Changes that can never affect the built app: docs, tests, editor droppings.
8
+ const IGNORED_RE = /\.(md|markdown)$|\.(test|spec)\.[jt]sx?$|(~|\.swp|\.swo|\.tmp)$/i;
9
+
10
+ export function shouldIgnoreForRebuild(absPath: string): boolean {
11
+ const name = basename(absPath);
12
+ return name === ".DS_Store" || IGNORED_RE.test(name);
13
+ }
14
+
15
+ /**
16
+ * True when a change at `absPath` can alter the route manifest. The scanner
17
+ * only reads `+`-prefixed files and directory structure under src/routes, so
18
+ * everything else (colocated components, lib files) can reuse the cached
19
+ * manifest. A basename without a dot is treated as a directory — group/param
20
+ * folder renames must rescan, and the path may already be gone so we can't
21
+ * stat. False negatives are impossible by construction; false positives just
22
+ * cost one extra scan.
23
+ */
24
+ export function affectsRouteManifest(absPath: string, routesDir: string): boolean {
25
+ if (absPath !== routesDir && !absPath.startsWith(routesDir + sep)) return false;
26
+ const name = basename(absPath);
27
+ return name.startsWith("+") || !name.includes(".");
28
+ }