bosia 1.1.3 → 1.1.5

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": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
package/src/ambient.d.ts CHANGED
@@ -7,6 +7,7 @@ declare module "bosia:routes" {
7
7
 
8
8
  export const clientRoutes: Array<{
9
9
  pattern: string;
10
+ id: string;
10
11
  page: Loader;
11
12
  layouts: Loader[];
12
13
  hasServerData: boolean;
@@ -16,6 +17,7 @@ declare module "bosia:routes" {
16
17
 
17
18
  export const serverRoutes: Array<{
18
19
  pattern: string;
20
+ id: string;
19
21
  pageModule: Loader;
20
22
  layoutModules: Loader[];
21
23
  pageServer: Loader | null;
@@ -25,6 +27,7 @@ declare module "bosia:routes" {
25
27
 
26
28
  export const apiRoutes: Array<{
27
29
  pattern: string;
30
+ id: string;
28
31
  module: Loader;
29
32
  }>;
30
33
 
package/src/cli/add.ts CHANGED
@@ -2,12 +2,13 @@ import { join, dirname } from "path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
3
  import * as p from "@clack/prompts";
4
4
  import {
5
+ checkRegistryFiles,
6
+ containedPath,
5
7
  type InstallOptions,
6
8
  REGISTRY_URL,
7
9
  resolveLocalRegistryOrExit,
8
10
  readRegistryJSON,
9
- readRegistryFile,
10
- writeRegistryFile,
11
+ installRegistryFiles,
11
12
  mergePkgJson,
12
13
  bunAdd,
13
14
  } from "./registry.ts";
@@ -128,13 +129,16 @@ export async function addComponent(name: string, root = false, options?: Install
128
129
  "meta.json",
129
130
  );
130
131
 
132
+ // Check this component's own paths before installing anything for it.
133
+ const destDir = containedPath(join(cwd, "src", "lib", "components"), fullPath);
134
+ checkRegistryFiles(registryRoot, "components", fullPath, meta.files, destDir);
135
+
131
136
  // Install component dependencies first (recursive)
132
137
  for (const dep of meta.dependencies) {
133
138
  await addComponent(dep, false, options);
134
139
  }
135
140
 
136
141
  // Check if component already exists (skip check entirely in non-interactive mode)
137
- const destDir = join(cwd, "src", "lib", "components", fullPath);
138
142
  if (!options?.skipPrompts && existsSync(destDir)) {
139
143
  const replace = await p.confirm({
140
144
  message: `Component "${name}" already exists at src/lib/components/${fullPath}/. Replace it?`,
@@ -146,15 +150,14 @@ export async function addComponent(name: string, root = false, options?: Install
146
150
  }
147
151
 
148
152
  // Download/copy component files into src/lib/components/<fullPath>/
149
- mkdirSync(destDir, { recursive: true });
150
-
151
- for (const file of meta.files) {
152
- const content = await readRegistryFile(registryRoot, "components", fullPath, file);
153
- const dest = join(destDir, file);
154
- if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
155
- writeRegistryFile(dest, content);
156
- console.log(` ✍️ src/lib/components/${fullPath}/${file}`);
157
- }
153
+ await installRegistryFiles(
154
+ registryRoot,
155
+ "components",
156
+ fullPath,
157
+ meta.files,
158
+ destDir,
159
+ `src/lib/components/${fullPath}`,
160
+ );
158
161
 
159
162
  // Install npm dependencies
160
163
  if (Object.keys(meta.npmDeps).length > 0) {
package/src/cli/block.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { join, dirname } from "path";
2
- import { mkdirSync, existsSync } from "fs";
1
+ import { join } from "path";
2
+ import { existsSync } from "fs";
3
3
  import * as p from "@clack/prompts";
4
4
  import {
5
+ checkRegistryFiles,
6
+ containedPath,
5
7
  type InstallOptions,
6
8
  resolveLocalRegistryOrExit,
7
9
  readRegistryJSON,
8
- readRegistryFile,
9
- writeRegistryFile,
10
+ installRegistryFiles,
10
11
  mergePkgJson,
11
12
  bunAdd,
12
13
  } from "./registry.ts";
@@ -69,6 +70,11 @@ export async function runAddBlock(
69
70
 
70
71
  const meta = await readRegistryJSON<BlockMeta>(registryRoot, "blocks", name, "meta.json");
71
72
 
73
+ // Check this item's own paths before installing anything for it.
74
+ const cwd = resolvedOptions.cwd ?? process.cwd();
75
+ const destDir = containedPath(join(cwd, "src", "lib", "blocks"), name);
76
+ checkRegistryFiles(registryRoot, "blocks", name, meta.files, destDir);
77
+
72
78
  // 1. Install primitive dependencies first.
73
79
  // Component deps (e.g. "ui/button") go through addComponent.
74
80
  // Block deps (e.g. "blocks/files/upload-area") recurse into runAddBlock.
@@ -81,9 +87,6 @@ export async function runAddBlock(
81
87
  }
82
88
 
83
89
  // 2. Copy block files to src/lib/blocks/<path>/
84
- const cwd = resolvedOptions.cwd ?? process.cwd();
85
- const destDir = join(cwd, "src", "lib", "blocks", name);
86
-
87
90
  if (!resolvedOptions.skipPrompts && existsSync(destDir)) {
88
91
  const replace = await p.confirm({
89
92
  message: `Block "${name}" already exists at src/lib/blocks/${name}/. Replace it?`,
@@ -94,15 +97,14 @@ export async function runAddBlock(
94
97
  }
95
98
  }
96
99
 
97
- mkdirSync(destDir, { recursive: true });
98
-
99
- for (const file of meta.files) {
100
- const content = await readRegistryFile(registryRoot, "blocks", name, file);
101
- const dest = join(destDir, file);
102
- if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
103
- writeRegistryFile(dest, content);
104
- console.log(` ✍️ src/lib/blocks/${name}/${file}`);
105
- }
100
+ await installRegistryFiles(
101
+ registryRoot,
102
+ "blocks",
103
+ name,
104
+ meta.files,
105
+ destDir,
106
+ `src/lib/blocks/${name}`,
107
+ );
106
108
 
107
109
  // 3. Merge font @imports into app.css (idempotent)
108
110
  if (meta.fonts && Object.keys(meta.fonts).length > 0) {
package/src/cli/feat.ts CHANGED
@@ -4,6 +4,8 @@ import * as p from "@clack/prompts";
4
4
  import { addComponent, initAddRegistry } from "./add.ts";
5
5
  import { runAddBlock } from "./block.ts";
6
6
  import {
7
+ containedPath,
8
+ registrySource,
7
9
  type InstallOptions,
8
10
  resolveLocalRegistryOrExit,
9
11
  readRegistryJSON,
@@ -246,6 +248,13 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
246
248
  featureArgs: undefined, // already consumed by the root feature
247
249
  };
248
250
 
251
+ // Check every target and source path before installing any dependency, so a
252
+ // bad path in this feature stops the install before anything is written.
253
+ for (const entry of meta.files) {
254
+ containedPath(cwd, entry.target);
255
+ registrySource(registryRoot, "features", name, entry.src);
256
+ }
257
+
249
258
  // Install required feature dependencies first (recursive)
250
259
  if (meta.features && meta.features.length > 0) {
251
260
  for (const feat of meta.features) {
@@ -271,19 +280,27 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
271
280
  console.log("");
272
281
  }
273
282
 
274
- // Apply each file entry per its strategy. Skip entries whose `when` clause doesn't match.
283
+ // Skip entries whose `when` clause doesn't match. Read every source (in
284
+ // parallel) before writing anything, so a failed download leaves this
285
+ // feature's files untouched.
286
+ const entries = meta.files.filter((e) => !e.when || whenMatches(e.when, myOptions));
287
+ const contents = await Promise.all(
288
+ entries.map((e) => readRegistryFile(registryRoot, "features", name, e.src)),
289
+ );
290
+ const planned: { entry: FileEntry; dest: string; content: string }[] = entries.map(
291
+ (entry, i) => ({ entry, dest: containedPath(cwd, entry.target), content: contents[i]! }),
292
+ );
293
+
294
+ // Apply each file entry per its strategy.
275
295
  const createdDirs = new Set<string>();
276
296
  const recordedFiles: { target: string; strategy: string; marker?: string }[] = [];
277
- for (const entry of meta.files) {
278
- if (entry.when && !whenMatches(entry.when, myOptions)) continue;
279
- const dest = join(cwd, entry.target);
297
+ for (const { entry, dest, content } of planned) {
280
298
  const strategy: FileStrategy = entry.strategy ?? "write";
281
299
  const dir = dirname(dest);
282
300
  if (!createdDirs.has(dir)) {
283
301
  mkdirSync(dir, { recursive: true });
284
302
  createdDirs.add(dir);
285
303
  }
286
- const content = await readRegistryFile(registryRoot, "features", name, entry.src);
287
304
  await applyStrategy({
288
305
  dest,
289
306
  target: entry.target,
@@ -331,12 +348,7 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
331
348
  if (envEntries.length > 0) {
332
349
  const envPath = join(cwd, ".env");
333
350
  const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
334
- const toAdd: string[] = [];
335
- for (const [key, val] of envEntries) {
336
- if (!existing.includes(`${key}=`)) {
337
- toAdd.push(`${key}=${val}`);
338
- }
339
- }
351
+ const toAdd = missingEnvLines(existing, envEntries);
340
352
  if (toAdd.length > 0) {
341
353
  const nl = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
342
354
  writeFileSync(envPath, existing + nl + toAdd.join("\n") + "\n", "utf-8");
@@ -496,6 +508,14 @@ function blockDelim(ext: string): { start: string; end: string } {
496
508
  return { start: "//", end: "" };
497
509
  }
498
510
 
511
+ /** `KEY=value` lines for the entries `.env` doesn't set yet. A commented-out
512
+ * `# KEY=` (the default template documents CACHE_KEYS that way) doesn't count. */
513
+ export function missingEnvLines(existing: string, entries: [string, string][]): string[] {
514
+ return entries
515
+ .filter(([key]) => !new RegExp(`^\\s*${escapeRegex(key)}=`, "m").test(existing))
516
+ .map(([key, val]) => `${key}=${val}`);
517
+ }
518
+
499
519
  function escapeRegex(s: string): string {
500
520
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
501
521
  }
package/src/cli/page.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { join, dirname } from "path";
2
- import { mkdirSync, existsSync } from "fs";
1
+ import { join } from "path";
2
+ import { existsSync } from "fs";
3
3
  import * as p from "@clack/prompts";
4
4
  import {
5
+ checkRegistryFiles,
6
+ containedPath,
5
7
  type InstallOptions,
6
8
  resolveLocalRegistryOrExit,
7
9
  readRegistryJSON,
8
- readRegistryFile,
9
- writeRegistryFile,
10
+ installRegistryFiles,
10
11
  mergePkgJson,
11
12
  bunAdd,
12
13
  } from "./registry.ts";
@@ -69,6 +70,11 @@ export async function runAddPage(
69
70
 
70
71
  const meta = await readRegistryJSON<PageMeta>(registryRoot, "pages", name, "meta.json");
71
72
 
73
+ // Check this item's own paths before installing anything for it.
74
+ const cwd = resolvedOptions.cwd ?? process.cwd();
75
+ const destDir = containedPath(join(cwd, "src", "lib", "pages"), name);
76
+ checkRegistryFiles(registryRoot, "pages", name, meta.files, destDir);
77
+
72
78
  // 1. Install dependencies first.
73
79
  // Block deps (e.g. "blocks/storefront/header") recurse into runAddBlock.
74
80
  // Component deps (e.g. "ui/button") go through addComponent.
@@ -81,9 +87,6 @@ export async function runAddPage(
81
87
  }
82
88
 
83
89
  // 2. Copy page files to src/lib/pages/<path>/
84
- const cwd = resolvedOptions.cwd ?? process.cwd();
85
- const destDir = join(cwd, "src", "lib", "pages", name);
86
-
87
90
  if (!resolvedOptions.skipPrompts && existsSync(destDir)) {
88
91
  const replace = await p.confirm({
89
92
  message: `Page "${name}" already exists at src/lib/pages/${name}/. Replace it?`,
@@ -94,15 +97,14 @@ export async function runAddPage(
94
97
  }
95
98
  }
96
99
 
97
- mkdirSync(destDir, { recursive: true });
98
-
99
- for (const file of meta.files) {
100
- const content = await readRegistryFile(registryRoot, "pages", name, file);
101
- const dest = join(destDir, file);
102
- if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
103
- writeRegistryFile(dest, content);
104
- console.log(` ✍️ src/lib/pages/${name}/${file}`);
105
- }
100
+ await installRegistryFiles(
101
+ registryRoot,
102
+ "pages",
103
+ name,
104
+ meta.files,
105
+ destDir,
106
+ `src/lib/pages/${name}`,
107
+ );
106
108
 
107
109
  // 3. Merge font @imports into app.css (idempotent)
108
110
  if (meta.fonts && Object.keys(meta.fonts).length > 0) {
@@ -1,7 +1,9 @@
1
- import { join, dirname } from "path";
2
- import { writeFileSync, readFileSync, existsSync, unlinkSync } from "fs";
1
+ import { join, dirname, resolve as resolvePath } from "path";
2
+ import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from "fs";
3
3
  import { spawn } from "bun";
4
4
 
5
+ import { safePath } from "../core/safePath.ts";
6
+
5
7
  // ─── Shared registry utilities for feat.ts and add.ts ─────
6
8
 
7
9
  export const REGISTRY_URL = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
@@ -42,8 +44,61 @@ export function resolveLocalRegistryOrExit(): string {
42
44
  }
43
45
  }
44
46
 
47
+ // ─── Path containment ─────────────────────────────────────
48
+
49
+ /**
50
+ * Resolve `rel` (a name or path taken from registry metadata or the command
51
+ * line) under `base`, and throw if it lands outside it. Registry `meta.json`
52
+ * decides where files go, so a `../` in it must not write over `~/.bashrc`.
53
+ *
54
+ * Backslashes are refused outright: on Linux and macOS `..\x` is one plain
55
+ * filename and would pass the check, but `fetch()` reads `\` as `/` when the
56
+ * same string goes into a registry URL. No registry entry uses one.
57
+ */
58
+ export function containedPath(base: string, rel: string): string {
59
+ if (rel.includes("\\")) {
60
+ throw new Error(`Refusing to use "${rel}": registry paths use "/", not "\\"`);
61
+ }
62
+ const full = safePath(base, rel);
63
+ if (!full || full === resolvePath(base)) {
64
+ throw new Error(`Refusing to use "${rel}": it resolves outside ${base}`);
65
+ }
66
+ return full;
67
+ }
68
+
45
69
  // ─── Registry file readers ────────────────────────────────
46
70
 
71
+ /**
72
+ * Where a registry file comes from: a path under the local registry's category
73
+ * folder, or a URL under the remote one. Throws when `name`/`file` would leave
74
+ * that folder.
75
+ *
76
+ * The remote case is checked as a URL, because a URL is what gets fetched. The
77
+ * URL parser reads `%2e%2e` as `..` (a path check sees a plain folder name), and
78
+ * `?` or `#` would cut the path short, so all three are refused, and the parsed
79
+ * pathname must still sit under the category.
80
+ */
81
+ export function registrySource(
82
+ registryRoot: string | null,
83
+ category: string,
84
+ name: string,
85
+ file: string,
86
+ ): string {
87
+ const rel = `${name}/${file}`;
88
+ if (registryRoot) return containedPath(join(registryRoot, category), rel);
89
+
90
+ if (/[%?#]/.test(rel)) {
91
+ throw new Error(`Refusing to use "${rel}": registry paths can't contain "%", "?" or "#"`);
92
+ }
93
+ containedPath(category, rel);
94
+ const base = new URL(`${REGISTRY_URL}/${category}/`);
95
+ const url = new URL(rel, base);
96
+ if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
97
+ throw new Error(`Refusing to use "${rel}": it resolves outside ${base.href}`);
98
+ }
99
+ return url.href;
100
+ }
101
+
47
102
  /** Read and parse a JSON file from the registry (local or remote). */
48
103
  export async function readRegistryJSON<T>(
49
104
  registryRoot: string | null,
@@ -51,16 +106,12 @@ export async function readRegistryJSON<T>(
51
106
  name: string,
52
107
  file: string,
53
108
  ): Promise<T> {
54
- if (registryRoot) {
55
- const path = join(registryRoot, category, name, file);
56
- if (!existsSync(path)) {
57
- throw new Error(
58
- `"${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`,
59
- );
60
- }
61
- return JSON.parse(readFileSync(path, "utf-8"));
109
+ const source = registrySource(registryRoot, category, name, file);
110
+ if (!registryRoot) return fetchJSON<T>(source);
111
+ if (!existsSync(source)) {
112
+ throw new Error(`"${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`);
62
113
  }
63
- return fetchJSON<T>(`${REGISTRY_URL}/${category}/${name}/${file}`);
114
+ return JSON.parse(readFileSync(source, "utf-8"));
64
115
  }
65
116
 
66
117
  /** Read a text file from the registry (local or remote). */
@@ -70,16 +121,62 @@ export async function readRegistryFile(
70
121
  name: string,
71
122
  file: string,
72
123
  ): Promise<string> {
73
- if (registryRoot) {
74
- const path = join(registryRoot, category, name, file);
75
- if (!existsSync(path)) {
76
- throw new Error(
77
- `File "${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`,
78
- );
79
- }
80
- return readFileSync(path, "utf-8");
124
+ const source = registrySource(registryRoot, category, name, file);
125
+ if (!registryRoot) return fetchText(source);
126
+ if (!existsSync(source)) {
127
+ throw new Error(
128
+ `File "${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`,
129
+ );
81
130
  }
82
- return fetchText(`${REGISTRY_URL}/${category}/${name}/${file}`);
131
+ return readFileSync(source, "utf-8");
132
+ }
133
+
134
+ // ─── Registry file installer ──────────────────────────────
135
+
136
+ /**
137
+ * Check every source and destination path of a registry item without reading
138
+ * or writing anything. Installers call this right after reading `meta.json`,
139
+ * before installing any dependency, so a bad path stops the install before
140
+ * anything is written. Returns the checked destinations, in `files` order.
141
+ */
142
+ export function checkRegistryFiles(
143
+ registryRoot: string | null,
144
+ category: string,
145
+ name: string,
146
+ files: string[],
147
+ destDir: string,
148
+ ): string[] {
149
+ return files.map((file) => {
150
+ registrySource(registryRoot, category, name, file);
151
+ return containedPath(destDir, file);
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Copy a registry item's `files` into `destDir`. Every path is checked and
157
+ * every file read (in parallel) before the first write, so a rejected path or a
158
+ * failed download leaves nothing half-installed. `label` is the project-relative
159
+ * `destDir` used in the log lines.
160
+ */
161
+ export async function installRegistryFiles(
162
+ registryRoot: string | null,
163
+ category: string,
164
+ name: string,
165
+ files: string[],
166
+ destDir: string,
167
+ label: string,
168
+ ): Promise<void> {
169
+ const dests = checkRegistryFiles(registryRoot, category, name, files, destDir);
170
+ const contents = await Promise.all(
171
+ files.map((file) => readRegistryFile(registryRoot, category, name, file)),
172
+ );
173
+
174
+ mkdirSync(destDir, { recursive: true });
175
+ files.forEach((file, i) => {
176
+ if (file.includes("/")) mkdirSync(dirname(dests[i]!), { recursive: true });
177
+ writeRegistryFile(dests[i]!, contents[i]!);
178
+ console.log(` ✍️ ${label}/${file}`);
179
+ });
83
180
  }
84
181
 
85
182
  // ─── Registry file writer ─────────────────────────────────
package/src/cli/theme.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { join } from "path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
- import { resolveLocalRegistryOrExit, readRegistryJSON, readRegistryFile } from "./registry.ts";
3
+ import {
4
+ containedPath,
5
+ resolveLocalRegistryOrExit,
6
+ readRegistryJSON,
7
+ readRegistryFile,
8
+ } from "./registry.ts";
4
9
  import { mergeFontImports } from "./fonts.ts";
5
10
 
6
11
  // ─── bun x bosia@latest add theme <name> ─────────────────
@@ -46,7 +51,7 @@ export async function runAddTheme(name: string | undefined, flags: string[] = []
46
51
  // Convention: first file is the tokens file, written as <name>.css.
47
52
  const tokensFile = meta.files[0] ?? "tokens.css";
48
53
  const content = await readRegistryFile(registryRoot, "themes", name, tokensFile);
49
- const tokensDest = join(themesDir, `${name}.css`);
54
+ const tokensDest = containedPath(themesDir, `${name}.css`);
50
55
  writeFileSync(tokensDest, content, "utf-8");
51
56
  console.log(` ✍️ src/lib/themes/${name}.css`);
52
57
 
@@ -0,0 +1,124 @@
1
+ // ─── In-memory Asset Cache ───────────────────────────────
2
+ // Keeps small static files and prerendered pages (and their .br/.gz variants)
3
+ // in memory after their first request, so later hits skip the per-request
4
+ // open/stat/read of `Bun.file`. Filled lazily — boot does no extra I/O, so a
5
+ // cold start costs the same as before. Byte-budget LRU, keyed by absolute path.
6
+ //
7
+ // Bun only: on Workers, static files are served by Workers Static Assets
8
+ // before the worker runs, so this module never sees them.
9
+
10
+ type Bytes = Uint8Array<ArrayBuffer>;
11
+ export type AssetHit = { bytes: Bytes; type: string };
12
+
13
+ function parseBytes(raw: string | undefined, fallback: number): number {
14
+ if (!raw) return fallback;
15
+ const n = parseInt(raw, 10);
16
+ if (!Number.isFinite(n) || n < 0) return fallback;
17
+ return n;
18
+ }
19
+
20
+ export const DEFAULT_ASSET_CACHE_MAX_BYTES = 52_428_800; // 50MB; 0 = disabled
21
+ export const DEFAULT_ASSET_CACHE_MAX_FILE_BYTES = 1_048_576; // 1MB
22
+
23
+ export function parseAssetCacheEnv(env: Record<string, string | undefined>): {
24
+ maxBytes: number;
25
+ maxFileBytes: number;
26
+ } {
27
+ return {
28
+ maxBytes: parseBytes(env.ASSET_CACHE_MAX_BYTES, DEFAULT_ASSET_CACHE_MAX_BYTES),
29
+ maxFileBytes: parseBytes(env.ASSET_CACHE_MAX_FILE_BYTES, DEFAULT_ASSET_CACHE_MAX_FILE_BYTES),
30
+ };
31
+ }
32
+
33
+ export class AssetCache {
34
+ private map = new Map<string, AssetHit>();
35
+ private inFlight = new Map<string, Promise<void>>();
36
+ private used = 0;
37
+
38
+ constructor(
39
+ readonly maxBytes: number,
40
+ readonly maxFileBytes: number,
41
+ ) {}
42
+
43
+ get enabled(): boolean {
44
+ return this.maxBytes > 0 && this.maxFileBytes > 0;
45
+ }
46
+
47
+ get usedBytes(): number {
48
+ return this.used;
49
+ }
50
+
51
+ get size(): number {
52
+ return this.map.size;
53
+ }
54
+
55
+ /** Hit promotes the entry (Map insertion order = LRU order). */
56
+ get(path: string): AssetHit | undefined {
57
+ const hit = this.map.get(path);
58
+ if (hit === undefined) return undefined;
59
+ this.map.delete(path);
60
+ this.map.set(path, hit);
61
+ return hit;
62
+ }
63
+
64
+ /**
65
+ * Read `path` into memory in the background. `type` is the Content-Type of
66
+ * the original file — a `.br` variant must not report its own. Errors are
67
+ * swallowed: the caller already answered from disk, and will again.
68
+ */
69
+ fill(path: string, type: string): void {
70
+ if (!this.enabled || this.map.has(path) || this.inFlight.has(path)) return;
71
+ const job = (async () => {
72
+ const file = Bun.file(path);
73
+ if (file.size > this.maxFileBytes || file.size > this.maxBytes) return;
74
+ const bytes = (await file.bytes()) as Bytes;
75
+ if (bytes.byteLength > this.maxFileBytes || bytes.byteLength > this.maxBytes) return;
76
+ this.store(path, { bytes, type });
77
+ })()
78
+ .catch(() => {})
79
+ .finally(() => this.inFlight.delete(path));
80
+ this.inFlight.set(path, job);
81
+ }
82
+
83
+ /** Resolves once every fill started so far has settled. For tests. */
84
+ async idle(): Promise<void> {
85
+ while (this.inFlight.size > 0) await Promise.all(this.inFlight.values());
86
+ }
87
+
88
+ clear(): void {
89
+ this.map.clear();
90
+ this.used = 0;
91
+ }
92
+
93
+ private store(path: string, hit: AssetHit): void {
94
+ const prev = this.map.get(path);
95
+ if (prev) {
96
+ this.used -= prev.bytes.byteLength;
97
+ this.map.delete(path);
98
+ }
99
+ const size = hit.bytes.byteLength;
100
+ for (const [oldPath, old] of this.map) {
101
+ if (this.used + size <= this.maxBytes) break;
102
+ this.map.delete(oldPath);
103
+ this.used -= old.bytes.byteLength;
104
+ }
105
+ this.map.set(path, hit);
106
+ this.used += size;
107
+ }
108
+ }
109
+
110
+ const env: Record<string, string | undefined> =
111
+ typeof process !== "undefined" && process.env ? process.env : {};
112
+ const limits = parseAssetCacheEnv(env);
113
+
114
+ export const assetCache = new AssetCache(limits.maxBytes, limits.maxFileBytes);
115
+
116
+ const mb = (n: number) => `${Math.round((n / 1_048_576) * 10) / 10}MB`;
117
+
118
+ export function logAssetCache(): void {
119
+ console.log(
120
+ assetCache.enabled
121
+ ? `🗂️ Asset cache: max ${mb(assetCache.maxBytes)}, max file ${mb(assetCache.maxFileBytes)}`
122
+ : "🗂️ Asset cache: disabled",
123
+ );
124
+ }
@@ -17,6 +17,8 @@ export interface HandlerContext {
17
17
  */
18
18
  body: unknown;
19
19
  query: Record<string, string>;
20
+ /** `request.url`, parsed once for the whole dispatch. */
21
+ url: URL;
20
22
  set: ResponseSet;
21
23
  }
22
24
 
@@ -153,10 +155,15 @@ export class BosiaApp {
153
155
  const route = this.match(method, url.pathname);
154
156
  let response: unknown;
155
157
  if (route) {
158
+ let query: Record<string, string> | undefined;
156
159
  response = await route.handler({
157
160
  request,
158
161
  body: BODYLESS.has(method) || route.path === "*" ? undefined : await parseBody(request),
159
- query: Object.fromEntries(url.searchParams),
162
+ // Built on first read: the framework's "*" route never reads it.
163
+ get query() {
164
+ return (query ??= Object.fromEntries(url.searchParams));
165
+ },
166
+ url,
160
167
  set,
161
168
  });
162
169
  } else {
package/src/core/build.ts CHANGED
@@ -393,11 +393,16 @@ writeAppHtmlSegments(appHtml);
393
393
  // so they resolve against the app's node_modules at runtime.
394
394
  await bundleRuntimeUserFiles(process.cwd());
395
395
 
396
+ // `bosia dev` builds for its own server only, which serves neither prerendered
397
+ // pages nor dist/static (server.ts reads both from disk in prod only). Skipping
398
+ // them saves booting a second server to crawl routes on every rebuild.
399
+ const devBuild = process.env.BOSIA_DEV_BUILD === "1";
400
+
396
401
  // 9. Prerender static routes
397
- await prerenderStaticRoutes(manifest);
402
+ if (!devBuild) await prerenderStaticRoutes(manifest);
398
403
 
399
404
  // 10. Generate static site output (HTML + client assets + public → dist/static/)
400
- generateStaticSite();
405
+ if (!devBuild) generateStaticSite();
401
406
 
402
407
  // 10b. Precompress client assets + prerendered HTML (.br/.gz siblings) for the
403
408
  // Bun server. Runs after the static mirror so dist/static — the Workers upload