bosia 0.9.6 → 0.9.8

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.9.6",
3
+ "version": "0.9.8",
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/create.ts CHANGED
@@ -5,6 +5,7 @@ import { spawn } from "bun";
5
5
  import * as p from "@clack/prompts";
6
6
  import { installFeature, initFeatRegistry, resolveLocalRegistry } from "./feat.ts";
7
7
  import { initAddRegistry } from "./add.ts";
8
+ import { toPosix } from "../core/paths.ts";
8
9
 
9
10
  // ─── bun x bosia@latest create <name> [--template <name>] ─
10
11
 
@@ -283,7 +284,7 @@ function copyDir(src: string, dest: string, projectName: string, isLocal: boolea
283
284
 
284
285
  if (entry.name === "package.json" && isLocal) {
285
286
  const bosiaPath = resolve(import.meta.dir, "../../");
286
- const relPath = relative(dest, bosiaPath);
287
+ const relPath = toPosix(relative(dest, bosiaPath));
287
288
  content = content.replaceAll('"^{{BOSIA_VERSION}}"', `"file:${relPath}"`);
288
289
  } else {
289
290
  content = content.replaceAll("{{BOSIA_VERSION}}", BOSIA_VERSION);
@@ -21,7 +21,7 @@ export interface InstallOptions {
21
21
  // ─── Local registry resolution ────────────────────────────
22
22
 
23
23
  export function resolveLocalRegistry(): string {
24
- let dir = dirname(new URL(import.meta.url).pathname);
24
+ let dir = import.meta.dir;
25
25
  for (let i = 0; i < 10; i++) {
26
26
  const candidate = join(dir, "registry");
27
27
  if (existsSync(join(candidate, "index.json"))) return candidate;
package/src/cli/sync.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { scanRoutes } from "../core/scanner.ts";
1
+ import { scanRoutes, RouteConflictError } from "../core/scanner.ts";
2
2
  import { generateRoutesFile } from "../core/routeFile.ts";
3
3
  import { generateRouteTypes, ensureRootDirs } from "../core/routeTypes.ts";
4
4
  import { loadEnv, classifyEnvVars } from "../core/env.ts";
@@ -8,7 +8,14 @@ import { findBrandPlaceholders, BRAND_SENTINEL } from "../core/brandGuard.ts";
8
8
  export async function runSync() {
9
9
  const envMode = process.env.NODE_ENV === "production" ? "production" : "development";
10
10
  const classifiedEnv = classifyEnvVars(loadEnv(envMode));
11
- const manifest = scanRoutes();
11
+ let manifest;
12
+ try {
13
+ manifest = scanRoutes();
14
+ } catch (err) {
15
+ if (!(err instanceof RouteConflictError)) throw err;
16
+ console.error(`❌ ${err.message}`);
17
+ process.exit(1);
18
+ }
12
19
  generateRoutesFile(manifest);
13
20
  generateRouteTypes(manifest);
14
21
  ensureRootDirs();
package/src/core/build.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { writeFileSync, readFileSync, rmSync, mkdirSync, existsSync } from "fs";
2
- import { join, relative } from "path";
2
+ import { basename, join, relative } from "path";
3
3
  import type { RouteManifest } from "./types.ts";
4
4
 
5
- import { scanRoutes } from "./scanner.ts";
5
+ import { scanRoutes, RouteConflictError } from "./scanner.ts";
6
6
  import { generateRoutesFile } from "./routeFile.ts";
7
7
  import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
8
8
  import { makeBosiaPlugin } from "./plugin.ts";
@@ -11,7 +11,7 @@ import { finalizeComponentCss } from "./componentCss.ts";
11
11
  import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
12
12
  import { loadEnv, classifyEnvVars } from "./env.ts";
13
13
  import { generateEnvModules } from "./envCodegen.ts";
14
- import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin } from "./paths.ts";
14
+ import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin, toPosix } from "./paths.ts";
15
15
  import { currentBase } from "./appBase.ts";
16
16
  import { finalizeTailwindCss, TW_TEMP_BASENAME } from "./twHash.ts";
17
17
  import { loadPlugins } from "./config.ts";
@@ -85,7 +85,14 @@ for (const p of [
85
85
  }
86
86
 
87
87
  // 1. Scan routes (or reuse the cached manifest — see 0b-pre)
88
- const manifest = cachedManifest ?? scanRoutes();
88
+ let manifest: RouteManifest;
89
+ try {
90
+ manifest = cachedManifest ?? scanRoutes();
91
+ } catch (err) {
92
+ if (!(err instanceof RouteConflictError)) throw err;
93
+ console.error(`❌ ${err.message}`);
94
+ process.exit(1);
95
+ }
89
96
  buildCtx.manifest = manifest;
90
97
  console.log(
91
98
  `📂 Found ${manifest.pages.length} page route(s)${cachedManifest ? " (cached scan)" : ""}:`,
@@ -251,7 +258,7 @@ const cssFiles: string[] = [];
251
258
  // `output.kind === "entry-point"` instead so we pin the actual entry.
252
259
  let clientEntry: string | null = null;
253
260
  for (const output of clientResult.outputs) {
254
- const rel = relative(`${OUT_DIR}/client`, output.path);
261
+ const rel = toPosix(relative(`${OUT_DIR}/client`, output.path)); // URL path, not fs path
255
262
  if (output.path.endsWith(".js")) jsFiles.push(rel);
256
263
  if (output.path.endsWith(".css")) cssFiles.push(rel);
257
264
  if ((output as { kind?: string }).kind === "entry-point" && output.path.endsWith(".js")) {
@@ -271,11 +278,8 @@ if (componentCssFile) {
271
278
  }
272
279
 
273
280
  // Entry is always "index.js" due to naming: { entry: "index.[ext]" }
274
- const serverEntry =
275
- serverResult.outputs
276
- .find((o) => o.path.endsWith("index.js"))
277
- ?.path.split("/")
278
- .pop() ?? "index.js";
281
+ const serverEntryOutput = serverResult.outputs.find((o) => o.path.endsWith("index.js"));
282
+ const serverEntry = serverEntryOutput ? basename(serverEntryOutput.path) : "index.js";
279
283
 
280
284
  // 8. Write dist/manifest.json
281
285
  mkdirSync(OUT_DIR, { recursive: true });
package/src/core/paths.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { join, dirname } from "path";
1
+ import { join, dirname, delimiter } from "path";
2
2
  import { existsSync } from "fs";
3
3
 
4
4
  // This file lives at src/core/paths.ts → package root is ../..
@@ -26,18 +26,31 @@ const ANCESTOR_NM = collectAncestorNodeModules(dirname(BOSIA_PKG_DIR));
26
26
  const ALL_NM = [NESTED_NM, ...ANCESTOR_NM];
27
27
 
28
28
  /** NODE_PATH value covering nested and every ancestor node_modules */
29
- export const BOSIA_NODE_PATH = ALL_NM.join(":");
29
+ export const BOSIA_NODE_PATH = ALL_NM.join(delimiter); // ";" on Windows, ":" elsewhere
30
30
 
31
31
  // On-disk output directory. URL namespace (/dist/client/...) stays stable;
32
32
  // only the on-disk location moves so dev (.bosia/dev) and a parallel
33
33
  // `bun run build` (./dist) don't clobber each other.
34
34
  export const OUT_DIR = process.env.BOSIA_OUT_DIR ?? "./dist";
35
35
 
36
+ /** Normalize a filesystem path to forward slashes (for URLs, manifests, imports). */
37
+ export function toPosix(p: string): string {
38
+ return p.replace(/\\/g, "/");
39
+ }
40
+
41
+ /** `.bin` file names to try for `name`. Windows shims carry an extension. */
42
+ export function binCandidates(name: string, platform: string = process.platform): string[] {
43
+ return platform === "win32" ? [`${name}.exe`, `${name}.cmd`, `${name}.bunx`, name] : [name];
44
+ }
45
+
36
46
  /** Find a binary from bosia's dependencies (handles hoisting) */
37
47
  export function resolveBosiaBin(name: string): string {
48
+ const candidates = binCandidates(name);
38
49
  for (const nm of ALL_NM) {
39
- const bin = join(nm, ".bin", name);
40
- if (existsSync(bin)) return bin;
50
+ for (const file of candidates) {
51
+ const bin = join(nm, ".bin", file);
52
+ if (existsSync(bin)) return bin;
53
+ }
41
54
  }
42
- return join(NESTED_NM, ".bin", name); // fallback — will produce a clear ENOENT
55
+ return join(NESTED_NM, ".bin", candidates[0]); // fallback — will produce a clear ENOENT
43
56
  }
@@ -4,6 +4,7 @@ import { relative } from "node:path";
4
4
  import type { BunPlugin } from "bun";
5
5
  import { svelteMapCache } from "../../svelteCompiler.ts";
6
6
  import { lineColFromOffset } from "../../sourceLoc.ts";
7
+ import { toPosix } from "../../paths.ts";
7
8
  import { collectComponentCss } from "../../componentCss.ts";
8
9
 
9
10
  type AnyNode = {
@@ -129,7 +130,7 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
129
130
  setup(build) {
130
131
  build.onLoad({ filter: /\.svelte$/ }, async (args) => {
131
132
  const source = await Bun.file(args.path).text();
132
- const rel = relative(cwd, args.path);
133
+ const rel = toPosix(relative(cwd, args.path));
133
134
  const transformed = injectLocs(source, rel);
134
135
 
135
136
  const result = compile(transformed, {
@@ -1,7 +1,7 @@
1
1
  import { TraceMap, originalPositionFor, GREATEST_LOWER_BOUND } from "@jridgewell/trace-mapping";
2
2
  import { readFileSync, existsSync } from "node:fs";
3
- import { dirname, resolve as pathResolve } from "node:path";
4
- import { OUT_DIR } from "../../paths.ts";
3
+ import { dirname, isAbsolute, relative, resolve as pathResolve } from "node:path";
4
+ import { OUT_DIR, toPosix } from "../../paths.ts";
5
5
 
6
6
  const cache = new Map<string, TraceMap | null>();
7
7
 
@@ -64,7 +64,7 @@ function mapPathFor(file: string): string | null {
64
64
  ? OUT_DIR + pathname.slice("/dist".length)
65
65
  : "." + pathname;
66
66
  fsPath = pathResolve(process.cwd(), relFromCwd);
67
- } else if (file.startsWith("/")) {
67
+ } else if (isAbsolute(file)) {
68
68
  fsPath = file;
69
69
  } else {
70
70
  fsPath = pathResolve(process.cwd(), file);
@@ -109,16 +109,18 @@ export function resolveFrame(
109
109
  }
110
110
  if (refined.source && refined.line != null) {
111
111
  const refinedAbs = pathResolve(dirname(abs), refined.source);
112
- const rel = refinedAbs.startsWith(process.cwd() + "/")
113
- ? refinedAbs.slice(process.cwd().length + 1)
114
- : refinedAbs;
115
- return { file: rel, line: refined.line, col: refined.column ?? 1 };
112
+ return { file: relToCwd(refinedAbs), line: refined.line, col: refined.column ?? 1 };
116
113
  }
117
114
  }
118
115
  }
119
116
 
120
- const rel = abs.startsWith(process.cwd() + "/") ? abs.slice(process.cwd().length + 1) : abs;
121
- return { file: rel, line: pos.line, col: pos.column ?? 1 };
117
+ return { file: relToCwd(abs), line: pos.line, col: pos.column ?? 1 };
118
+ }
119
+
120
+ // cwd-relative "/"-separated path when `abs` is inside cwd, else `abs` unchanged.
121
+ function relToCwd(abs: string): string {
122
+ const rel = relative(process.cwd(), abs);
123
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosix(rel) : abs;
122
124
  }
123
125
 
124
126
  // Rewrite frames in stack strings: "(url:L:C)", "at url:L:C", "@url:L:C".
package/src/core/port.ts CHANGED
@@ -1,16 +1,38 @@
1
- /** PIDs listening on `port`. Fails open to [] — lsof may be absent. */
1
+ /** PIDs listening on `port`. Fails open to [] — lsof / netstat may be absent. */
2
2
  export async function pidsOnPort(port: number): Promise<number[]> {
3
+ const isWindows = process.platform === "win32";
3
4
  try {
4
- const proc = Bun.spawn(["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"], {
5
- stdout: "pipe",
6
- stderr: "ignore",
7
- });
5
+ const cmd = isWindows ? ["netstat", "-ano"] : ["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"];
6
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" });
8
7
  const out = await new Response(proc.stdout).text();
9
8
  await proc.exited;
10
- return [...new Set(out.split("\n").map((s) => s.trim()))]
11
- .map(Number)
12
- .filter((pid) => Number.isInteger(pid) && pid > 0);
9
+ return isWindows ? parseNetstat(out, port) : parseLsof(out);
13
10
  } catch {
14
11
  return [];
15
12
  }
16
13
  }
14
+
15
+ /** `lsof -t` output: one PID per line. */
16
+ export function parseLsof(out: string): number[] {
17
+ return uniquePids(out.split("\n").map((s) => s.trim()));
18
+ }
19
+
20
+ /**
21
+ * `netstat -ano` (Windows) rows: `TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 1234`.
22
+ * The state column is localized on non-English Windows, so a listener is
23
+ * detected by its foreign address ending in `:0` instead of the word LISTENING.
24
+ */
25
+ export function parseNetstat(out: string, port: number): number[] {
26
+ const pids: string[] = [];
27
+ for (const line of out.split(/\r?\n/)) {
28
+ const cols = line.trim().split(/\s+/);
29
+ if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue;
30
+ const [, local, foreign] = cols;
31
+ if (local.endsWith(`:${port}`) && foreign.endsWith(":0")) pids.push(cols[cols.length - 1]);
32
+ }
33
+ return uniquePids(pids);
34
+ }
35
+
36
+ function uniquePids(raw: string[]): number[] {
37
+ return [...new Set(raw)].map(Number).filter((pid) => Number.isInteger(pid) && pid > 0);
38
+ }
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, mkdirSync } from "fs";
2
2
  import type { RouteManifest } from "./types.ts";
3
3
  import { currentBase } from "./appBase.ts";
4
+ import { toPosix } from "./paths.ts";
4
5
 
5
6
  /**
6
7
  * The pattern the *client* router matches against. It sees real browser URLs, so
@@ -250,5 +251,5 @@ function generateClientRoutesFile(
250
251
 
251
252
  // Import path from .bosia/routes.ts to src/routes/<routePath>
252
253
  function toImportPath(routePath: string): string {
253
- return "../src/routes/" + routePath.replace(/\\/g, "/");
254
+ return "../src/routes/" + toPosix(routePath);
254
255
  }
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
2
2
  import { join } from "path";
3
3
  import type { RouteManifest } from "./types.ts";
4
+ import { toPosix } from "./paths.ts";
4
5
 
5
6
  // ─── Route Types Generator ────────────────────────────────
6
7
  // Generates .bosia/types/src/routes/**/$types.d.ts for each
@@ -9,7 +10,7 @@ import type { RouteManifest } from "./types.ts";
9
10
  // work in +page.svelte files — identical to SvelteKit's API.
10
11
 
11
12
  function routeDirOf(filePath: string): string {
12
- const parts = filePath.replace(/\\/g, "/").split("/");
13
+ const parts = toPosix(filePath).split("/");
13
14
  parts.pop();
14
15
  return parts.join("/") || ".";
15
16
  }
@@ -1,4 +1,4 @@
1
- import { join, resolve as resolvePath } from "path";
1
+ import { join, resolve as resolvePath, sep } from "path";
2
2
 
3
3
  /**
4
4
  * Resolve `untrusted` relative to `base` and verify the result stays inside
@@ -10,5 +10,5 @@ import { join, resolve as resolvePath } from "path";
10
10
  export function safePath(base: string, untrusted: string): string | null {
11
11
  const root = resolvePath(base);
12
12
  const full = resolvePath(join(base, untrusted));
13
- return full.startsWith(root + "/") || full === root ? full : null;
13
+ return full.startsWith(root + sep) || full === root ? full : null;
14
14
  }
@@ -70,6 +70,10 @@ export function scanRoutes(): RouteManifest {
70
70
  const fullDir = join(ROUTES_DIR, dir);
71
71
  if (!existsSync(fullDir)) return;
72
72
 
73
+ // Manifest paths are always "/"-separated (they become import specifiers),
74
+ // so build them by hand — path.join would emit "\" on Windows.
75
+ const rel = (name: string) => (dir ? `${dir}/${name}` : name);
76
+
73
77
  const items = readdirSync(fullDir, { withFileTypes: true });
74
78
 
75
79
  // Accumulate layouts for this level
@@ -81,14 +85,14 @@ export function scanRoutes(): RouteManifest {
81
85
  // as the layout chain. Without this a section with 40 routes needed 40
82
86
  // identical +loading.svelte files to cover its navigations.
83
87
  const currentLoading = items.some((i) => i.isFile() && i.name === "+loading.svelte")
84
- ? join(dir, "+loading.svelte")
88
+ ? rel("+loading.svelte")
85
89
  : inheritedLoading;
86
90
 
87
91
  if (items.some((i) => i.isFile() && i.name === "+layout.svelte")) {
88
- currentLayouts.push(join(dir, "+layout.svelte"));
92
+ currentLayouts.push(rel("+layout.svelte"));
89
93
  }
90
94
  if (items.some((i) => i.isFile() && i.name === "+layout.server.ts")) {
91
- const layoutServerPath = join(dir, "+layout.server.ts");
95
+ const layoutServerPath = rel("+layout.server.ts");
92
96
  currentLayoutServers.push({
93
97
  path: layoutServerPath,
94
98
  depth: currentLayouts.length - 1,
@@ -100,7 +104,7 @@ export function scanRoutes(): RouteManifest {
100
104
  // depth = number of layouts wrapping this dir (this dir's layout included).
101
105
  // An error page at depth K renders inside layouts[0..K-1].
102
106
  currentErrorPages.push({
103
- path: join(dir, "+error.svelte"),
107
+ path: rel("+error.svelte"),
104
108
  depth: currentLayouts.length,
105
109
  });
106
110
  }
@@ -109,20 +113,20 @@ export function scanRoutes(): RouteManifest {
109
113
  if (items.some((i) => i.isFile() && i.name === "+server.ts")) {
110
114
  apis.push({
111
115
  pattern: toUrlPath(urlSegments),
112
- server: join(dir, "+server.ts"),
116
+ server: rel("+server.ts"),
113
117
  });
114
118
  }
115
119
 
116
120
  // Page route (+page.svelte)
117
121
  if (items.some((i) => i.isFile() && i.name === "+page.svelte")) {
118
122
  const pageServerFile = items.some((i) => i.isFile() && i.name === "+page.server.ts")
119
- ? join(dir, "+page.server.ts")
123
+ ? rel("+page.server.ts")
120
124
  : null;
121
125
 
122
126
  const pageTs = pageServerFile ? readTrailingSlash(join(ROUTES_DIR, pageServerFile)) : null;
123
127
  const effectiveTs: TrailingSlash = pageTs ?? currentTrailingSlash;
124
128
 
125
- const pageFile = join(dir, "+page.svelte");
129
+ const pageFile = rel("+page.svelte");
126
130
  pages.push({
127
131
  pattern: toUrlPath(urlSegments),
128
132
  page: pageFile,
@@ -146,7 +150,7 @@ export function scanRoutes(): RouteManifest {
146
150
  const isGroup = /^\(.*\)$/.test(dirName);
147
151
 
148
152
  walk(
149
- dir ? join(dir, dirName) : dirName,
153
+ rel(dirName),
150
154
  isGroup ? [...urlSegments] : [...urlSegments, dirName],
151
155
  currentLayouts,
152
156
  currentLayoutServers,
@@ -177,11 +181,45 @@ export function scanRoutes(): RouteManifest {
177
181
  }
178
182
  }
179
183
 
184
+ preventConflicts(pages, (p) => p.page);
185
+ preventConflicts(apis, (a) => a.server);
186
+
180
187
  const errorPage = existsSync(join(ROUTES_DIR, "+error.svelte")) ? "+error.svelte" : null;
181
188
 
182
189
  return { pages, apis, errorPage };
183
190
  }
184
191
 
192
+ /**
193
+ * Fail when two route files serve the same URL — e.g. `+page.svelte` next to
194
+ * `(public)/+page.svelte`, or `blog/[id]` next to `(app)/blog/[slug]`. Route
195
+ * groups vanish from the URL and param names don't affect matching, so one of
196
+ * the two could never be reached. Same rule as SvelteKit's prevent_conflicts
197
+ * and Next.js's "two parallel pages that resolve to the same path".
198
+ */
199
+ function preventConflicts<T extends { pattern: string }>(routes: T[], fileOf: (r: T) => string) {
200
+ const seen = new Map<string, T>();
201
+ for (const r of routes) {
202
+ const key = r.pattern.replace(/\[\.\.\.\w+\]/g, "[...]").replace(/\[\w+\]/g, "[]");
203
+ const first = seen.get(key);
204
+ if (!first) {
205
+ seen.set(key, r);
206
+ continue;
207
+ }
208
+ throw new RouteConflictError(
209
+ `The "${first.pattern}" and "${r.pattern}" routes conflict with each other:\n` +
210
+ ` src/routes/${fileOf(first)}\n` +
211
+ ` src/routes/${fileOf(r)}\n` +
212
+ ` Route groups like (public) are not part of the URL, and [id] matches the same URLs as [slug].\n` +
213
+ ` Delete or move one of them.`,
214
+ );
215
+ }
216
+ }
217
+
218
+ /** Two route files resolve to the same URL. Callers print `message` and exit. */
219
+ export class RouteConflictError extends Error {
220
+ override name = "RouteConflictError";
221
+ }
222
+
185
223
  function toUrlPath(segments: string[]): string {
186
224
  if (segments.length === 0) return "/";
187
225
  return "/" + segments.join("/");