bosia 0.3.0 → 0.3.2

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.0",
3
+ "version": "0.3.2",
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/dev.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  import { spawn } from "bun";
2
2
  import { resolve } from "path";
3
- import { loadEnv } from "../core/env.ts";
4
3
 
5
4
  export async function runDev() {
6
- loadEnv("development");
7
5
  const devScript = resolve(import.meta.dir, "../core/dev.ts");
8
6
  const proc = spawn(["bun", "run", devScript], {
9
7
  stdout: "inherit",
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
@@ -1,6 +1,23 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
2
  import { watch } from "fs";
3
3
  import { join } from "path";
4
+ import { loadEnv, resetDeclaredKeys } from "./env.ts";
5
+
6
+ // Snapshot pure shell env BEFORE any loadEnv call pollutes process.env.
7
+ // On `.env*` change we restore from this snapshot, then re-run loadEnv,
8
+ // so removed/renamed keys no longer linger in the dev process.
9
+ const SHELL_ENV_SNAPSHOT: Record<string, string | undefined> = { ...process.env };
10
+
11
+ loadEnv("development");
12
+
13
+ function reloadEnv() {
14
+ for (const k of Object.keys(process.env)) delete process.env[k];
15
+ for (const [k, v] of Object.entries(SHELL_ENV_SNAPSHOT)) {
16
+ if (v !== undefined) process.env[k] = v;
17
+ }
18
+ resetDeclaredKeys();
19
+ loadEnv("development");
20
+ }
4
21
 
5
22
  console.log("⬡ Bosia dev server starting...\n");
6
23
 
@@ -120,22 +137,21 @@ async function buildAndRestart() {
120
137
  }
121
138
  building = true;
122
139
  try {
123
- const ok = await runBuild();
124
- if (!ok) {
125
- console.error("❌ Build failed fix errors and save again");
126
- return;
127
- }
128
- await startAppServer();
129
- // Give the app server a moment to bind its port
130
- await Bun.sleep(200);
131
- 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);
132
152
  } finally {
133
153
  building = false;
134
154
  }
135
- if (buildPending) {
136
- buildPending = false;
137
- buildAndRestart();
138
- }
139
155
  }
140
156
 
141
157
  function scheduleBuild() {
@@ -234,4 +250,39 @@ watch(join(process.cwd(), "src"), { recursive: true }, (_event, filename) => {
234
250
  scheduleBuild();
235
251
  });
236
252
 
253
+ // ─── .env Watcher ─────────────────────────────────────────
254
+ // Reset to shell-env snapshot and re-run loadEnv so removed/renamed
255
+ // keys don't linger across hot-reloads. The respawn at startAppServer
256
+ // already spreads `...process.env`, so the child picks up the fresh state.
257
+
258
+ const ENV_FILES = new Set([".env", ".env.local", ".env.development", ".env.development.local"]);
259
+
260
+ watch(process.cwd(), { recursive: false }, (_event, filename) => {
261
+ if (!filename || !ENV_FILES.has(filename)) return;
262
+ console.log(`[watch] env changed: ${filename}`);
263
+ reloadEnv();
264
+ scheduleBuild();
265
+ });
266
+
237
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);
package/src/core/env.ts CHANGED
@@ -175,6 +175,11 @@ export function getDeclaredEnvKeys(): ReadonlySet<string> {
175
175
  return _declaredKeys;
176
176
  }
177
177
 
178
+ /** Clear the declared-keys set. Call before re-running `loadEnv` on hot-reload so removed PUBLIC_* keys stop leaking to the client. */
179
+ export function resetDeclaredKeys(): void {
180
+ _declaredKeys.clear();
181
+ }
182
+
178
183
  // ─── Classifier ──────────────────────────────────────────
179
184
 
180
185
  export interface ClassifiedEnv {
@@ -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");
@@ -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
@@ -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,11 +1,7 @@
1
- <script lang="ts">
2
- const name = "{{PROJECT_NAME}}";
3
- </script>
4
-
5
1
  <main class="flex min-h-screen flex-col items-center justify-center gap-6 p-8">
6
2
  <div class="flex flex-col items-center gap-3 text-center">
7
3
  <img src="/favicon.svg" alt="" class="size-16" />
8
- <h1 class="text-4xl font-bold tracking-tight">{name}</h1>
4
+ <h1 class="text-4xl font-bold tracking-tight">Welcome to Bosia</h1>
9
5
  <p class="text-muted-foreground text-lg">
10
6
  A Bosia project — SSR + Svelte 5 + Bun + ElysiaJS
11
7
  </p>
@@ -1,5 +1,5 @@
1
1
  <svelte:head>
2
- <title>About | {{ PROJECT_NAME }}</title>
2
+ <title>About | Bosia</title>
3
3
  </svelte:head>
4
4
 
5
5
  <div>
@@ -8,7 +8,7 @@
8
8
  <header class="sticky top-0 z-10 border-b bg-background/80 backdrop-blur">
9
9
  <nav class="mx-auto flex max-w-4xl items-center gap-6 px-4 py-3">
10
10
  <a href="/" class="font-bold tracking-tight flex items-center gap-2"
11
- ><img src="/favicon.svg" alt="" class="size-5" /> {{ PROJECT_NAME }}</a
11
+ ><img src="/favicon.svg" alt="" class="size-5" /> {data.appName}</a
12
12
  >
13
13
  <a
14
14
  href="/"
@@ -37,8 +37,8 @@
37
37
  </script>
38
38
 
39
39
  <svelte:head>
40
- <title>{{ PROJECT_NAME }}</title>
41
- <meta name="description" content="{{ PROJECT_NAME }} — SSR + Svelte 5 + Bun + ElysiaJS" />
40
+ <title>Bosia Demo</title>
41
+ <meta name="description" content="Bosia Demo — SSR + Svelte 5 + Bun + ElysiaJS" />
42
42
  </svelte:head>
43
43
 
44
44
  <div class="space-y-12">
@@ -46,7 +46,7 @@
46
46
  <div class="space-y-3 pt-4">
47
47
  <h1 class="text-5xl font-bold tracking-tight flex items-center gap-3">
48
48
  <img src="/favicon.svg" alt="" class="size-10" />
49
- {{ PROJECT_NAME }}
49
+ Bosia Demo
50
50
  </h1>
51
51
  <p class="text-xl text-muted-foreground max-w-xl">
52
52
  A minimalist fullstack framework — SSR, Svelte 5 Runes, Bun, and ElysiaJS.
@@ -1,9 +1,9 @@
1
1
  <svelte:head>
2
- <title>About | {{ PROJECT_NAME }}</title>
2
+ <title>About | Bosia Demo</title>
3
3
  </svelte:head>
4
4
 
5
5
  <div class="space-y-6 max-w-2xl">
6
- <h1 class="text-4xl font-bold tracking-tight">About {{ PROJECT_NAME }}</h1>
6
+ <h1 class="text-4xl font-bold tracking-tight">About Bosia Demo</h1>
7
7
  <p class="text-muted-foreground text-lg">
8
8
  A minimalist fullstack framework built on Bun, ElysiaJS, and Svelte 5.
9
9
  </p>
@@ -4,7 +4,7 @@
4
4
  </script>
5
5
 
6
6
  <svelte:head>
7
- <title>Catch-all Demo | {{ PROJECT_NAME }}</title>
7
+ <title>Catch-all Demo | Bosia Demo</title>
8
8
  </svelte:head>
9
9
 
10
10
  <div class="flex flex-col items-center justify-center py-24 text-center space-y-6">
@@ -26,7 +26,7 @@
26
26
  </script>
27
27
 
28
28
  <svelte:head>
29
- <title>Blog | {{ PROJECT_NAME }}</title>
29
+ <title>Blog | Bosia Demo</title>
30
30
  </svelte:head>
31
31
 
32
32
  <div class="space-y-8">
@@ -8,7 +8,7 @@
8
8
  </script>
9
9
 
10
10
  <svelte:head>
11
- <title>{post ? post.title : "Post Not Found"} | {{ PROJECT_NAME }}</title>
11
+ <title>{post ? post.title : "Post Not Found"} | Bosia Demo</title>
12
12
  </svelte:head>
13
13
 
14
14
  {#if post}
@@ -4,7 +4,7 @@ import type { LoadEvent } from "bosia";
4
4
  // and to all layouts via the `data` prop.
5
5
  export async function load({ locals }: LoadEvent) {
6
6
  return {
7
- appName: "{{PROJECT_NAME}}",
7
+ appName: "Bosia Demo",
8
8
  requestTime: (locals.requestTime as number | null) ?? null,
9
9
  };
10
10
  }
@@ -2,7 +2,7 @@ import type { LoadEvent } from "bosia";
2
2
 
3
3
  export async function load({ locals }: LoadEvent) {
4
4
  return {
5
- appName: "{{PROJECT_NAME}}",
5
+ appName: "Bosia Todo",
6
6
  requestTime: (locals.requestTime as number | null) ?? null,
7
7
  };
8
8
  }