bosia 0.8.8 → 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/cli/create.ts +24 -15
- 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/cli/create.ts
CHANGED
|
@@ -20,16 +20,21 @@ const TEMPLATE_DESCRIPTIONS: Record<string, string> = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export async function runCreate(name: string | undefined, args: string[] = []) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
// "." or omitted → scaffold into the current directory
|
|
24
|
+
const inCurrentDir = !name || name === ".";
|
|
25
|
+
const targetDir = inCurrentDir ? process.cwd() : resolve(process.cwd(), name);
|
|
26
|
+
// package.json / placeholder name: cwd basename when scaffolding in place
|
|
27
|
+
const projectName = inCurrentDir ? basename(targetDir) : name!;
|
|
28
|
+
|
|
29
|
+
// allowlist covers the two files that routinely sit in an otherwise empty
|
|
30
|
+
// scaffold target (git init'd, macOS). Widen if users hit others.
|
|
31
|
+
const IGNORE = new Set([".git", ".DS_Store"]);
|
|
30
32
|
if (existsSync(targetDir)) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
const entries = readdirSync(targetDir).filter((e) => !IGNORE.has(e));
|
|
34
|
+
if (entries.length > 0) {
|
|
35
|
+
console.error(`❌ Directory is not empty: ${targetDir}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
// Parse --template flag (supports `--template foo` and `--template=foo`)
|
|
@@ -77,15 +82,15 @@ export async function runCreate(name: string | undefined, args: string[] = []) {
|
|
|
77
82
|
// `BOSIA_BUILDING_PREBUILT` is set by the artifact generator so it scaffolds
|
|
78
83
|
// via the registry instead of trying to download the asset it's producing.
|
|
79
84
|
if (config?.prebuilt === true && !isLocal && !process.env.BOSIA_BUILDING_PREBUILT) {
|
|
80
|
-
const ok = await scaffoldFromPrebuilt(template, targetDir,
|
|
85
|
+
const ok = await scaffoldFromPrebuilt(template, targetDir, projectName);
|
|
81
86
|
if (ok) {
|
|
82
|
-
await finishCreate(targetDir,
|
|
87
|
+
await finishCreate(targetDir, projectName, templateDir, skipInstall, inCurrentDir);
|
|
83
88
|
return;
|
|
84
89
|
}
|
|
85
90
|
console.log("⚠️ Prebuilt artifact unavailable — installing from registry instead.\n");
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
copyDir(templateDir, targetDir,
|
|
93
|
+
copyDir(templateDir, targetDir, projectName, isLocal);
|
|
89
94
|
|
|
90
95
|
if (existsSync(join(targetDir, ".env.example"))) {
|
|
91
96
|
writeFileSync(join(targetDir, ".env"), readFileSync(join(targetDir, ".env.example"), "utf-8"));
|
|
@@ -116,7 +121,7 @@ export async function runCreate(name: string | undefined, args: string[] = []) {
|
|
|
116
121
|
}
|
|
117
122
|
}
|
|
118
123
|
|
|
119
|
-
await finishCreate(targetDir,
|
|
124
|
+
await finishCreate(targetDir, projectName, templateDir, skipInstall, inCurrentDir);
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
// ─── Shared finish: optional `bun install` + printed instructions ──────────
|
|
@@ -125,7 +130,10 @@ async function finishCreate(
|
|
|
125
130
|
name: string,
|
|
126
131
|
templateDir: string,
|
|
127
132
|
skipInstall: boolean,
|
|
133
|
+
inCurrentDir = false,
|
|
128
134
|
) {
|
|
135
|
+
// Already inside the project dir → no `cd` step to show
|
|
136
|
+
const cd = inCurrentDir ? "" : `cd ${name} && `;
|
|
129
137
|
const printInstructions = () => {
|
|
130
138
|
const instPath = join(templateDir, "instructions.txt");
|
|
131
139
|
if (existsSync(instPath)) {
|
|
@@ -137,7 +145,7 @@ async function finishCreate(
|
|
|
137
145
|
console.log(`\n✅ Project created at ${targetDir}\n`);
|
|
138
146
|
|
|
139
147
|
if (skipInstall) {
|
|
140
|
-
console.log(`Skipped \`bun install\` (--no-install).\n\
|
|
148
|
+
console.log(`Skipped \`bun install\` (--no-install).\n\n${cd}bun install\n`);
|
|
141
149
|
printInstructions();
|
|
142
150
|
return;
|
|
143
151
|
}
|
|
@@ -152,7 +160,8 @@ async function finishCreate(
|
|
|
152
160
|
if (exitCode !== 0) {
|
|
153
161
|
console.warn("⚠️ bun install failed — run it manually.");
|
|
154
162
|
} else {
|
|
155
|
-
console.log(`\n🎉 Ready!\n
|
|
163
|
+
console.log(`\n🎉 Ready!\n`);
|
|
164
|
+
if (!inCurrentDir) console.log(`cd ${name}`);
|
|
156
165
|
printInstructions();
|
|
157
166
|
console.log(`bun x bosia dev\n`);
|
|
158
167
|
}
|
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
|
+
}
|