bosia 0.3.1 → 0.3.3

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.3.1",
3
+ "version": "0.3.3",
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/ambient.d.ts CHANGED
@@ -20,6 +20,7 @@ declare module "bosia:routes" {
20
20
  pageServer: Loader | null;
21
21
  layoutServers: { loader: Loader; depth: number }[];
22
22
  trailingSlash: TrailingSlash;
23
+ scope: "public" | "private";
23
24
  }>;
24
25
 
25
26
  export const apiRoutes: Array<{
package/src/cli/add.ts CHANGED
@@ -11,13 +11,13 @@ import {
11
11
  bunAdd,
12
12
  } from "./registry.ts";
13
13
 
14
- // ─── bosia add <component> ────────────────────────────────
14
+ // ─── bun x bosia@latest add <component> ──────────────────
15
15
  // Fetches a component from the GitHub registry (or local registry
16
16
  // with --local) and copies it into src/lib/components/<path>/.
17
17
  //
18
18
  // Path-based names:
19
- // bosia add button → src/lib/components/ui/button/
20
- // bosia add shop/cart → src/lib/components/shop/cart/
19
+ // bun x bosia@latest add button → src/lib/components/ui/button/
20
+ // bun x bosia@latest add shop/cart → src/lib/components/shop/cart/
21
21
 
22
22
  interface ComponentMeta {
23
23
  name: string;
@@ -48,7 +48,7 @@ export async function initAddRegistry(root: string | null) {
48
48
  export async function runAdd(name: string | undefined, flags: string[] = []) {
49
49
  if (!name) {
50
50
  console.error(
51
- "❌ Please provide a component name.\n Usage: bosia add <component> [--local]",
51
+ "❌ Please provide a component name.\n Usage: bun x bosia@latest add <component> [--local]",
52
52
  );
53
53
  process.exit(1);
54
54
  }
package/src/cli/create.ts CHANGED
@@ -5,7 +5,7 @@ import * as p from "@clack/prompts";
5
5
  import { installFeature, initFeatRegistry, resolveLocalRegistry } from "./feat.ts";
6
6
  import { initAddRegistry } from "./add.ts";
7
7
 
8
- // ─── bosia create <name> [--template <name>] ──────────────
8
+ // ─── bun x bosia@latest create <name> [--template <name>]
9
9
 
10
10
  const TEMPLATES_DIR = resolve(import.meta.dir, "../../templates");
11
11
  const BOSIA_PKG = JSON.parse(readFileSync(resolve(import.meta.dir, "../../package.json"), "utf-8"));
@@ -19,7 +19,9 @@ const TEMPLATE_DESCRIPTIONS: Record<string, string> = {
19
19
 
20
20
  export async function runCreate(name: string | undefined, args: string[] = []) {
21
21
  if (!name) {
22
- console.error("❌ Please provide a project name.\n Usage: bosia create my-app");
22
+ console.error(
23
+ "❌ Please provide a project name.\n Usage: bun x bosia@latest create my-app",
24
+ );
23
25
  process.exit(1);
24
26
  }
25
27
 
package/src/cli/feat.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  bunAdd,
12
12
  } from "./registry.ts";
13
13
 
14
- // ─── bosia feat <feature> [--local] ──────────────────────
14
+ // ─── bun x bosia@latest feat <feature> [--local] ─────────
15
15
  // Fetches a feature scaffold from the GitHub registry (or local
16
16
  // registry with --local) and copies route/lib files, installs npm deps.
17
17
  // Supports nested feature dependencies (e.g. todo → drizzle).
@@ -34,7 +34,7 @@ interface FeatureMeta {
34
34
  name: string;
35
35
  description: string;
36
36
  features?: string[]; // other bosia features required
37
- components: string[]; // bosia components to install via `bosia add`
37
+ components: string[]; // bosia components to install via `bun x bosia@latest add`
38
38
  files: FileEntry[]; // file entries with per-file strategy
39
39
  npmDeps: Record<string, string>;
40
40
  npmDevDeps?: Record<string, string>;
@@ -50,7 +50,7 @@ const installedFeats = new Set<string>();
50
50
  export async function runFeat(name: string | undefined, flags: string[] = []) {
51
51
  if (!name) {
52
52
  console.error(
53
- "❌ Please provide a feature name.\n Usage: bosia feat <feature> [--local]",
53
+ "❌ Please provide a feature name.\n Usage: bun x bosia@latest feat <feature> [--local]",
54
54
  );
55
55
  process.exit(1);
56
56
  }
package/src/cli/index.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  // ─── Bosia CLI ────────────────────────────────────────────
3
- // bosia create <name> scaffold a new project
4
- // bosia dev start the development server
5
- // bosia build build for production
6
- // bosia start run the production server
7
- // bosia add <name> add a UI component from the registry
8
- // bosia feat <name> add a feature scaffold from the registry
3
+ // bun x bosia@latest create <name> scaffold a new project
4
+ // bun x bosia dev start the development server
5
+ // bun x bosia build build for production
6
+ // bun x bosia start run the production server
7
+ // bun x bosia@latest add <name> add a UI component from the registry
8
+ // bun x bosia@latest feat <name> add a feature scaffold from the registry
9
9
 
10
10
  const [, , command, ...args] = process.argv;
11
11
 
@@ -67,17 +67,17 @@ Commands:
67
67
  feat <feature> Add a feature scaffold from the registry [--local]
68
68
 
69
69
  Examples:
70
- bosia create my-app
71
- bosia create my-app --template todo
72
- bosia dev
73
- bosia build
74
- bosia start
75
- bosia test
76
- bosia test --watch
77
- bosia test --coverage
78
- bosia add button → src/lib/components/ui/button/
79
- bosia add shop/cart → src/lib/components/shop/cart/
80
- bosia feat login
70
+ bun x bosia@latest create my-app
71
+ bun x bosia@latest create my-app --template todo
72
+ bun x bosia dev
73
+ bun x bosia build
74
+ bun x bosia start
75
+ bun x bosia test
76
+ bun x bosia test --watch
77
+ bun x bosia test --coverage
78
+ bun x bosia@latest add button → src/lib/components/ui/button/
79
+ bun x bosia@latest add shop/cart → src/lib/components/shop/cart/
80
+ bun x bosia@latest feat login
81
81
  `);
82
82
  break;
83
83
  }
package/src/core/dedup.ts CHANGED
@@ -1,32 +1,29 @@
1
- // packages/bosia/src/core/dedup.ts
2
- // Request deduplication for concurrent identical GET requests to /__bosia/data/
1
+ // ─── Request Deduplication ──────────────────────────────
2
+ // Concurrent in-flight coalescing for public routes. When N parallel requests
3
+ // hit the same URL, run the loader once and share the resolved value across
4
+ // all N waiters. Settled responses are NOT cached — once the promise resolves,
5
+ // the entry is dropped, so the next request runs the loader again.
6
+ //
7
+ // Scope decision lives in the scanner: routes under a `(private)` group skip
8
+ // dedup entirely. Per-user routes MUST be private — sharing a loader result
9
+ // across users would leak data. See docs/guides/request-deduplication.md.
3
10
 
4
- const inflight = new Map<string, Promise<any>>();
11
+ const inflight = new Map<string, Promise<unknown>>();
5
12
 
6
- const AUTH_COOKIE_RE = /(?:^|;\s*)authorization=([^;]*)/i;
7
-
8
- /** Build dedup key from route URL + request identity. Sort search params for consistency. */
9
- export function dedupKey(url: URL, request: Request): string {
13
+ /** Build a stable dedup key from a URL: normalized path + sorted query. */
14
+ export function dedupKey(url: URL): string {
10
15
  let path = url.pathname;
11
16
  if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
12
17
  const sorted = new URLSearchParams([...url.searchParams.entries()].sort());
13
18
  const search = sorted.toString();
14
- const base = search ? `${path}?${search}` : path;
15
-
16
- const authHeader = request.headers.get("authorization") ?? "";
17
- const cookieHeader = request.headers.get("cookie") ?? "";
18
- const match = cookieHeader.match(AUTH_COOKIE_RE);
19
- const authCookie = match?.[1] ?? "";
20
- const identity = authHeader || authCookie;
21
- if (!identity) return base;
22
- return `${base}|${Bun.hash(identity).toString(36)}`;
19
+ return search ? `${path}?${search}` : path;
23
20
  }
24
21
 
25
- /** Run `fn` with dedup. Concurrent calls with same key share the in-flight promise. */
22
+ /** Coalesce concurrent calls under `key`. `fn` runs at most once per inflight window. */
26
23
  export function dedup<T>(key: string, fn: () => Promise<T>): Promise<T> {
27
24
  const existing = inflight.get(key);
28
- if (existing) return existing;
25
+ if (existing) return existing as Promise<T>;
29
26
  const promise = fn().finally(() => inflight.delete(key));
30
27
  inflight.set(key, promise);
31
- return promise;
28
+ return promise as Promise<T>;
32
29
  }
package/src/core/dev.ts CHANGED
@@ -137,22 +137,21 @@ async function buildAndRestart() {
137
137
  }
138
138
  building = true;
139
139
  try {
140
- const ok = await runBuild();
141
- if (!ok) {
142
- console.error("❌ Build failed fix errors and save again");
143
- return;
144
- }
145
- await startAppServer();
146
- // Give the app server a moment to bind its port
147
- await Bun.sleep(200);
148
- broadcastReload();
140
+ do {
141
+ buildPending = false;
142
+ const ok = await runBuild();
143
+ if (!ok) {
144
+ console.error("❌ Build failed — fix errors and save again");
145
+ return;
146
+ }
147
+ await startAppServer();
148
+ // Give the app server a moment to bind its port
149
+ await Bun.sleep(200);
150
+ broadcastReload();
151
+ } while (buildPending);
149
152
  } finally {
150
153
  building = false;
151
154
  }
152
- if (buildPending) {
153
- buildPending = false;
154
- buildAndRestart();
155
- }
156
155
  }
157
156
 
158
157
  function scheduleBuild() {
@@ -266,3 +265,24 @@ watch(process.cwd(), { recursive: false }, (_event, filename) => {
266
265
  });
267
266
 
268
267
  console.log("👀 Watching src/ for changes...\n");
268
+
269
+ // ─── Shutdown ─────────────────────────────────────────────
270
+ // Own SIGINT/SIGTERM so we can cleanly stop the child app server.
271
+ // Without this, the terminal's ^C reaches both processes; the parent
272
+ // exits instantly while the child blocks in `app.stop()`, requiring
273
+ // a second ^C.
274
+
275
+ let shuttingDown = false;
276
+ async function shutdown() {
277
+ if (shuttingDown) process.exit(130);
278
+ shuttingDown = true;
279
+ intentionalKill = true;
280
+ if (appProcess) {
281
+ appProcess.kill("SIGTERM");
282
+ await Promise.race([appProcess.exited, Bun.sleep(2_500)]);
283
+ }
284
+ process.exit(0);
285
+ }
286
+
287
+ process.on("SIGINT", shutdown);
288
+ process.on("SIGTERM", shutdown);
@@ -1,4 +1,5 @@
1
1
  import { join, dirname } from "path";
2
+ import { existsSync } from "fs";
2
3
 
3
4
  // ─── Bun Build Plugin ─────────────────────────────────────
4
5
  // Resolves:
@@ -9,11 +10,18 @@ import { join, dirname } from "path";
9
10
  let cachedTsconfigPaths: Record<string, string[]> | null = null;
10
11
  async function getTsconfigPaths() {
11
12
  if (cachedTsconfigPaths !== null) return cachedTsconfigPaths;
13
+ const tsconfigPath = join(process.cwd(), "tsconfig.json");
14
+ if (!existsSync(tsconfigPath)) {
15
+ cachedTsconfigPaths = {};
16
+ return cachedTsconfigPaths;
17
+ }
12
18
  try {
13
- const tsconfig = await Bun.file(join(process.cwd(), "tsconfig.json")).json();
19
+ const tsconfig = await Bun.file(tsconfigPath).json();
14
20
  cachedTsconfigPaths = tsconfig?.compilerOptions?.paths || {};
15
- } catch {
16
- cachedTsconfigPaths = {};
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
+ );
17
25
  }
18
26
  return cachedTsconfigPaths!;
19
27
  }
@@ -1,9 +1,29 @@
1
1
  import { writeFileSync, mkdirSync, cpSync, existsSync } from "fs";
2
+ import { createServer } from "net";
2
3
  import { join } from "path";
3
4
  import type { RouteManifest, TrailingSlash } from "./types.ts";
4
5
 
5
6
  import { BOSIA_NODE_PATH } from "./paths.ts";
6
7
 
8
+ /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
9
+ export function getEphemeralPort(): Promise<number> {
10
+ return new Promise((resolve, reject) => {
11
+ const srv = createServer();
12
+ srv.unref();
13
+ srv.on("error", reject);
14
+ srv.listen(0, "127.0.0.1", () => {
15
+ const addr = srv.address();
16
+ if (!addr || typeof addr === "string") {
17
+ srv.close();
18
+ reject(new Error("Failed to acquire ephemeral port"));
19
+ return;
20
+ }
21
+ const port = addr.port;
22
+ srv.close(() => resolve(port));
23
+ });
24
+ });
25
+ }
26
+
7
27
  const CORE_DIR = import.meta.dir;
8
28
 
9
29
  const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
@@ -104,7 +124,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
104
124
 
105
125
  console.log(`\n🖨️ Prerendering ${targets.length} route(s)...`);
106
126
 
107
- const port = 13572;
127
+ const port = await getEphemeralPort();
108
128
  const child = Bun.spawn(["bun", "run", "./dist/server/index.js"], {
109
129
  env: {
110
130
  ...process.env,
@@ -116,78 +136,93 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
116
136
  stderr: "ignore",
117
137
  });
118
138
 
119
- // Poll /_health until ready (max 10s)
120
- const base = `http://localhost:${port}`;
121
- let ready = false;
122
- for (let i = 0; i < 50; i++) {
123
- await Bun.sleep(200);
124
- try {
125
- const res = await fetch(`${base}/_health`);
126
- if (res.ok) {
127
- ready = true;
128
- break;
139
+ const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
140
+ let receivedSignal: NodeJS.Signals | null = null;
141
+ const onSignal = (sig: NodeJS.Signals) => {
142
+ receivedSignal = sig;
143
+ };
144
+ for (const sig of signals) process.once(sig, onSignal);
145
+
146
+ try {
147
+ // Poll /_health until ready (max 10s)
148
+ const base = `http://localhost:${port}`;
149
+ let ready = false;
150
+ for (let i = 0; i < 50; i++) {
151
+ await Bun.sleep(200);
152
+ try {
153
+ const res = await fetch(`${base}/_health`);
154
+ if (res.ok) {
155
+ ready = true;
156
+ break;
157
+ }
158
+ } catch {
159
+ /* not ready yet */
129
160
  }
130
- } catch {
131
- /* not ready yet */
132
161
  }
133
- }
134
162
 
135
- if (!ready) {
136
- child.kill();
137
- console.error("❌ Prerender server failed to start");
138
- return;
139
- }
163
+ if (!ready) {
164
+ console.error("❌ Prerender server failed to start");
165
+ return;
166
+ }
167
+
168
+ mkdirSync("./dist/prerendered", { recursive: true });
140
169
 
141
- mkdirSync("./dist/prerendered", { recursive: true });
142
-
143
- for (const { path: routePath, trailingSlash: ts } of targets) {
144
- try {
145
- // Hit the canonical URL so the server doesn't 308 us mid-prerender
146
- const canonicalRoute = canonicalRouteFor(routePath, ts);
147
-
148
- const res = await fetch(`${base}${canonicalRoute}`, {
149
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
150
- });
151
- const html = await res.text();
152
-
153
- // Filename strategy:
154
- // never → about.html (canonical /about, served by static host as /about → about.html)
155
- // always → about/index.html (canonical /about/, static host serves /about/ → about/index.html)
156
- // ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
157
- // root → index.html
158
- const outPath = prerenderOutPath(routePath, ts);
159
- mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
160
- writeFileSync(outPath, html);
161
-
162
- // Also prerender the data payload (filename matches dataUrl() — strips trailing slash)
163
- const dataPath = prerenderDataPath(routePath);
164
- const dataRes = await fetch(`${base}/__bosia/data${dataPath}`, {
165
- signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
166
- });
167
- if (dataRes.ok) {
168
- const dataJson = await dataRes.text();
169
- const dataOutPath = `./dist/prerendered/__bosia/data${dataPath}`;
170
- mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
171
- recursive: true,
170
+ for (const { path: routePath, trailingSlash: ts } of targets) {
171
+ try {
172
+ // Hit the canonical URL so the server doesn't 308 us mid-prerender
173
+ const canonicalRoute = canonicalRouteFor(routePath, ts);
174
+
175
+ const res = await fetch(`${base}${canonicalRoute}`, {
176
+ signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
172
177
  });
173
- writeFileSync(dataOutPath, dataJson);
174
- console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
175
- } else {
176
- console.log(` ✅ ${routePath}${outPath}`);
177
- }
178
- } catch (err) {
179
- if (err instanceof DOMException && err.name === "TimeoutError") {
180
- console.error(
181
- ` ❌ Prerender timed out for ${routePath} after ${PRERENDER_TIMEOUT / 1000}s — increase PRERENDER_TIMEOUT to raise the limit`,
182
- );
183
- } else {
184
- console.error(` ❌ Failed to prerender ${routePath}:`, err);
178
+ const html = await res.text();
179
+
180
+ // Filename strategy:
181
+ // never → about.html (canonical /about, served by static host as /about about.html)
182
+ // always → about/index.html (canonical /about/, static host serves /about/ → about/index.html)
183
+ // ignore about/index.html (single emit; both URLs resolve via server canonicalize=off)
184
+ // root → index.html
185
+ const outPath = prerenderOutPath(routePath, ts);
186
+ mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
187
+ writeFileSync(outPath, html);
188
+
189
+ // Also prerender the data payload (filename matches dataUrl() strips trailing slash)
190
+ const dataPath = prerenderDataPath(routePath);
191
+ const dataRes = await fetch(`${base}/__bosia/data${dataPath}`, {
192
+ signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
193
+ });
194
+ if (dataRes.ok) {
195
+ const dataJson = await dataRes.text();
196
+ const dataOutPath = `./dist/prerendered/__bosia/data${dataPath}`;
197
+ mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
198
+ recursive: true,
199
+ });
200
+ writeFileSync(dataOutPath, dataJson);
201
+ console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
202
+ } else {
203
+ console.log(` ✅ ${routePath} → ${outPath}`);
204
+ }
205
+ } catch (err) {
206
+ if (err instanceof DOMException && err.name === "TimeoutError") {
207
+ console.error(
208
+ ` ❌ Prerender timed out for ${routePath} after ${PRERENDER_TIMEOUT / 1000}s — increase PRERENDER_TIMEOUT to raise the limit`,
209
+ );
210
+ } else {
211
+ console.error(` ❌ Failed to prerender ${routePath}:`, err);
212
+ }
185
213
  }
186
214
  }
187
- }
188
215
 
189
- child.kill();
190
- console.log("✅ Prerendering complete");
216
+ console.log("✅ Prerendering complete");
217
+ } finally {
218
+ for (const sig of signals) process.off(sig, onSignal);
219
+ child.kill();
220
+ await child.exited;
221
+ if (receivedSignal) {
222
+ // Re-raise so parent exit code is 128+signum (standard Unix convention)
223
+ process.kill(process.pid, receivedSignal);
224
+ }
225
+ }
191
226
  }
192
227
 
193
228
  // ─── Static Site Output ──────────────────────────────────
@@ -276,65 +276,73 @@ export async function renderSSRStream(
276
276
 
277
277
  const enc = new TextEncoder();
278
278
 
279
+ // ssr=false → no render() needed; ship shell + hydration as a single response.
280
+ // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
281
+ if (!data.ssr) {
282
+ if (!data.csr && isDev) {
283
+ console.warn(
284
+ `⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
285
+ );
286
+ }
287
+ const html =
288
+ buildHtmlShellOpen(metadata?.lang) +
289
+ buildMetadataChunk(metadata) +
290
+ buildHtmlTail("", "", data.pageData, data.layoutData, true, null, false);
291
+ return new Response(html, {
292
+ headers: { "Content-Type": "text/html; charset=utf-8" },
293
+ });
294
+ }
295
+
296
+ // Render-first: run render() before committing to a 200. Failure → proper error page
297
+ // with correct status code, instead of a bare <p> mixed into an already-flushed shell.
298
+ let body: string, head: string;
299
+ try {
300
+ ({ body, head } = render(App, {
301
+ props: {
302
+ ssrMode: true,
303
+ ssrPageComponent: pageMod.default,
304
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
305
+ ssrPageData: data.pageData,
306
+ ssrLayoutData: data.layoutData,
307
+ },
308
+ }));
309
+ } catch (err) {
310
+ if (isDev) console.error("SSR render error:", err);
311
+ else console.error("SSR render error:", (err as Error).message ?? err);
312
+ return renderErrorPage(500, "Internal Server Error", url, req);
313
+ }
314
+
315
+ // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
316
+ const chunks: Uint8Array[] = [
317
+ enc.encode(buildHtmlShellOpen(metadata?.lang)),
318
+ enc.encode(buildMetadataChunk(metadata)),
319
+ enc.encode(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)),
320
+ ];
321
+
322
+ let i = 0;
323
+ let cancelled = false;
324
+ const onAbort = () => {
325
+ cancelled = true;
326
+ };
327
+ req.signal.addEventListener("abort", onAbort, { once: true });
328
+
279
329
  const stream = new ReadableStream<Uint8Array>({
280
- async start(controller) {
281
- // Chunk 1: head opening (CSS, modulepreload cached per lang)
282
- controller.enqueue(enc.encode(buildHtmlShellOpen(metadata?.lang)));
283
-
284
- // Chunk 2: metadata tags, close </head>, open <body> + spinner
285
- controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
286
-
287
- try {
288
- if (!data!.ssr) {
289
- // ssr=false → skip render(); ship empty shell + hydration scripts.
290
- // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
291
- if (!data!.csr && isDev) {
292
- console.warn(
293
- `⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
294
- );
295
- }
296
- controller.enqueue(
297
- enc.encode(
298
- buildHtmlTail(
299
- "",
300
- "",
301
- data!.pageData,
302
- data!.layoutData,
303
- true,
304
- null,
305
- false,
306
- ),
307
- ),
308
- );
309
- controller.close();
310
- return;
311
- }
312
-
313
- const { body, head } = render(App, {
314
- props: {
315
- ssrMode: true,
316
- ssrPageComponent: pageMod.default,
317
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
318
- ssrPageData: data!.pageData,
319
- ssrLayoutData: data!.layoutData,
320
- },
321
- });
322
-
323
- // Chunk 3: rendered content
324
- controller.enqueue(
325
- enc.encode(
326
- buildHtmlTail(body, head, data!.pageData, data!.layoutData, data!.csr),
327
- ),
328
- );
330
+ pull(controller) {
331
+ if (cancelled || i >= chunks.length) {
329
332
  controller.close();
330
- } catch (err) {
331
- // Only render() can throw here — data is already loaded successfully
332
- if (isDev) console.error("SSR render error:", err);
333
- else console.error("SSR render error:", (err as Error).message ?? err);
334
- controller.enqueue(enc.encode(`<p>Internal Server Error</p></body></html>`));
333
+ req.signal.removeEventListener("abort", onAbort);
334
+ return;
335
+ }
336
+ controller.enqueue(chunks[i++]);
337
+ if (i >= chunks.length) {
335
338
  controller.close();
339
+ req.signal.removeEventListener("abort", onAbort);
336
340
  }
337
341
  },
342
+ cancel() {
343
+ cancelled = true;
344
+ req.signal.removeEventListener("abort", onAbort);
345
+ },
338
346
  });
339
347
 
340
348
  return new Response(stream, {
@@ -63,6 +63,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
63
63
  lines.push(" pageServer: (() => Promise<any>) | null;");
64
64
  lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
65
65
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
66
+ lines.push(' scope: "public" | "private";');
66
67
  lines.push("}> = [");
67
68
  for (const r of pages) {
68
69
  const layoutImports = r.layouts
@@ -83,6 +84,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
83
84
  );
84
85
  lines.push(` layoutServers: [${layoutServerImports}],`);
85
86
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
87
+ lines.push(` scope: ${JSON.stringify(r.scope)},`);
86
88
  lines.push(" },");
87
89
  }
88
90
  lines.push("];\n");
@@ -50,6 +50,8 @@ export function generateRouteTypes(manifest: RouteManifest): void {
50
50
  }
51
51
  }
52
52
 
53
+ if (manifest.errorPage && !dirs.has(".")) dirs.set(".", {});
54
+
53
55
  for (const [dir, info] of dirs) {
54
56
  // Path segments of the route dir (empty array for root ".")
55
57
  const segments = dir === "." ? [] : dir.split("/").filter(Boolean);
@@ -96,6 +98,12 @@ export function generateRouteTypes(manifest: RouteManifest): void {
96
98
  }
97
99
  lines.push(`export type PageProps = { data: PageData };`);
98
100
 
101
+ if (dir === "." && manifest.errorPage) {
102
+ lines.push(``);
103
+ lines.push(`export type PageError = { status: number; message: string };`);
104
+ lines.push(`export type ErrorProps = { error: PageError };`);
105
+ }
106
+
99
107
  // ActionData — union of all action return types, unwrapping ActionFailure
100
108
  if (info.pageServer) {
101
109
  lines.push(``);
@@ -144,12 +152,10 @@ export function ensureRootDirs(): void {
144
152
  let tsconfig: any;
145
153
  try {
146
154
  tsconfig = JSON.parse(readFileSync(tsconfigPath, "utf-8"));
147
- } catch {
148
- console.warn(
149
- "⚠️ Could not parse tsconfig.json add rootDirs manually:\n" +
150
- ' "rootDirs": [".", ".bosia/types"]',
155
+ } catch (err) {
156
+ throw new Error(
157
+ `tsconfig.json at ${tsconfigPath} is invalid JSON: ${(err as Error).message}. Fix the file and re-run.`,
151
158
  );
152
- return;
153
159
  }
154
160
 
155
161
  const rootDirs: string[] = tsconfig.compilerOptions?.rootDirs ?? [];
@@ -44,6 +44,7 @@ export function scanRoutes(): RouteManifest {
44
44
  layoutChain: string[],
45
45
  layoutServerChain: { path: string; depth: number }[],
46
46
  inheritedTrailingSlash: TrailingSlash,
47
+ inheritedScope: "public" | "private",
47
48
  ) {
48
49
  const fullDir = join(ROUTES_DIR, dir);
49
50
  if (!existsSync(fullDir)) return;
@@ -94,6 +95,7 @@ export function scanRoutes(): RouteManifest {
94
95
  pageServer: pageServerFile,
95
96
  layoutServers: [...currentLayoutServers],
96
97
  trailingSlash: effectiveTs,
98
+ scope: inheritedScope,
97
99
  });
98
100
  }
99
101
 
@@ -105,6 +107,9 @@ export function scanRoutes(): RouteManifest {
105
107
  const dirName = entry.name;
106
108
  // Route groups like (public), (auth) are invisible in URLs
107
109
  const isGroup = /^\(.*\)$/.test(dirName);
110
+ // `(private)` anywhere in the chain marks descendants as per-user (dedup off)
111
+ const childScope: "public" | "private" =
112
+ inheritedScope === "private" || dirName === "(private)" ? "private" : "public";
108
113
 
109
114
  walk(
110
115
  dir ? join(dir, dirName) : dirName,
@@ -112,11 +117,12 @@ export function scanRoutes(): RouteManifest {
112
117
  currentLayouts,
113
118
  currentLayoutServers,
114
119
  currentTrailingSlash,
120
+ childScope,
115
121
  );
116
122
  }
117
123
  }
118
124
 
119
- walk("", [], [], [], "never");
125
+ walk("", [], [], [], "never", "public");
120
126
 
121
127
  // Warn when a catch-all exists but no exact route covers its prefix.
122
128
  // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
@@ -17,6 +17,7 @@ import type { CsrfConfig } from "./csrf.ts";
17
17
  import { getCorsHeaders, handlePreflight } from "./cors.ts";
18
18
  import type { CorsConfig } from "./cors.ts";
19
19
  import { isDev, compress, isStaticPath } from "./html.ts";
20
+ import { dedup, dedupKey } from "./dedup.ts";
20
21
  import {
21
22
  loadRouteData,
22
23
  loadMetadata,
@@ -25,7 +26,6 @@ import {
25
26
  renderPageWithFormData,
26
27
  } from "./renderer.ts";
27
28
  import { getServerTime } from "../lib/utils.ts";
28
- import { dedup, dedupKey } from "./dedup.ts";
29
29
 
30
30
  // ─── User Hooks ──────────────────────────────────────────
31
31
  // Load src/hooks.server.ts if present. Uses process.cwd() so
@@ -148,10 +148,9 @@ async function resolve(event: RequestEvent): Promise<Response> {
148
148
  }
149
149
  // Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
150
150
  event.url = routeUrl;
151
- const dedupKeyStr = dedupKey(routeUrl, request);
152
151
  try {
153
- const result = await dedup(dedupKeyStr, async () => {
154
- const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
152
+ const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
153
+ const runLoad = async () => {
155
154
  const data = await loadRouteData(routeUrl, locals, request, cookies);
156
155
 
157
156
  let metadata = null;
@@ -172,7 +171,14 @@ async function resolve(event: RequestEvent): Promise<Response> {
172
171
  }
173
172
 
174
173
  return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
175
- });
174
+ };
175
+
176
+ // Dedup public routes by URL only. `(private)` scope routes (per-user content)
177
+ // skip the cache to prevent cross-user data leaks. See dedup.ts.
178
+ const result =
179
+ pageMatch?.route.scope === "private"
180
+ ? await runLoad()
181
+ : await dedup(dedupKey(routeUrl), runLoad);
176
182
 
177
183
  const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
178
184
  const cc = cookiesWereAccessed
@@ -398,7 +404,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
398
404
  { status: result.status },
399
405
  );
400
406
  }
401
- return renderPageWithFormData(
407
+ return await renderPageWithFormData(
402
408
  url,
403
409
  locals,
404
410
  request,
@@ -416,7 +422,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
416
422
  data: result ?? null,
417
423
  });
418
424
  }
419
- return renderPageWithFormData(
425
+ return await renderPageWithFormData(
420
426
  url,
421
427
  locals,
422
428
  request,
@@ -649,8 +655,8 @@ async function shutdown() {
649
655
  console.log("✅ All requests drained");
650
656
  }
651
657
 
652
- app.stop().then(() => process.exit(0));
653
- setTimeout(() => process.exit(1), 5_000);
658
+ app.stop(true).then(() => process.exit(0));
659
+ setTimeout(() => process.exit(1), 2_000);
654
660
  }
655
661
 
656
662
  process.on("SIGTERM", shutdown);
package/src/core/types.ts CHANGED
@@ -17,6 +17,12 @@ export interface PageRoute {
17
17
  layoutServers: { path: string; depth: number }[];
18
18
  /** Effective trailing-slash mode (page wins over layout chain). Defaults to "never". */
19
19
  trailingSlash: TrailingSlash;
20
+ /**
21
+ * Dedup scope. `"public"` (default) → loader runs once for concurrent identical
22
+ * URLs. `"private"` → loader runs per-request (use for per-user routes).
23
+ * Set by placing the route under a `(private)` group folder anywhere in the chain.
24
+ */
25
+ scope: "public" | "private";
20
26
  }
21
27
 
22
28
  /** An API route discovered from the file system */
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
- let { error }: { error: { status: number; message: string } } = $props();
2
+ import type { ErrorProps } from "./$types";
3
+ let { error }: ErrorProps = $props();
3
4
  </script>
4
5
 
5
6
  <svelte:head>
@@ -26,7 +26,7 @@
26
26
  {
27
27
  icon: "🧩",
28
28
  label: "Component registry",
29
- desc: "bosia add button — shadcn-style, code you own",
29
+ desc: "bun x bosia@latest add button — shadcn-style, code you own",
30
30
  },
31
31
  {
32
32
  icon: "✨",
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <ul class="space-y-2 text-foreground">
12
- {#each ["Bun runtime — fast builds, native TypeScript", "ElysiaJS — HTTP server with type-safe routing", "Svelte 5 Runes — fine-grained reactivity", "Isomorphic SSR with client hydration", "File-based routing (SvelteKit-compatible conventions)", "Nested layouts and route groups (public), (auth), (admin)", "Dynamic params [slug] and catch-all [...rest]", "Server loaders with parent() data threading", "Hooks — sequence() middleware for auth, logging, etc.", "Component registry — bosia add button", "Feature registry — bosia feat login"] as item}
12
+ {#each ["Bun runtime — fast builds, native TypeScript", "ElysiaJS — HTTP server with type-safe routing", "Svelte 5 Runes — fine-grained reactivity", "Isomorphic SSR with client hydration", "File-based routing (SvelteKit-compatible conventions)", "Nested layouts and route groups (public), (auth), (admin)", "Dynamic params [slug] and catch-all [...rest]", "Server loaders with parent() data threading", "Hooks — sequence() middleware for auth, logging, etc.", "Component registry — bun x bosia@latest add button", "Feature registry — bosia feat login"] as item}
13
13
  <li class="flex items-start gap-2">
14
14
  <span class="text-primary mt-0.5">✓</span>
15
15
  <span>{item}</span>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
- let { error }: { error: { status: number; message: string } } = $props();
2
+ import type { ErrorProps } from "./$types";
3
+ let { error }: ErrorProps = $props();
3
4
  </script>
4
5
 
5
6
  <svelte:head>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
- let { error }: { error: { status: number; message: string } } = $props();
2
+ import type { ErrorProps } from "./$types";
3
+ let { error }: ErrorProps = $props();
3
4
  </script>
4
5
 
5
6
  <svelte:head>