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 +1 -1
- package/src/core/build.ts +18 -4
- package/src/core/dev.ts +48 -11
- package/src/core/devWatch.ts +28 -0
package/package.json
CHANGED
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(
|
|
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: {
|
|
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
|
-
//
|
|
285
|
-
|
|
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
|
|
559
|
-
|
|
597
|
+
changed ??= path;
|
|
598
|
+
if (affectsRouteManifest(path, ROUTES_DIR)) routesDirty = true;
|
|
560
599
|
}
|
|
561
600
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
}
|