bosia 0.6.5 → 0.6.7

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/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # Bosia
2
2
 
3
- > Full documentation: [bosia.bosapi.com](https://bosia.bosapi.com)
3
+ > Full documentation: [bosia.dev](https://bosia.dev)
4
4
 
5
5
  A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS.
6
6
 
7
+ **Production-ready out of the box** — built-in security (CSRF, XSS escaping, secure cookies, security headers), performance (response cache, gzip, static asset caching, prerendering), and reliability (graceful shutdown drain, request backpressure, crash backoff).
8
+
7
9
  File-based routing inspired by SvelteKit, built on top of the Bun runtime and ElysiaJS HTTP server. No Node.js, no Vite, no adapters.
8
10
 
9
11
  ## Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
package/src/cli/block.ts CHANGED
@@ -2,9 +2,11 @@ import { join, dirname } from "path";
2
2
  import { mkdirSync, writeFileSync, existsSync } from "fs";
3
3
  import * as p from "@clack/prompts";
4
4
  import {
5
+ type InstallOptions,
5
6
  resolveLocalRegistryOrExit,
6
7
  readRegistryJSON,
7
8
  readRegistryFile,
9
+ mergePkgJson,
8
10
  bunAdd,
9
11
  } from "./registry.ts";
10
12
  import { addComponent, initAddRegistry, ensureUtils } from "./add.ts";
@@ -26,18 +28,28 @@ interface BlockMeta {
26
28
  npmDeps: Record<string, string>;
27
29
  }
28
30
 
29
- export async function runAddBlock(name: string | undefined, flags: string[] = []) {
31
+ export async function runAddBlock(
32
+ name: string | undefined,
33
+ flags: string[] = [],
34
+ options?: InstallOptions,
35
+ ) {
30
36
  if (!name || !name.includes("/")) {
31
37
  console.error(
32
- "❌ Please provide a block path.\n Usage: bun x bosia@latest add block <category>/<name> [--local]",
38
+ "❌ Please provide a block path.\n Usage: bun x bosia@latest add block <category>/<name> [-y] [--local]",
33
39
  );
34
40
  process.exit(1);
35
41
  }
36
42
 
37
43
  const local = flags.includes("--local");
44
+ const flagYes = flags.includes("-y") || flags.includes("--yes");
38
45
  const registryRoot = local ? resolveLocalRegistryOrExit() : null;
39
46
  if (local) console.log(`⬡ Using local registry: ${registryRoot}\n`);
40
47
 
48
+ const resolvedOptions: InstallOptions = {
49
+ ...(options ?? {}),
50
+ skipPrompts: options?.skipPrompts ?? flagYes,
51
+ };
52
+
41
53
  await initAddRegistry(registryRoot);
42
54
  ensureUtils();
43
55
 
@@ -47,14 +59,14 @@ export async function runAddBlock(name: string | undefined, flags: string[] = []
47
59
 
48
60
  // 1. Install primitive dependencies first
49
61
  for (const dep of meta.dependencies ?? []) {
50
- await addComponent(dep, false);
62
+ await addComponent(dep, false, resolvedOptions);
51
63
  }
52
64
 
53
65
  // 2. Copy block files to src/lib/blocks/<path>/
54
- const cwd = process.cwd();
66
+ const cwd = resolvedOptions.cwd ?? process.cwd();
55
67
  const destDir = join(cwd, "src", "lib", "blocks", name);
56
68
 
57
- if (existsSync(destDir)) {
69
+ if (!resolvedOptions.skipPrompts && existsSync(destDir)) {
58
70
  const replace = await p.confirm({
59
71
  message: `Block "${name}" already exists at src/lib/blocks/${name}/. Replace it?`,
60
72
  });
@@ -87,7 +99,13 @@ export async function runAddBlock(name: string | undefined, flags: string[] = []
87
99
 
88
100
  // 4. npm deps
89
101
  if (meta.npmDeps && Object.keys(meta.npmDeps).length > 0) {
90
- await bunAdd(cwd, meta.npmDeps);
102
+ if (resolvedOptions.skipInstall) {
103
+ const { addedDeps } = mergePkgJson(cwd, { deps: meta.npmDeps });
104
+ if (addedDeps.length > 0)
105
+ console.log(` 📥 Added to package.json: ${addedDeps.join(", ")}`);
106
+ } else {
107
+ await bunAdd(cwd, meta.npmDeps);
108
+ }
91
109
  }
92
110
 
93
111
  console.log(`\n✅ ${name} installed at src/lib/blocks/${name}/`);
package/src/cli/feat.ts CHANGED
@@ -2,6 +2,7 @@ import { join, dirname, extname } from "path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
3
  import * as p from "@clack/prompts";
4
4
  import { addComponent, initAddRegistry } from "./add.ts";
5
+ import { runAddBlock } from "./block.ts";
5
6
  import {
6
7
  type InstallOptions,
7
8
  resolveLocalRegistryOrExit,
@@ -46,6 +47,7 @@ interface FeatureMeta {
46
47
  description: string;
47
48
  features?: string[]; // other bosia features required
48
49
  components: string[]; // bosia components to install via `bun x bosia@latest add`
50
+ blocks?: string[]; // bosia blocks to install via `bun x bosia@latest add block`
49
51
  files: FileEntry[]; // file entries with per-file strategy
50
52
  npmDeps: Record<string, string>;
51
53
  npmDevDeps?: Record<string, string>;
@@ -255,6 +257,15 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
255
257
  console.log("");
256
258
  }
257
259
 
260
+ // Install required blocks
261
+ if (meta.blocks && meta.blocks.length > 0) {
262
+ console.log("🧱 Installing required blocks...");
263
+ for (const blockName of meta.blocks) {
264
+ await runAddBlock(blockName, [], options);
265
+ }
266
+ console.log("");
267
+ }
268
+
258
269
  // Apply each file entry per its strategy. Skip entries whose `when` clause doesn't match.
259
270
  const createdDirs = new Set<string>();
260
271
  for (const entry of meta.files) {
package/src/cli/index.ts CHANGED
@@ -41,7 +41,7 @@ async function main() {
41
41
  const flags = args.filter((a) => a.startsWith("-"));
42
42
  const sub = positional[0];
43
43
  if (sub === "block") {
44
- const blockFlags = args.filter((a) => a.startsWith("--"));
44
+ const blockFlags = flags;
45
45
  const { runAddBlock } = await import("./block.ts");
46
46
  await runAddBlock(positional[1], blockFlags);
47
47
  } else if (sub === "theme") {
package/src/core/paths.ts CHANGED
@@ -4,19 +4,29 @@ import { existsSync } from "fs";
4
4
  // This file lives at src/core/paths.ts → package root is ../..
5
5
  const BOSIA_PKG_DIR = join(import.meta.dir, "..", "..");
6
6
 
7
- // Bun hoists dependencies flat, so bosia's deps may live in the parent
8
- // node_modules rather than a nested node_modules/bosia/node_modules.
9
7
  const NESTED_NM = join(BOSIA_PKG_DIR, "node_modules");
10
8
 
11
- // When installed as a dep (node_modules/bosia/), parent is node_modules/ itself.
12
- // Only include it if the package is actually inside a node_modules directory
13
- // AND the parent node_modules contains real (resolvable) packages.
14
- const parentDir = dirname(BOSIA_PKG_DIR); // node_modules/ when installed, packages/ in workspace
15
- const isInstalledAsDep = parentDir.endsWith("node_modules");
16
- const HOISTED_NM = isInstalledAsDep ? parentDir : null;
9
+ // Walk up from bosia's package dir collecting every ancestor `node_modules/`.
10
+ // Covers all install layouts: nested per-workspace, parent-of-node_modules (installed as dep),
11
+ // monorepo root (--linker=hoisted), and any intermediate hoist target.
12
+ function collectAncestorNodeModules(start: string): string[] {
13
+ const out: string[] = [];
14
+ let dir = start;
15
+ while (true) {
16
+ const nm = join(dir, "node_modules");
17
+ if (existsSync(nm)) out.push(nm);
18
+ const parent = dirname(dir);
19
+ if (parent === dir) break;
20
+ dir = parent;
21
+ }
22
+ return out;
23
+ }
24
+
25
+ const ANCESTOR_NM = collectAncestorNodeModules(dirname(BOSIA_PKG_DIR));
26
+ const ALL_NM = [NESTED_NM, ...ANCESTOR_NM];
17
27
 
18
- /** NODE_PATH value covering both nested and hoisted dependency locations */
19
- export const BOSIA_NODE_PATH = HOISTED_NM ? [NESTED_NM, HOISTED_NM].join(":") : NESTED_NM;
28
+ /** NODE_PATH value covering nested and every ancestor node_modules */
29
+ export const BOSIA_NODE_PATH = ALL_NM.join(":");
20
30
 
21
31
  // On-disk output directory. URL namespace (/dist/client/...) stays stable;
22
32
  // only the on-disk location moves so dev (.bosia/dev) and a parallel
@@ -25,11 +35,9 @@ export const OUT_DIR = process.env.BOSIA_OUT_DIR ?? "./dist";
25
35
 
26
36
  /** Find a binary from bosia's dependencies (handles hoisting) */
27
37
  export function resolveBosiaBin(name: string): string {
28
- const nested = join(NESTED_NM, ".bin", name);
29
- if (existsSync(nested)) return nested;
30
- if (HOISTED_NM) {
31
- const hoisted = join(HOISTED_NM, ".bin", name);
32
- if (existsSync(hoisted)) return hoisted;
38
+ for (const nm of ALL_NM) {
39
+ const bin = join(nm, ".bin", name);
40
+ if (existsSync(bin)) return bin;
33
41
  }
34
- return nested; // fallback — will produce a clear ENOENT
42
+ return join(NESTED_NM, ".bin", name); // fallback — will produce a clear ENOENT
35
43
  }
@@ -1,5 +1,6 @@
1
1
  import { join, dirname } from "path";
2
- import { existsSync } from "fs";
2
+
3
+ import { resolveImportPath } from "./resolveImport.ts";
3
4
 
4
5
  // ─── Bun Build Plugin ─────────────────────────────────────
5
6
  // Resolves:
@@ -7,25 +8,6 @@ import { existsSync } from "fs";
7
8
  // $env → .bosia/env.server.ts (bun) or .bosia/env.client.ts (browser)
8
9
  // $* → resolved dynamically via tsconfig.json compilerOptions.paths
9
10
 
10
- let cachedTsconfigPaths: Record<string, string[]> | null = null;
11
- async function getTsconfigPaths() {
12
- if (cachedTsconfigPaths !== null) return cachedTsconfigPaths;
13
- const tsconfigPath = join(process.cwd(), "tsconfig.json");
14
- if (!existsSync(tsconfigPath)) {
15
- cachedTsconfigPaths = {};
16
- return cachedTsconfigPaths;
17
- }
18
- try {
19
- const tsconfig = await Bun.file(tsconfigPath).json();
20
- cachedTsconfigPaths = tsconfig?.compilerOptions?.paths || {};
21
- } catch (err) {
22
- throw new Error(
23
- `tsconfig.json at ${tsconfigPath} is invalid JSON: ${(err as Error).message}. Fix the file and re-run.`,
24
- );
25
- }
26
- return cachedTsconfigPaths!;
27
- }
28
-
29
11
  export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
30
12
  return {
31
13
  name: "bosia-resolver",
@@ -54,32 +36,14 @@ export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
54
36
  build.onResolve({ filter: /^\$/ }, async (args) => {
55
37
  if (args.path === "$env") return undefined; // Handled above
56
38
 
57
- const paths = await getTsconfigPaths();
58
- let longestMatch = "";
59
- let targetPattern = "";
60
-
61
- for (const [pattern, targets] of Object.entries(paths)) {
62
- const prefix = pattern.replace(/\*$/, "");
63
- if (args.path.startsWith(prefix) && prefix.length > longestMatch.length) {
64
- longestMatch = prefix;
65
- targetPattern = (targets as string[])[0];
66
- }
67
- }
68
-
69
- if (longestMatch && targetPattern) {
70
- const suffix = args.path.slice(longestMatch.length);
71
- const targetDir = targetPattern.replace(/\*$/, "");
72
- const resolved = join(process.cwd(), targetDir, suffix);
73
- return { path: await resolveWithExts(resolved) };
74
- }
75
-
76
- // Fallback for $lib/* if not in tsconfig
77
- if (args.path.startsWith("$lib/")) {
78
- const rel = args.path.slice(5);
79
- const base = join(process.cwd(), "src", "lib", rel);
80
- return { path: await resolveWithExts(base) };
39
+ const resolved = await resolveImportPath(
40
+ args.path,
41
+ join(process.cwd(), "_"),
42
+ process.cwd(),
43
+ );
44
+ if (resolved.kind === "alias" && resolved.path) {
45
+ return { path: resolved.path };
81
46
  }
82
-
83
47
  return undefined;
84
48
  });
85
49
 
@@ -146,15 +110,3 @@ export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
146
110
  },
147
111
  };
148
112
  }
149
-
150
- async function resolveWithExts(base: string): Promise<string> {
151
- if (await Bun.file(base).exists()) return base;
152
- for (const ext of [".ts", ".svelte", ".js"]) {
153
- if (await Bun.file(base + ext).exists()) return base + ext;
154
- }
155
- for (const idx of ["index.ts", "index.svelte", "index.js"]) {
156
- const p = join(base, idx);
157
- if (await Bun.file(p).exists()) return p;
158
- }
159
- return base;
160
- }
@@ -3,6 +3,7 @@ import MagicString from "magic-string";
3
3
  import { basename, relative } from "node:path";
4
4
  import type { BunPlugin } from "bun";
5
5
  import { svelteMapCache } from "../../svelteCompiler.ts";
6
+ import { lineColFromOffset } from "../../sourceLoc.ts";
6
7
 
7
8
  const VIRTUAL_NS = "bosia-inspector-css";
8
9
 
@@ -42,20 +43,6 @@ function walk(node: unknown, visit: (n: AnyNode) => void) {
42
43
  }
43
44
  }
44
45
 
45
- function lineColFromOffset(source: string, offset: number): { line: number; col: number } {
46
- let line = 1;
47
- let col = 1;
48
- for (let i = 0; i < offset && i < source.length; i++) {
49
- if (source[i] === "\n") {
50
- line++;
51
- col = 1;
52
- } else {
53
- col++;
54
- }
55
- }
56
- return { line, col };
57
- }
58
-
59
46
  function injectLocs(source: string, relPath: string): string {
60
47
  let ast: { fragment?: AnyNode };
61
48
  try {
@@ -0,0 +1,114 @@
1
+ import { existsSync } from "fs";
2
+ import { join, dirname, resolve as pathResolve } from "path";
3
+
4
+ // ─── Shared $alias / tsconfig-paths / relative resolver ──
5
+ // Mirrors the resolution `plugin.ts` does inside Bun's `onResolve`, but as a
6
+ // plain async function callable from contexts without a Bun PluginBuilder
7
+ // (e.g. the svelte component-import audit).
8
+
9
+ let cachedTsconfigPaths: Record<string, string[]> | null = null;
10
+ let cachedTsconfigCwd: string | null = null;
11
+
12
+ async function getTsconfigPaths(cwd: string): Promise<Record<string, string[]>> {
13
+ if (cachedTsconfigPaths !== null && cachedTsconfigCwd === cwd) return cachedTsconfigPaths;
14
+ const tsconfigPath = join(cwd, "tsconfig.json");
15
+ if (!existsSync(tsconfigPath)) {
16
+ cachedTsconfigPaths = {};
17
+ cachedTsconfigCwd = cwd;
18
+ return cachedTsconfigPaths;
19
+ }
20
+ try {
21
+ const tsconfig = await Bun.file(tsconfigPath).json();
22
+ cachedTsconfigPaths = tsconfig?.compilerOptions?.paths || {};
23
+ } catch (err) {
24
+ throw new Error(
25
+ `tsconfig.json at ${tsconfigPath} is invalid JSON: ${(err as Error).message}. Fix the file and re-run.`,
26
+ );
27
+ }
28
+ cachedTsconfigCwd = cwd;
29
+ return cachedTsconfigPaths!;
30
+ }
31
+
32
+ async function resolveWithExts(base: string): Promise<string> {
33
+ if (await Bun.file(base).exists()) return base;
34
+ for (const ext of [".ts", ".svelte", ".js"]) {
35
+ if (await Bun.file(base + ext).exists()) return base + ext;
36
+ }
37
+ for (const idx of ["index.ts", "index.svelte", "index.js"]) {
38
+ const p = join(base, idx);
39
+ if (await Bun.file(p).exists()) return p;
40
+ }
41
+ return base;
42
+ }
43
+
44
+ /**
45
+ * Kind classifies why the spec resolved (or did not):
46
+ * - "alias" — `$lib/...` / `$registry/...` (tsconfig paths or $lib fallback)
47
+ * - "relative" — `./` or `../` from `fromFile`
48
+ * - "absolute" — `/abs/path`
49
+ * - "bare" — bare package specifier; not introspectable from disk
50
+ * - "virtual" — bosia-special (`$env`, `bosia:routes`); skip
51
+ */
52
+ export type ResolvedKind = "alias" | "relative" | "absolute" | "bare" | "virtual";
53
+
54
+ export interface ResolvedImport {
55
+ kind: ResolvedKind;
56
+ /** Absolute path on disk for alias/relative/absolute kinds; otherwise null. */
57
+ path: string | null;
58
+ }
59
+
60
+ export async function resolveImportPath(
61
+ spec: string,
62
+ fromFile: string,
63
+ cwd: string,
64
+ ): Promise<ResolvedImport> {
65
+ if (spec === "$env" || spec === "bosia:routes") {
66
+ return { kind: "virtual", path: null };
67
+ }
68
+
69
+ if (spec.startsWith("./") || spec.startsWith("../")) {
70
+ const abs = pathResolve(dirname(fromFile), spec);
71
+ return { kind: "relative", path: await resolveWithExts(abs) };
72
+ }
73
+
74
+ if (spec.startsWith("/")) {
75
+ return { kind: "absolute", path: await resolveWithExts(spec) };
76
+ }
77
+
78
+ if (spec.startsWith("$")) {
79
+ const paths = await getTsconfigPaths(cwd);
80
+ let longestMatch = "";
81
+ let targetPattern = "";
82
+
83
+ for (const [pattern, targets] of Object.entries(paths)) {
84
+ const prefix = pattern.replace(/\*$/, "");
85
+ if (spec.startsWith(prefix) && prefix.length > longestMatch.length) {
86
+ longestMatch = prefix;
87
+ targetPattern = (targets as string[])[0];
88
+ }
89
+ }
90
+
91
+ if (longestMatch && targetPattern) {
92
+ const suffix = spec.slice(longestMatch.length);
93
+ const targetDir = targetPattern.replace(/\*$/, "");
94
+ const resolved = join(cwd, targetDir, suffix);
95
+ return { kind: "alias", path: await resolveWithExts(resolved) };
96
+ }
97
+
98
+ if (spec.startsWith("$lib/")) {
99
+ const rel = spec.slice(5);
100
+ const base = join(cwd, "src", "lib", rel);
101
+ return { kind: "alias", path: await resolveWithExts(base) };
102
+ }
103
+
104
+ return { kind: "alias", path: null };
105
+ }
106
+
107
+ return { kind: "bare", path: null };
108
+ }
109
+
110
+ /** Test-only — drop the tsconfig cache so fixtures with fresh tsconfig.json reload. */
111
+ export function resetResolveImportCache(): void {
112
+ cachedTsconfigPaths = null;
113
+ cachedTsconfigCwd = null;
114
+ }
@@ -0,0 +1,13 @@
1
+ export function lineColFromOffset(source: string, offset: number): { line: number; col: number } {
2
+ let line = 1;
3
+ let col = 1;
4
+ for (let i = 0; i < offset && i < source.length; i++) {
5
+ if (source[i] === "\n") {
6
+ line++;
7
+ col = 1;
8
+ } else {
9
+ col++;
10
+ }
11
+ }
12
+ return { line, col };
13
+ }
@@ -0,0 +1,563 @@
1
+ import { resolveImportPath } from "./resolveImport.ts";
2
+ import { lineColFromOffset } from "./sourceLoc.ts";
3
+ import type { StrictImportsOption } from "./types/plugin.ts";
4
+
5
+ // ─── Compile-time component-import audit ─────────────────
6
+ // `bosia build` used to silently produce bundles that crashed on first SSR
7
+ // render when a `.svelte` template did `<Card.Root>` while `card/index.ts`
8
+ // exported `Card`/`CardContent` (not `Root`). Bun's bundler + svelte/compiler
9
+ // validate JS, but neither cross-checks template component identifiers
10
+ // against their imported source — the failure only surfaces at runtime as
11
+ // `undefined is not a function`.
12
+ //
13
+ // This module walks the template fragment for `<Component>` / `<X.Y>` refs,
14
+ // extracts top-level bindings from `<script>` / `<script module>`, and reports
15
+ // unbound identifiers or missing namespace members as aggregated errors —
16
+ // thrown from `onLoad`, captured into the Bun build's `result.logs`.
17
+
18
+ type AnyNode = {
19
+ type?: string;
20
+ name?: string;
21
+ start?: number;
22
+ end?: number;
23
+ [k: string]: unknown;
24
+ };
25
+
26
+ // Same set the inspector plugin walks — `key` deliberately omitted because
27
+ // KeyBlock uses `fragment`, which is already covered.
28
+ const CHILD_KEYS = [
29
+ "nodes",
30
+ "fragment",
31
+ "consequent",
32
+ "alternate",
33
+ "body",
34
+ "fallback",
35
+ "pending",
36
+ "then",
37
+ "catch",
38
+ ];
39
+
40
+ type BindingKind = "namespace-import" | "named-import" | "default-import" | "local";
41
+
42
+ interface Binding {
43
+ name: string;
44
+ kind: BindingKind;
45
+ /** Source string from the `from` clause (only for `*-import` kinds). */
46
+ source?: string;
47
+ }
48
+
49
+ interface AuditError {
50
+ kind: "unbound-identifier" | "missing-namespace-export" | "dotted-on-non-namespace" | "warning";
51
+ line: number;
52
+ col: number;
53
+ message: string;
54
+ }
55
+
56
+ export interface SvelteAuditOptions {
57
+ source: string;
58
+ filename: string;
59
+ ast: unknown;
60
+ warnings: Array<{
61
+ code?: string;
62
+ message?: string;
63
+ start?: { line?: number; column?: number };
64
+ }>;
65
+ cwd: string;
66
+ exportCache: Map<string, Set<string> | null>;
67
+ strict: StrictImportsOption;
68
+ }
69
+
70
+ function resolveStrict(opt: StrictImportsOption): {
71
+ unbound: boolean;
72
+ namespaceMember: boolean;
73
+ warnings: boolean;
74
+ } {
75
+ if (opt === false) return { unbound: false, namespaceMember: false, warnings: false };
76
+ if (opt === true || opt === undefined) {
77
+ return { unbound: true, namespaceMember: true, warnings: true };
78
+ }
79
+ return {
80
+ unbound: opt.unbound !== false,
81
+ namespaceMember: opt.namespaceMember !== false,
82
+ warnings: opt.warnings !== false,
83
+ };
84
+ }
85
+
86
+ function walk(node: unknown, visit: (n: AnyNode, parent: AnyNode | null) => boolean | void) {
87
+ const stack: Array<{ node: unknown; parent: AnyNode | null }> = [{ node, parent: null }];
88
+ while (stack.length) {
89
+ const { node: cur, parent } = stack.pop()!;
90
+ if (!cur) continue;
91
+ if (Array.isArray(cur)) {
92
+ for (const c of cur) stack.push({ node: c, parent });
93
+ continue;
94
+ }
95
+ if (typeof cur !== "object") continue;
96
+ const n = cur as AnyNode;
97
+ let descend = true;
98
+ if (typeof n.type === "string") {
99
+ const result = visit(n, parent);
100
+ if (result === false) descend = false;
101
+ }
102
+ if (!descend) continue;
103
+ for (const key of CHILD_KEYS) {
104
+ const child = n[key];
105
+ if (child) stack.push({ node: child, parent: n });
106
+ }
107
+ }
108
+ }
109
+
110
+ function extractBindings(ast: AnyNode): Binding[] {
111
+ const out: Binding[] = [];
112
+ const collectFromScript = (script: AnyNode | undefined | null) => {
113
+ if (!script || typeof script !== "object") return;
114
+ const content = script.content as AnyNode | undefined;
115
+ if (!content) return;
116
+ const body = content.body;
117
+ if (!Array.isArray(body)) return;
118
+ for (const stmt of body as AnyNode[]) {
119
+ if (!stmt || typeof stmt !== "object") continue;
120
+ switch (stmt.type) {
121
+ case "ImportDeclaration": {
122
+ const sourceNode = stmt.source as AnyNode | undefined;
123
+ const source =
124
+ sourceNode && typeof sourceNode.value === "string"
125
+ ? (sourceNode.value as string)
126
+ : "";
127
+ const specs = stmt.specifiers as AnyNode[] | undefined;
128
+ if (!Array.isArray(specs)) break;
129
+ for (const spec of specs) {
130
+ const local = spec.local as AnyNode | undefined;
131
+ const name = local && typeof local.name === "string" ? local.name : null;
132
+ if (!name) continue;
133
+ if (spec.type === "ImportNamespaceSpecifier") {
134
+ out.push({ name, kind: "namespace-import", source });
135
+ } else if (spec.type === "ImportDefaultSpecifier") {
136
+ out.push({ name, kind: "default-import", source });
137
+ } else if (spec.type === "ImportSpecifier") {
138
+ out.push({ name, kind: "named-import", source });
139
+ }
140
+ }
141
+ break;
142
+ }
143
+ case "VariableDeclaration": {
144
+ const declarations = stmt.declarations as AnyNode[] | undefined;
145
+ if (!Array.isArray(declarations)) break;
146
+ for (const d of declarations) {
147
+ const id = d.id as AnyNode | undefined;
148
+ if (id && id.type === "Identifier" && typeof id.name === "string") {
149
+ out.push({ name: id.name as string, kind: "local" });
150
+ }
151
+ }
152
+ break;
153
+ }
154
+ case "FunctionDeclaration":
155
+ case "ClassDeclaration": {
156
+ const id = stmt.id as AnyNode | undefined;
157
+ if (id && typeof id.name === "string") {
158
+ out.push({ name: id.name as string, kind: "local" });
159
+ }
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ };
165
+ collectFromScript(ast.instance as AnyNode | undefined);
166
+ collectFromScript(ast.module as AnyNode | undefined);
167
+ return out;
168
+ }
169
+
170
+ function collectShadowedNames(pattern: AnyNode | null | undefined, into: Set<string>): void {
171
+ if (!pattern || typeof pattern !== "object") return;
172
+ if (pattern.type === "Identifier" && typeof pattern.name === "string") {
173
+ into.add(pattern.name as string);
174
+ return;
175
+ }
176
+ if (pattern.type === "ArrayPattern") {
177
+ const elements = pattern.elements as AnyNode[] | undefined;
178
+ if (Array.isArray(elements)) {
179
+ for (const el of elements) collectShadowedNames(el, into);
180
+ }
181
+ return;
182
+ }
183
+ if (pattern.type === "ObjectPattern") {
184
+ const properties = pattern.properties as AnyNode[] | undefined;
185
+ if (Array.isArray(properties)) {
186
+ for (const prop of properties) {
187
+ if (prop.type === "Property") {
188
+ collectShadowedNames(prop.value as AnyNode | undefined, into);
189
+ } else if (prop.type === "RestElement") {
190
+ collectShadowedNames(prop.argument as AnyNode | undefined, into);
191
+ }
192
+ }
193
+ }
194
+ return;
195
+ }
196
+ if (pattern.type === "RestElement") {
197
+ collectShadowedNames(pattern.argument as AnyNode | undefined, into);
198
+ return;
199
+ }
200
+ if (pattern.type === "AssignmentPattern") {
201
+ collectShadowedNames(pattern.left as AnyNode | undefined, into);
202
+ return;
203
+ }
204
+ }
205
+
206
+ async function loadExports(
207
+ absPath: string,
208
+ cache: Map<string, Set<string> | null>,
209
+ ): Promise<Set<string> | null> {
210
+ const cached = cache.get(absPath);
211
+ if (cached !== undefined) return cached;
212
+ if (!(await Bun.file(absPath).exists())) {
213
+ cache.set(absPath, null);
214
+ return null;
215
+ }
216
+ const text = await Bun.file(absPath).text();
217
+ const ext = absPath.toLowerCase();
218
+ if (ext.endsWith(".svelte")) {
219
+ const set = new Set(["default"]);
220
+ cache.set(absPath, set);
221
+ return set;
222
+ }
223
+ // `export * from "..."` is opaque to a static scan — Bun.Transpiler.scan
224
+ // returns names declared locally, not re-exported star members. Treat such
225
+ // modules as un-introspectable so we don't false-positive on, e.g.,
226
+ // barrel files. The regex tolerates `export *`, `export * as Foo`, and
227
+ // preceding whitespace.
228
+ if (/(^|\n)\s*export\s+\*/.test(text)) {
229
+ cache.set(absPath, null);
230
+ return null;
231
+ }
232
+ try {
233
+ const loader: "ts" | "tsx" | "js" | "jsx" = ext.endsWith(".tsx")
234
+ ? "tsx"
235
+ : ext.endsWith(".jsx")
236
+ ? "jsx"
237
+ : ext.endsWith(".js") || ext.endsWith(".mjs")
238
+ ? "js"
239
+ : "ts";
240
+ const scan = new Bun.Transpiler({ loader }).scan(text);
241
+ const exports = new Set<string>(scan.exports ?? []);
242
+ cache.set(absPath, exports);
243
+ return exports;
244
+ } catch {
245
+ cache.set(absPath, null);
246
+ return null;
247
+ }
248
+ }
249
+
250
+ function levenshtein1(a: string, b: string): boolean {
251
+ if (a === b) return false;
252
+ const la = a.length;
253
+ const lb = b.length;
254
+ if (Math.abs(la - lb) > 1) return false;
255
+ if (la === lb) {
256
+ let diffs = 0;
257
+ for (let i = 0; i < la; i++) {
258
+ if (a[i] !== b[i] && ++diffs > 1) return false;
259
+ }
260
+ return diffs === 1;
261
+ }
262
+ const [s, l] = la < lb ? [a, b] : [b, a];
263
+ let i = 0;
264
+ let j = 0;
265
+ let edits = 0;
266
+ while (i < s.length && j < l.length) {
267
+ if (s[i] !== l[j]) {
268
+ if (++edits > 1) return false;
269
+ j++;
270
+ } else {
271
+ i++;
272
+ j++;
273
+ }
274
+ }
275
+ return true;
276
+ }
277
+
278
+ function bestSuggestion(target: string, available: Set<string>): string | null {
279
+ for (const name of available) {
280
+ if (levenshtein1(target.toLowerCase(), name.toLowerCase())) return name;
281
+ }
282
+ return null;
283
+ }
284
+
285
+ const PROMOTABLE_WARNING_CODES = new Set([
286
+ "component_name_lowercase",
287
+ "bind_invalid_value",
288
+ "invalid_html_attribute",
289
+ ]);
290
+
291
+ // Skip these template node types — they're not user-defined components and
292
+ // reference no JS binding.
293
+ const SKIP_TEMPLATE_TYPES = new Set([
294
+ "SvelteSelf",
295
+ "SvelteElement",
296
+ "SvelteFragment",
297
+ "SvelteHead",
298
+ "SvelteBody",
299
+ "SvelteWindow",
300
+ "SvelteDocument",
301
+ "SvelteOptions",
302
+ "SvelteBoundary",
303
+ "RegularElement",
304
+ "TitleElement",
305
+ "SlotElement",
306
+ ]);
307
+
308
+ interface TemplateRef {
309
+ name: string;
310
+ line: number;
311
+ col: number;
312
+ shadow: Set<string>;
313
+ }
314
+
315
+ function collectTemplateRefs(source: string, fragment: AnyNode): TemplateRef[] {
316
+ const refs: TemplateRef[] = [];
317
+ const scopeStack: Array<Set<string>> = [];
318
+
319
+ const visit = (n: AnyNode, _parent: AnyNode | null): boolean | void => {
320
+ if (n.type === "ConstTag") {
321
+ // Handled by the Fragment pre-pass in walkWithScope — siblings need
322
+ // the binding visible across the fragment, not just for ConstTag's
323
+ // own children.
324
+ return;
325
+ }
326
+ if (n.type === "EachBlock") {
327
+ const ctx = n.context as AnyNode | null | undefined;
328
+ const indexName = typeof n.index === "string" ? (n.index as string) : null;
329
+ const names = new Set<string>();
330
+ collectShadowedNames(ctx ?? null, names);
331
+ if (indexName) names.add(indexName);
332
+ scopeStack.push(names);
333
+ return; // continue walking children
334
+ }
335
+ if (n.type === "SnippetBlock") {
336
+ const params = n.parameters as AnyNode[] | undefined;
337
+ const names = new Set<string>();
338
+ if (Array.isArray(params)) {
339
+ for (const p of params) collectShadowedNames(p, names);
340
+ }
341
+ // Snippet identifier itself is a local in the surrounding scope; the
342
+ // outer binding extractor already records top-level `{#snippet}`
343
+ // declarations? No — `{#snippet}` is template-level. Add the snippet
344
+ // name into the surrounding scope so `<MySnippet/>` doesn't false-
345
+ // positive. The expression's name is the snippet's identifier.
346
+ const expr = n.expression as AnyNode | undefined;
347
+ const snippetName =
348
+ expr && typeof expr.name === "string" ? (expr.name as string) : null;
349
+ if (snippetName && scopeStack.length > 0) {
350
+ scopeStack[scopeStack.length - 1].add(snippetName);
351
+ } else if (snippetName) {
352
+ // Top-level snippet — push into a synthetic root scope.
353
+ if (scopeStack.length === 0) scopeStack.push(new Set());
354
+ scopeStack[0].add(snippetName);
355
+ }
356
+ scopeStack.push(names);
357
+ return;
358
+ }
359
+ if (n.type === "Component") {
360
+ const name = typeof n.name === "string" ? (n.name as string) : "";
361
+ if (name) {
362
+ const start = typeof n.start === "number" ? (n.start as number) : 0;
363
+ const { line, col } = lineColFromOffset(source, start);
364
+ const shadow = new Set<string>();
365
+ for (const s of scopeStack) for (const v of s) shadow.add(v);
366
+ refs.push({ name, line, col, shadow });
367
+ }
368
+ return;
369
+ }
370
+ if (n.type === "SvelteComponent") {
371
+ const expr = n.expression as AnyNode | undefined;
372
+ if (expr && expr.type === "Identifier" && typeof expr.name === "string") {
373
+ const start = typeof n.start === "number" ? (n.start as number) : 0;
374
+ const { line, col } = lineColFromOffset(source, start);
375
+ const shadow = new Set<string>();
376
+ for (const s of scopeStack) for (const v of s) shadow.add(v);
377
+ refs.push({ name: expr.name as string, line, col, shadow });
378
+ }
379
+ return;
380
+ }
381
+ if (SKIP_TEMPLATE_TYPES.has(n.type ?? "")) {
382
+ return;
383
+ }
384
+ };
385
+
386
+ // Custom DFS that pops scopes when leaving Each/Snippet/Fragment nodes.
387
+ const walkWithScope = (node: unknown, parent: AnyNode | null) => {
388
+ if (!node) return;
389
+ if (Array.isArray(node)) {
390
+ for (const c of node) walkWithScope(c, parent);
391
+ return;
392
+ }
393
+ if (typeof node !== "object") return;
394
+ const n = node as AnyNode;
395
+ let pushed = false;
396
+ if (typeof n.type === "string") {
397
+ const before = scopeStack.length;
398
+ visit(n, parent);
399
+ if (scopeStack.length > before) pushed = true;
400
+ }
401
+ // Fragment pre-pass: `{@const}` bindings live across all siblings in the
402
+ // surrounding fragment, so collect them before descending. Without this,
403
+ // `<ComponentPreview>{@const X = ...}<X /></ComponentPreview>` would
404
+ // false-positive on `<X />`.
405
+ let fragmentScopePushed = false;
406
+ if (n.type === "Fragment" && Array.isArray(n.nodes)) {
407
+ const fragmentScope = new Set<string>();
408
+ for (const child of n.nodes as AnyNode[]) {
409
+ if (child && child.type === "ConstTag") {
410
+ const decl = child.declaration as AnyNode | undefined;
411
+ const decls = (decl?.declarations as AnyNode[] | undefined) ?? [];
412
+ for (const d of decls) {
413
+ collectShadowedNames(d.id as AnyNode | undefined, fragmentScope);
414
+ }
415
+ }
416
+ }
417
+ if (fragmentScope.size > 0) {
418
+ scopeStack.push(fragmentScope);
419
+ fragmentScopePushed = true;
420
+ }
421
+ }
422
+ for (const key of CHILD_KEYS) {
423
+ const child = n[key];
424
+ if (child) walkWithScope(child, n);
425
+ }
426
+ if (fragmentScopePushed) scopeStack.pop();
427
+ if (pushed) scopeStack.pop();
428
+ };
429
+
430
+ walkWithScope(fragment, null);
431
+ return refs;
432
+ }
433
+
434
+ export async function auditSvelteSource(opts: SvelteAuditOptions): Promise<string | null> {
435
+ const { source, filename, ast, warnings, cwd, exportCache, strict } = opts;
436
+ const flags = resolveStrict(strict);
437
+ const envOverride = process.env.BOSIA_STRICT_IMPORTS === "0";
438
+
439
+ const root = ast as AnyNode | undefined;
440
+ if (!root || !root.fragment) return null;
441
+
442
+ const errors: AuditError[] = [];
443
+
444
+ if (flags.unbound || flags.namespaceMember) {
445
+ const bindings = extractBindings(root);
446
+ const bindingByName = new Map<string, Binding>();
447
+ for (const b of bindings) bindingByName.set(b.name, b);
448
+
449
+ const refs = collectTemplateRefs(source, root.fragment as AnyNode);
450
+
451
+ for (const ref of refs) {
452
+ // Builtin / svelte special tags (already filtered as separate AST types).
453
+ if (ref.name.includes(":")) continue;
454
+ const dotIdx = ref.name.indexOf(".");
455
+ const head = dotIdx === -1 ? ref.name : ref.name.slice(0, dotIdx);
456
+ const member = dotIdx === -1 ? null : ref.name.slice(dotIdx + 1);
457
+
458
+ // Heuristic: lowercase head with no member is an HTML element that
459
+ // somehow landed in Component (rare — usually svelte/compiler rejects).
460
+ // Bail to avoid false positives.
461
+ if (!head || head.length === 0) continue;
462
+
463
+ if (ref.shadow.has(head)) continue;
464
+
465
+ const binding = bindingByName.get(head);
466
+ if (!binding) {
467
+ if (flags.unbound) {
468
+ errors.push({
469
+ kind: "unbound-identifier",
470
+ line: ref.line,
471
+ col: ref.col,
472
+ message: `<${ref.name}> — identifier \`${head}\` is not imported or declared in this component.`,
473
+ });
474
+ }
475
+ continue;
476
+ }
477
+
478
+ if (!member) continue;
479
+
480
+ if (binding.kind !== "namespace-import") {
481
+ if (flags.namespaceMember) {
482
+ errors.push({
483
+ kind: "dotted-on-non-namespace",
484
+ line: ref.line,
485
+ col: ref.col,
486
+ message:
487
+ `<${ref.name}> — \`${head}\` is a ` +
488
+ (binding.kind === "named-import"
489
+ ? "named import"
490
+ : binding.kind === "default-import"
491
+ ? "default import"
492
+ : "local declaration") +
493
+ `; only namespace imports (\`import * as ${head} from ...\`) support \`${head}.${member}\` usage.`,
494
+ });
495
+ }
496
+ continue;
497
+ }
498
+
499
+ if (!flags.namespaceMember) continue;
500
+
501
+ const spec = binding.source ?? "";
502
+ const resolved = await resolveImportPath(spec, filename, cwd);
503
+ if (resolved.kind === "bare" || resolved.kind === "virtual" || !resolved.path) {
504
+ // Bare-package / virtual sources are out of scope — too easy to
505
+ // false-positive on tree-shaken barrels (e.g. `lucide-svelte`).
506
+ continue;
507
+ }
508
+
509
+ const exportsSet = await loadExports(resolved.path, exportCache);
510
+ if (!exportsSet) continue; // un-introspectable source
511
+ if (exportsSet.has(member)) continue;
512
+
513
+ const available = Array.from(exportsSet).filter((e) => e !== "default");
514
+ const hint = bestSuggestion(member, exportsSet);
515
+ const availableStr = available.length
516
+ ? `Available exports: ${available.join(", ")}.`
517
+ : `Module has no named exports.`;
518
+ const hintStr = hint ? ` Did you mean \`<${head}.${hint}>\`?` : "";
519
+ errors.push({
520
+ kind: "missing-namespace-export",
521
+ line: ref.line,
522
+ col: ref.col,
523
+ message: `<${ref.name}> — namespace import \`${head}\` (from "${spec}") has no export \`${member}\`. ${availableStr}${hintStr}`,
524
+ });
525
+ }
526
+ }
527
+
528
+ if (flags.warnings && Array.isArray(warnings)) {
529
+ for (const w of warnings) {
530
+ if (!w || !w.code) continue;
531
+ if (!PROMOTABLE_WARNING_CODES.has(w.code)) continue;
532
+ const line = w.start?.line ?? 1;
533
+ const col = (w.start?.column ?? 0) + 1;
534
+ errors.push({
535
+ kind: "warning",
536
+ line,
537
+ col,
538
+ message: `[${w.code}] ${w.message ?? "(no message)"}`,
539
+ });
540
+ }
541
+ }
542
+
543
+ if (errors.length === 0) return null;
544
+
545
+ errors.sort((a, b) => a.line - b.line || a.col - b.col);
546
+ const header = `Svelte component-import audit failed: ${filename}`;
547
+ const body = errors
548
+ .map((e) => {
549
+ const loc = ` ${e.line}:${e.col}`;
550
+ return `${loc} ${e.message}`;
551
+ })
552
+ .join("\n");
553
+ const footer =
554
+ "\n\nSet BOSIA_STRICT_IMPORTS=0 to downgrade these to warnings, or configure \`strictImports\` in bosia.config.ts.";
555
+
556
+ const formatted = `${header}\n\n${body}${footer}`;
557
+
558
+ if (envOverride) {
559
+ console.warn(`[bosia] ${formatted}`);
560
+ return null;
561
+ }
562
+ return formatted;
563
+ }
@@ -1,6 +1,10 @@
1
1
  import { compile, compileModule } from "svelte/compiler";
2
2
  import type { BunPlugin } from "bun";
3
3
 
4
+ import { auditSvelteSource } from "./svelteAudit.ts";
5
+ import { loadBosiaConfig } from "./config.ts";
6
+ import type { StrictImportsOption } from "./types/plugin.ts";
7
+
4
8
  const svelteHash = (s: string) => Bun.hash(s, 5381).toString(36);
5
9
 
6
10
  // Bun's bundler does not chain sourcemaps from `onLoad` results, so the final
@@ -11,6 +15,37 @@ const svelteHash = (s: string) => Bun.hash(s, 5381).toString(36);
11
15
  // the output `.map` files to chain back to original source positions.
12
16
  export const svelteMapCache = new Map<string, unknown>();
13
17
 
18
+ // Module-scoped so both the `browser` and `bun` plugin instances share state.
19
+ // Bosia spawns both per build (client + server in parallel) and each calls
20
+ // `onLoad` on the same `.svelte` file. Without sharing, the audit would run
21
+ // twice per file (wasteful) and the export cache wouldn't amortize across
22
+ // targets. Keyed by absolute path. Cleared between builds is not needed —
23
+ // stale entries are scoped to the (path, build-process) tuple.
24
+ const auditInflight = new Map<string, Promise<void>>();
25
+ const auditExportCache = new Map<string, Set<string> | null>();
26
+ let auditStrictPromise: Promise<StrictImportsOption> | null = null;
27
+
28
+ function getStrictImportsOption(): Promise<StrictImportsOption> {
29
+ if (!auditStrictPromise) {
30
+ auditStrictPromise = (async () => {
31
+ try {
32
+ const config = await loadBosiaConfig(process.cwd());
33
+ return config.strictImports ?? true;
34
+ } catch {
35
+ return true;
36
+ }
37
+ })();
38
+ }
39
+ return auditStrictPromise;
40
+ }
41
+
42
+ /** Test-only — drop cached audit state so fixtures with fresh configs reload. */
43
+ export function resetSvelteAuditCache(): void {
44
+ auditInflight.clear();
45
+ auditExportCache.clear();
46
+ auditStrictPromise = null;
47
+ }
48
+
14
49
  // Svelte 5 dev compile emits named `function get()` / `function set($$value)`
15
50
  // expressions inside `$.bind_*` calls (for nicer `$inspect` stack traces). Bun's
16
51
  // bundler destructures `import * as $ from "svelte/internal/client"` into named
@@ -47,7 +82,32 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
47
82
  hmr: false,
48
83
  cssHash: ({ css }) => `svelte-${svelteHash(css)}`,
49
84
  filename: args.path,
85
+ // Modern AST shape (Svelte 5.x) — `fragment`, `instance`, `module`
86
+ // rather than the legacy `html`. The audit walker assumes modern.
87
+ modernAst: true,
50
88
  });
89
+ const existing = auditInflight.get(args.path);
90
+ if (existing) {
91
+ await existing;
92
+ } else {
93
+ const promise = (async () => {
94
+ const strict = await getStrictImportsOption();
95
+ const failure = await auditSvelteSource({
96
+ source,
97
+ filename: args.path,
98
+ ast: (result as unknown as { ast?: unknown }).ast,
99
+ warnings: (result.warnings ?? []) as unknown as Parameters<
100
+ typeof auditSvelteSource
101
+ >[0]["warnings"],
102
+ cwd: process.cwd(),
103
+ exportCache: auditExportCache,
104
+ strict,
105
+ });
106
+ if (failure) throw new Error(failure);
107
+ })();
108
+ auditInflight.set(args.path, promise);
109
+ await promise;
110
+ }
51
111
  // Only the client target's map is useful to the inspector's runtime
52
112
  // resolver — browser-side stack frames are what we need to translate.
53
113
  // Server (Bun) compile output has different line numbers and would
@@ -71,8 +71,27 @@ export interface BosiaPlugin {
71
71
  };
72
72
  }
73
73
 
74
+ /**
75
+ * Compile-time audit of component imports in `.svelte` templates. When enabled
76
+ * (the default), `<Card.Root>` style usages are validated against the
77
+ * referenced module's actual exports so missing names fail the build instead
78
+ * of crashing on first SSR render. `BOSIA_STRICT_IMPORTS=0` downgrades to
79
+ * warnings at runtime.
80
+ */
81
+ export type StrictImportsOption =
82
+ | boolean
83
+ | {
84
+ /** Catch `<Mystery />` where `Mystery` has no binding. Default: true. */
85
+ unbound?: boolean;
86
+ /** Catch `<Card.Root>` where the namespace has no `Root` export. Default: true. */
87
+ namespaceMember?: boolean;
88
+ /** Promote selected `svelte/compiler` warnings to errors. Default: true. */
89
+ warnings?: boolean;
90
+ };
91
+
74
92
  export interface BosiaConfig {
75
93
  plugins?: (BosiaPlugin | false | null | undefined)[];
94
+ strictImports?: StrictImportsOption;
76
95
  }
77
96
 
78
97
  /** Identity helper for type inference in `bosia.config.ts`. */
@@ -2,6 +2,8 @@
2
2
 
3
3
  A [Bosia](https://github.com/bosapi/bosia) project — SSR · Svelte 5 · Bun · ElysiaJS.
4
4
 
5
+ Bosia is **production-ready out of the box** — security (CSRF, XSS, secure cookies, headers), performance (response cache, gzip, prerendering), and reliability (graceful shutdown, request backpressure, crash backoff) are all built in.
6
+
5
7
  ## Getting Started
6
8
 
7
9
  ```bash
@@ -101,6 +103,6 @@ cn("px-4 py-2", isActive && "bg-primary");
101
103
 
102
104
  ## Learn More
103
105
 
104
- - [Bosia documentation](https://bosia.bosapi.com)
106
+ - [Bosia documentation](https://bosia.dev)
105
107
  - [Svelte 5 docs](https://svelte.dev)
106
108
  - [Tailwind CSS v4](https://tailwindcss.com)
@@ -2,6 +2,8 @@
2
2
 
3
3
  A [Bosia](https://github.com/nicholascostadev/bosia) project with demo routes, hooks, API endpoints, and form actions.
4
4
 
5
+ Bosia is **production-ready out of the box** — security (CSRF, XSS, secure cookies, headers), performance (response cache, gzip, prerendering), and reliability (graceful shutdown, request backpressure, crash backoff) are all built in.
6
+
5
7
  ## Running
6
8
 
7
9
  ```bash
@@ -24,6 +26,6 @@ bun x bosia start # run production server
24
26
 
25
27
  ## Learn More
26
28
 
27
- - [Bosia documentation](https://bosia.bosapi.com)
29
+ - [Bosia documentation](https://bosia.dev)
28
30
  - [Svelte 5 docs](https://svelte.dev)
29
31
  - [Tailwind CSS v4](https://tailwindcss.com)