bosia 0.8.2 → 0.8.4

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
@@ -18,6 +18,20 @@ File-based routing inspired by SvelteKit, built on top of the Bun runtime and El
18
18
  - **Dev server with HMR** — file watcher + SSE browser reload, no page blink
19
19
  - **Tailwind CSS v4** — compiled at build time, shadcn-inspired design tokens out of the box
20
20
  - **CLI** — `bosia create`, `bosia dev`, `bosia build`, `bosia add`, `bosia feat`
21
+ - **UI registry** — shadcn-style, copy-paste source you own: **60 components**, **100+ blocks**, **10 pages**, **19 themes**
22
+
23
+ ## UI Registry
24
+
25
+ `bosia add` copies source directly into your project — no runtime package to import:
26
+
27
+ ```bash
28
+ bun x bosia@latest add button card data-table # 60 primitives
29
+ bun x bosia@latest add block heros/product # 100+ composed sections
30
+ bun x bosia@latest add page storefront/home # 10 ready-to-wire pages
31
+ bun x bosia@latest add theme editorial # 19 token themes
32
+ ```
33
+
34
+ See the [registry docs](https://bosia.dev) for the full catalog.
21
35
 
22
36
  ## Quick Start
23
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
package/src/ambient.d.ts CHANGED
@@ -20,7 +20,6 @@ declare module "bosia:routes" {
20
20
  pageServer: Loader | null;
21
21
  layoutServers: { loader: Loader; depth: number }[];
22
22
  trailingSlash: TrailingSlash;
23
- scope: "public" | "private";
24
23
  }>;
25
24
 
26
25
  export const apiRoutes: Array<{
package/src/cli/dev.ts CHANGED
@@ -8,5 +8,9 @@ export async function runDev() {
8
8
  stderr: "inherit",
9
9
  cwd: process.cwd(),
10
10
  });
11
+ // Survive ^C so we keep waiting for the child instead of orphaning it
12
+ // mid-shutdown. The terminal delivers SIGINT to the whole process group,
13
+ // so the child already gets the signal — no forwarding needed.
14
+ for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => {});
11
15
  await proc.exited;
12
16
  }
package/src/cli/start.ts CHANGED
@@ -22,5 +22,9 @@ export async function runStart() {
22
22
  },
23
23
  });
24
24
 
25
+ // Survive ^C so we keep waiting for the child instead of orphaning it
26
+ // mid-drain. The terminal delivers SIGINT to the whole process group,
27
+ // so the child already gets the signal — no forwarding needed.
28
+ for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => {});
25
29
  await proc.exited;
26
30
  }
package/src/cli/sync.ts CHANGED
@@ -3,6 +3,7 @@ import { generateRoutesFile } from "../core/routeFile.ts";
3
3
  import { generateRouteTypes, ensureRootDirs } from "../core/routeTypes.ts";
4
4
  import { loadEnv, classifyEnvVars } from "../core/env.ts";
5
5
  import { generateEnvModules } from "../core/envCodegen.ts";
6
+ import { findBrandPlaceholders, BRAND_SENTINEL } from "../core/brandGuard.ts";
6
7
 
7
8
  export async function runSync() {
8
9
  const envMode = process.env.NODE_ENV === "production" ? "production" : "development";
@@ -13,4 +14,14 @@ export async function runSync() {
13
14
  ensureRootDirs();
14
15
  generateEnvModules(classifiedEnv);
15
16
  console.log("✅ Bosia codegen ready (.bosia/routes.ts, types, env modules)");
17
+
18
+ const brandHits = findBrandPlaceholders();
19
+ if (brandHits.length > 0) {
20
+ console.error(
21
+ `\n❌ ${BRAND_SENTINEL} placeholder left un-replaced in ${brandHits.length} spot(s):`,
22
+ );
23
+ for (const { file, line } of brandHits) console.error(` • ${file}:${line}`);
24
+ console.error(`\nReplace ${BRAND_SENTINEL} with the app's name (nav/header/footer).\n`);
25
+ process.exit(1);
26
+ }
16
27
  }
@@ -0,0 +1,36 @@
1
+ import { readdirSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ // Registry blocks (navbars, storefront header/footer) ship the brand name as the
5
+ // literal `__BRAND__` sentinel so it's unmistakably a placeholder. This guard fails
6
+ // `bosia sync` (and therefore `bun run check`) if any survive un-replaced in an app —
7
+ // otherwise a fresh scaffold ships someone else's brand in its nav/header/footer.
8
+ export const BRAND_SENTINEL = "__BRAND__";
9
+
10
+ const SRC_DIR = "./src";
11
+ const TEXT_EXT = /\.(svelte|ts|js|html|md|json)$/;
12
+
13
+ export function findBrandPlaceholders(srcDir = SRC_DIR): { file: string; line: number }[] {
14
+ const hits: { file: string; line: number }[] = [];
15
+ let entries: string[];
16
+ try {
17
+ entries = readdirSync(srcDir, { recursive: true }) as string[];
18
+ } catch {
19
+ return hits; // no src/ (e.g. non-app cwd) → nothing to guard
20
+ }
21
+ for (const rel of entries) {
22
+ if (!TEXT_EXT.test(rel)) continue;
23
+ const path = join(srcDir, rel);
24
+ let contents: string;
25
+ try {
26
+ contents = readFileSync(path, "utf-8");
27
+ } catch {
28
+ continue; // directory or unreadable
29
+ }
30
+ if (!contents.includes(BRAND_SENTINEL)) continue;
31
+ contents.split("\n").forEach((l, i) => {
32
+ if (l.includes(BRAND_SENTINEL)) hits.push({ file: path, line: i + 1 });
33
+ });
34
+ }
35
+ return hits;
36
+ }
package/src/core/cache.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  //
6
6
  // See docs/guides/response-cache.md.
7
7
 
8
+ import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
8
9
  import type { Cookies, LoaderDeps } from "./hooks.ts";
10
+ import type { CookieJar } from "./cookies.ts";
9
11
  import { dedupKey } from "./dedup.ts";
10
12
 
11
13
  // ─── Config ──────────────────────────────────────────────
@@ -28,22 +30,31 @@ function parseMaxEntries(raw: string | undefined): number {
28
30
  return n;
29
31
  }
30
32
 
31
- // `invalidate` / `invalidateAll` are re-exported from the public `bosia`
32
- // barrel, so this module also evaluates in the browser bundle. Guard every
33
- // `process.env` read — otherwise hydration throws `ReferenceError: Can't
34
- // find variable: process` (Safari) the moment the barrel is imported.
33
+ function parseMaxBodyBytes(raw: string | undefined): number {
34
+ const DEFAULT = 2_097_152; // 2MB; 0 = unlimited
35
+ if (!raw) return DEFAULT;
36
+ const n = parseInt(raw, 10);
37
+ if (!Number.isFinite(n) || n < 0) return DEFAULT;
38
+ return n;
39
+ }
40
+
41
+ // Server-only module (imported by core/renderer.ts, core/server.ts,
42
+ // lib/server.ts — never the client barrel; client `invalidate` lives in
43
+ // core/client/navigation.ts). The `process` guards below are kept as
44
+ // defense in case a future refactor pulls this into a browser bundle.
35
45
  const env: Record<string, string | undefined> =
36
46
  typeof process !== "undefined" && process.env ? process.env : {};
37
47
  const isServer = typeof process !== "undefined";
38
48
 
39
49
  export const CACHE_KEYS: readonly string[] = parseCacheKeys(env.CACHE_KEYS);
40
50
  export const CACHE_MAX_ENTRIES = parseMaxEntries(env.CACHE_MAX_ENTRIES);
51
+ export const CACHE_MAX_BODY_BYTES = parseMaxBodyBytes(env.CACHE_MAX_BODY_BYTES);
41
52
  export const CACHE_ENABLED = CACHE_MAX_ENTRIES > 0;
42
53
 
43
54
  if (isServer) {
44
55
  if (CACHE_ENABLED) {
45
56
  console.log(
46
- `💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, identity keys [${CACHE_KEYS.join(", ")}]`,
57
+ `💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, max body ${CACHE_MAX_BODY_BYTES === 0 ? "unlimited" : `${CACHE_MAX_BODY_BYTES} bytes`}, identity keys [${CACHE_KEYS.join(", ")}]`,
47
58
  );
48
59
  } else {
49
60
  console.log("💾 Response cache: disabled (CACHE_MAX_ENTRIES=0)");
@@ -111,31 +122,28 @@ const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
111
122
 
112
123
  // ─── Key building ────────────────────────────────────────
113
124
 
114
- /** FNV-1a 32-bit hash. Compact, no dep, collision-tolerant for an identity bucket. */
115
- function fnv1a(s: string): string {
116
- let h = 0x811c9dc5;
117
- for (let i = 0; i < s.length; i++) {
118
- h ^= s.charCodeAt(i);
119
- h = Math.imul(h, 0x01000193);
120
- }
121
- return (h >>> 0).toString(36);
125
+ /** SHA-256 truncated to 64 bits identity buckets must not collide across users. */
126
+ function identityDigest(s: string): string {
127
+ return new Bun.CryptoHasher("sha256").update(s).digest("hex").slice(0, 16);
122
128
  }
123
129
 
124
- export function computeIdentityHash(req: Request, cookies: Cookies): string {
130
+ export function computeIdentityHash(req: Request, cookies: Pick<CookieJar, "peek">): string {
125
131
  const headers = req.headers;
126
132
  const parts: string[] = [];
127
133
  for (const name of CACHE_KEYS) {
128
- const cv = cookies.get(name);
134
+ // peek, not get — building the identity must not count as a cookie read
135
+ // (get flips `accessed`, which forces Cache-Control: private downstream).
136
+ const cv = cookies.peek(name);
129
137
  if (cv) parts.push(`c:${name}=${cv}`);
130
138
  const hv = headers.get(name);
131
139
  if (hv) parts.push(`h:${name}=${hv}`);
132
140
  }
133
141
  if (parts.length === 0) return "0";
134
142
  parts.sort();
135
- return fnv1a(parts.join("&"));
143
+ return identityDigest(parts.join("&"));
136
144
  }
137
145
 
138
- export function computeCacheKey(url: URL, req: Request, cookies: Cookies): string {
146
+ export function computeCacheKey(url: URL, req: Request, cookies: Pick<CookieJar, "peek">): string {
139
147
  return `${dedupKey(url)}|i=${computeIdentityHash(req, cookies)}`;
140
148
  }
141
149
 
@@ -175,8 +183,37 @@ export function cacheGet(key: string): CacheEntry | undefined {
175
183
  return htmlCache.get(key);
176
184
  }
177
185
 
178
- export function cacheSet(key: string, entry: CacheEntry): void {
186
+ // ─── Uncovered-cookie warning ────────────────────────────
187
+ // The identity hash only sees cookies named in CACHE_KEYS. If a loader read a
188
+ // cookie outside that list and the response got cached anyway, the response
189
+ // may be personalised on something the cache key can't distinguish — users
190
+ // could be served each other's pages. Warn loudly (dev AND prod), once per
191
+ // cookie name per process.
192
+
193
+ const warnedUncoveredCookies = new Set<string>();
194
+
195
+ export function warnUncoveredCookies(cookies: Cookies): void {
196
+ const readNames = (cookies as { readNames?: ReadonlySet<string> }).readNames;
197
+ if (!readNames) return;
198
+ for (const name of readNames) {
199
+ if (CACHE_KEYS.includes(name) || warnedUncoveredCookies.has(name)) continue;
200
+ warnedUncoveredCookies.add(name);
201
+ console.warn(
202
+ `\n🚨 [bosia] SECURITY WARNING — possible cross-user cache leak 🚨\n` +
203
+ ` A cached response's loader read the cookie "${name}", which is NOT in CACHE_KEYS.\n` +
204
+ ` The cache key cannot tell users apart by this cookie: if the page content\n` +
205
+ ` depends on it, one user's page can be served to another.\n` +
206
+ ` Fix (pick one):\n` +
207
+ ` - Add it to the identity key list: CACHE_KEYS=${[...CACHE_KEYS, name].join(",")}\n` +
208
+ ` - Or opt the route out of caching: export const cache = false\n`,
209
+ );
210
+ }
211
+ }
212
+
213
+ export function cacheSet(key: string, entry: CacheEntry, cookies?: Cookies): void {
179
214
  if (!CACHE_ENABLED) return;
215
+ if (CACHE_MAX_BODY_BYTES > 0 && entry.raw.length > CACHE_MAX_BODY_BYTES) return;
216
+ if (cookies) warnUncoveredCookies(cookies);
180
217
  // Drop any existing entry's index pointers first
181
218
  cacheDeleteKey(key);
182
219
  const evicted = htmlCache.set(key, entry);
@@ -247,16 +284,16 @@ export function buildCompressedVariants(body: Bytes): {
247
284
  } catch {
248
285
  gzip = null;
249
286
  }
250
- // brotliCompressSync exists in Bun >= 1.1.5 but is missing from older
251
- // @types/bun shipments cast through any so the call stays loose.
252
- const brotliFn = (Bun as unknown as { brotliCompressSync?: (b: Bytes) => Uint8Array })
253
- .brotliCompressSync;
254
- if (brotliFn) {
255
- try {
256
- brotli = brotliFn(body) as Bytes;
257
- } catch {
258
- brotli = null;
259
- }
287
+ try {
288
+ // Quality 5: ~20x faster than the default 11, still beats gzip on size.
289
+ // This runs sync in a microtask 11 would block the event loop ~17ms/500KB.
290
+ brotli = new Uint8Array(
291
+ brotliCompressSync(body, {
292
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 },
293
+ }),
294
+ ) as Bytes;
295
+ } catch {
296
+ brotli = null;
260
297
  }
261
298
  return { gzip, brotli };
262
299
  }
@@ -274,6 +311,35 @@ export function concatChunks(chunks: Uint8Array[]): Bytes {
274
311
  return out;
275
312
  }
276
313
 
314
+ // ─── Miss coalescing ─────────────────────────────────────
315
+ // Stampede protection for cache misses. The first miss on a key becomes the
316
+ // leader (gets `release`); concurrent misses become waiters (get `wait`).
317
+ // Waiters re-check cacheGet after the wait resolves — hit: serve it; miss
318
+ // (leader skipped the write): build independently, no re-leadering. Distinct
319
+ // from dedup(), which shares the *result*; this shares only the *wait*.
320
+ //
321
+ // INVARIANT: the leader must call release() exactly once, after its cacheSet
322
+ // attempt completed or was skipped. A missed release() hangs waiters for the
323
+ // process lifetime — callers guard with try/finally.
324
+
325
+ const missGates = new Map<string, { promise: Promise<void>; release: () => void }>();
326
+
327
+ export function coalesceMiss(
328
+ key: string,
329
+ ): { release: () => void; wait?: undefined } | { wait: Promise<void>; release?: undefined } {
330
+ const existing = missGates.get(key);
331
+ if (existing) return { wait: existing.promise };
332
+ let resolve!: () => void;
333
+ const promise = new Promise<void>((r) => (resolve = r));
334
+ const release = () => {
335
+ // Idempotent, and never deletes a successor leader's gate.
336
+ if (missGates.get(key)?.promise === promise) missGates.delete(key);
337
+ resolve();
338
+ };
339
+ missGates.set(key, { promise, release });
340
+ return { release };
341
+ }
342
+
277
343
  // ─── Serve a cache hit ───────────────────────────────────
278
344
 
279
345
  export function serveCached(entry: CacheEntry, req: Request): Response {
@@ -58,6 +58,7 @@ export class CookieJar implements Cookies {
58
58
  private _outgoing: string[] = [];
59
59
  private _defaults: CookieOptions;
60
60
  private _accessed = false;
61
+ private _readNames = new Set<string>();
61
62
  private _isHttps: boolean;
62
63
 
63
64
  constructor(cookieHeader: string, isHttps = false) {
@@ -70,11 +71,23 @@ export class CookieJar implements Cookies {
70
71
 
71
72
  get(name: string): string | undefined {
72
73
  this._accessed = true;
74
+ if (name in this._incoming) this._readNames.add(name);
75
+ return this._incoming[name];
76
+ }
77
+
78
+ /**
79
+ * Read an incoming cookie WITHOUT flipping `accessed` or recording the name
80
+ * in `readNames`. Framework-internal (identity hashing must not count as a
81
+ * cookie read); deliberately absent from the public `Cookies` interface so
82
+ * loaders can't bypass dependency tracking.
83
+ */
84
+ peek(name: string): string | undefined {
73
85
  return this._incoming[name];
74
86
  }
75
87
 
76
88
  getAll(): Record<string, string> {
77
89
  this._accessed = true;
90
+ for (const name of Object.keys(this._incoming)) this._readNames.add(name);
78
91
  return { ...this._incoming };
79
92
  }
80
93
 
@@ -82,6 +95,11 @@ export class CookieJar implements Cookies {
82
95
  return this._accessed;
83
96
  }
84
97
 
98
+ /** Names of incoming cookies that were actually read (present in the request). */
99
+ get readNames(): ReadonlySet<string> {
100
+ return this._readNames;
101
+ }
102
+
85
103
  set(name: string, value: string, options?: CookieOptions): void {
86
104
  if (!VALID_COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
87
105
  const opts = { ...this._defaults, ...options };
package/src/core/dedup.ts CHANGED
@@ -1,12 +1,12 @@
1
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.
2
+ // Concurrent in-flight coalescing. When N parallel requests hit the same URL,
3
+ // run the loader once and share the resolved value across all N waiters.
4
+ // Settled responses are NOT cached — once the promise resolves, the entry is
5
+ // dropped, so the next request runs the loader again.
6
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.
7
+ // The caller's key includes the CACHE_KEYS identity hash (see cache.ts), so
8
+ // different users never share a loader result. Custom session cookie names
9
+ // must be listed in CACHE_KEYS. See docs/guides/request-deduplication.md.
10
10
 
11
11
  const inflight = new Map<string, Promise<unknown>>();
12
12
 
package/src/core/dev.ts CHANGED
@@ -564,9 +564,16 @@ console.log(
564
564
  // a second ^C.
565
565
 
566
566
  let shuttingDown = false;
567
+ let firstSignalAt = 0;
567
568
  async function shutdown() {
568
- if (shuttingDown) return; // re-entry from process-group signals or impatient ^C — drain is already running
569
+ if (shuttingDown) {
570
+ // One ^C arrives multiple times (process group + `bun run` forwarding
571
+ // to its child) — only a genuinely later signal is a second ^C.
572
+ if (Date.now() - firstSignalAt > 200) process.exit(130); // second ^C = force quit
573
+ return;
574
+ }
569
575
  shuttingDown = true;
576
+ firstSignalAt = Date.now();
570
577
  intentionalKill = true;
571
578
 
572
579
  if (buildTimer) clearTimeout(buildTimer);
@@ -582,9 +589,8 @@ async function shutdown() {
582
589
  await Promise.race([appProcess.exited, Bun.sleep(2_500)]);
583
590
  }
584
591
 
585
- // Safety net: if any stray handle still holds the loop, force clean exit.
586
- // .unref() so the timer itself doesn't keep the loop alive when drain succeeds.
587
- setTimeout(() => process.exit(0), 1_500).unref();
592
+ // Everything is stopped exit now rather than waiting for the loop to drain.
593
+ process.exit(0);
588
594
  }
589
595
 
590
596
  process.on("SIGINT", shutdown);
@@ -7,14 +7,17 @@ import type { Cookies, LoaderDeps } from "./hooks.ts";
7
7
  import { CSP_ENABLED } from "./csp.ts";
8
8
  import {
9
9
  CACHE_ENABLED,
10
+ CACHE_MAX_BODY_BYTES,
10
11
  buildCompressedVariants,
11
12
  cacheGet,
12
13
  cacheSet,
14
+ coalesceMiss,
13
15
  collectTags,
14
16
  computeCacheKey,
15
17
  concatChunks,
16
18
  serveCached,
17
19
  } from "./cache.ts";
20
+ import type { CookieJar } from "./cookies.ts";
18
21
  import { HttpError, Redirect } from "./errors.ts";
19
22
  import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
20
23
  import App from "./client/App.svelte";
@@ -532,27 +535,96 @@ export async function renderSSRStream(
532
535
  const cacheable =
533
536
  CACHE_ENABLED && !CSP_ENABLED && pageMod.cache !== false && req.method === "GET";
534
537
  let cacheKey: string | null = null;
538
+ let releaseMiss: (() => void) | null = null;
535
539
  if (cacheable) {
536
- cacheKey = computeCacheKey(url, req, cookies);
540
+ cacheKey = computeCacheKey(url, req, cookies as CookieJar);
537
541
  if (!cacheBypass) {
538
542
  const hit = cacheGet(cacheKey);
539
543
  if (hit) return serveCached(hit, req);
544
+ // Miss coalescing: the first miss leads the build; concurrent misses
545
+ // wait, then re-check the cache. A waiter that still misses (leader
546
+ // skipped the write) builds independently — no re-leadering.
547
+ const gate = coalesceMiss(cacheKey);
548
+ if (gate.wait) {
549
+ await gate.wait;
550
+ const rehit = cacheGet(cacheKey);
551
+ if (rehit) return serveCached(rehit, req);
552
+ } else {
553
+ releaseMiss = gate.release;
554
+ }
540
555
  }
541
556
  }
542
557
 
543
- // ── Pre-stream phase: resolve metadata before committing to a 200 ──
544
- // Errors here return a proper error response with correct status code.
545
- let metadata: Metadata | null = null;
558
+ // INVARIANT: releaseMiss must fire exactly once a missed release() hangs
559
+ // coalesced waiters for the process lifetime. Every early return and throw
560
+ // below must stay inside this try; the cache-write path hands the release
561
+ // off to its cacheSet microtask by nulling releaseMiss first.
546
562
  try {
547
- metadata = await loadMetadata(route, params, url, locals, cookies, req);
548
- } catch (err) {
549
- if (err instanceof Redirect) {
550
- return Response.redirect(err.location, err.status);
563
+ // ── Pre-stream phase: resolve metadata before committing to a 200 ──
564
+ // Errors here return a proper error response with correct status code.
565
+ let metadata: Metadata | null = null;
566
+ try {
567
+ metadata = await loadMetadata(route, params, url, locals, cookies, req);
568
+ } catch (err) {
569
+ if (err instanceof Redirect) {
570
+ return Response.redirect(err.location, err.status);
571
+ }
572
+ if (err instanceof HttpError) {
573
+ return renderErrorPage(
574
+ err.status,
575
+ err.message,
576
+ url,
577
+ req,
578
+ route,
579
+ undefined,
580
+ undefined,
581
+ undefined,
582
+ nonce,
583
+ );
584
+ }
585
+ if (isDev) console.error("Metadata load error:", err);
586
+ else console.error("Metadata load error:", (err as Error).message ?? err);
587
+ if (isDev) reportDevErrorFromCatch(err);
588
+ // Continue with null metadata — don't break the page for a metadata failure
551
589
  }
552
- if (err instanceof HttpError) {
590
+
591
+ // ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
592
+ // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
593
+ const metadataData = metadata?.data ?? null;
594
+ let data: Awaited<ReturnType<typeof loadRouteData>>;
595
+ let layoutMods: any[];
596
+
597
+ try {
598
+ [data, layoutMods] = await Promise.all([
599
+ loadRouteData(url, locals, req, cookies, metadataData, match),
600
+ Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
601
+ ]);
602
+ } catch (err) {
603
+ if (err instanceof Redirect) return Response.redirect(err.location, err.status);
604
+ if (err instanceof HttpError) {
605
+ const e = err as HttpError & {
606
+ errorDepth?: number;
607
+ errorOrigin?: ErrorOrigin;
608
+ partialLayoutData?: Record<string, any>[];
609
+ };
610
+ return renderErrorPage(
611
+ err.status,
612
+ err.message,
613
+ url,
614
+ req,
615
+ route,
616
+ e.errorDepth,
617
+ e.errorOrigin,
618
+ e.partialLayoutData,
619
+ nonce,
620
+ );
621
+ }
622
+ if (isDev) console.error("SSR load error:", err);
623
+ else console.error("SSR load error:", (err as Error).message ?? err);
624
+ if (isDev) reportDevErrorFromCatch(err);
553
625
  return renderErrorPage(
554
- err.status,
555
- err.message,
626
+ 500,
627
+ "Internal Server Error",
556
628
  url,
557
629
  req,
558
630
  route,
@@ -562,222 +634,189 @@ export async function renderSSRStream(
562
634
  nonce,
563
635
  );
564
636
  }
565
- if (isDev) console.error("Metadata load error:", err);
566
- else console.error("Metadata load error:", (err as Error).message ?? err);
567
- if (isDev) reportDevErrorFromCatch(err);
568
- // Continue with null metadata — don't break the page for a metadata failure
569
- }
570
637
 
571
- // ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
572
- // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
573
- const metadataData = metadata?.data ?? null;
574
- let data: Awaited<ReturnType<typeof loadRouteData>>;
575
- let layoutMods: any[];
576
-
577
- try {
578
- [data, layoutMods] = await Promise.all([
579
- loadRouteData(url, locals, req, cookies, metadataData, match),
580
- Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
581
- ]);
582
- } catch (err) {
583
- if (err instanceof Redirect) return Response.redirect(err.location, err.status);
584
- if (err instanceof HttpError) {
585
- const e = err as HttpError & {
586
- errorDepth?: number;
587
- errorOrigin?: ErrorOrigin;
588
- partialLayoutData?: Record<string, any>[];
589
- };
638
+ if (!data)
590
639
  return renderErrorPage(
591
- err.status,
592
- err.message,
640
+ 404,
641
+ "Not Found",
593
642
  url,
594
643
  req,
595
- route,
596
- e.errorDepth,
597
- e.errorOrigin,
598
- e.partialLayoutData,
644
+ undefined,
645
+ undefined,
646
+ undefined,
647
+ undefined,
599
648
  nonce,
600
649
  );
601
- }
602
- if (isDev) console.error("SSR load error:", err);
603
- else console.error("SSR load error:", (err as Error).message ?? err);
604
- if (isDev) reportDevErrorFromCatch(err);
605
- return renderErrorPage(
606
- 500,
607
- "Internal Server Error",
608
- url,
609
- req,
610
- route,
611
- undefined,
612
- undefined,
613
- undefined,
614
- nonce,
615
- );
616
- }
617
650
 
618
- if (!data)
619
- return renderErrorPage(
620
- 404,
621
- "Not Found",
651
+ const enc = new TextEncoder();
652
+ const renderCtx: RenderContext = {
653
+ request: req,
622
654
  url,
623
- req,
624
- undefined,
625
- undefined,
626
- undefined,
627
- undefined,
628
- nonce,
629
- );
630
-
631
- const enc = new TextEncoder();
632
- const renderCtx: RenderContext = {
633
- request: req,
634
- url,
635
- route: { pattern: route.pattern },
636
- metadata,
637
- };
638
- const [headExtras, bodyEndExtras] = await Promise.all([
639
- pluginRenderFragments("head", renderCtx),
640
- pluginRenderFragments("bodyEnd", renderCtx),
641
- ]);
655
+ route: { pattern: route.pattern },
656
+ metadata,
657
+ };
658
+ const [headExtras, bodyEndExtras] = await Promise.all([
659
+ pluginRenderFragments("head", renderCtx),
660
+ pluginRenderFragments("bodyEnd", renderCtx),
661
+ ]);
642
662
 
643
- // SSR always runs every loader, so coerce types from the optional sparse shape.
644
- const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
645
- const pageDataFull = data.pageData ?? {};
663
+ // SSR always runs every loader, so coerce types from the optional sparse shape.
664
+ const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
665
+ const pageDataFull = data.pageData ?? {};
646
666
 
647
- // ssr=false → no render() needed; ship shell + hydration as a single response.
648
- // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
649
- if (!data.ssr) {
650
- if (!data.csr && isDev) {
651
- console.warn(
652
- `⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
653
- );
667
+ // ssr=false → no render() needed; ship shell + hydration as a single response.
668
+ // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
669
+ if (!data.ssr) {
670
+ if (!data.csr && isDev) {
671
+ console.warn(
672
+ `⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
673
+ );
674
+ }
675
+ const html =
676
+ buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments) +
677
+ buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
678
+ buildHtmlTail(
679
+ "",
680
+ "",
681
+ pageDataFull,
682
+ layoutDataFull,
683
+ true,
684
+ null,
685
+ false,
686
+ bodyEndExtras,
687
+ nonce,
688
+ data.pageDeps,
689
+ data.layoutDeps,
690
+ appHtmlSegments,
691
+ );
692
+ return new Response(html, {
693
+ headers: { "Content-Type": "text/html; charset=utf-8" },
694
+ });
654
695
  }
655
- const html =
656
- buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments) +
657
- buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
658
- buildHtmlTail(
659
- "",
660
- "",
661
- pageDataFull,
696
+
697
+ // Render-first: run render() before committing to a 200. Failure → proper error page
698
+ // with correct status code, instead of a bare <p> mixed into an already-flushed shell.
699
+ let body: string, head: string;
700
+ try {
701
+ ({ body, head } = render(App, {
702
+ props: {
703
+ ssrMode: true,
704
+ ssrPageComponent: pageMod.default,
705
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
706
+ ssrPageData: pageDataFull,
707
+ ssrLayoutData: layoutDataFull,
708
+ },
709
+ }));
710
+ } catch (err) {
711
+ if (isDev) console.error("SSR render error:", err);
712
+ else console.error("SSR render error:", (err as Error).message ?? err);
713
+ if (isDev) reportDevErrorFromCatch(err);
714
+ // Render-phase errors fall through to deepest boundary like a page error.
715
+ return renderErrorPage(
716
+ 500,
717
+ "Internal Server Error",
718
+ url,
719
+ req,
720
+ route,
721
+ route.layoutModules.length,
722
+ "page",
662
723
  layoutDataFull,
663
- true,
664
- null,
665
- false,
666
- bodyEndExtras,
667
724
  nonce,
668
- data.pageDeps,
669
- data.layoutDeps,
670
- appHtmlSegments,
671
725
  );
672
- return new Response(html, {
673
- headers: { "Content-Type": "text/html; charset=utf-8" },
674
- });
675
- }
676
-
677
- // Render-first: run render() before committing to a 200. Failure → proper error page
678
- // with correct status code, instead of a bare <p> mixed into an already-flushed shell.
679
- let body: string, head: string;
680
- try {
681
- ({ body, head } = render(App, {
682
- props: {
683
- ssrMode: true,
684
- ssrPageComponent: pageMod.default,
685
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
686
- ssrPageData: pageDataFull,
687
- ssrLayoutData: layoutDataFull,
688
- },
689
- }));
690
- } catch (err) {
691
- if (isDev) console.error("SSR render error:", err);
692
- else console.error("SSR render error:", (err as Error).message ?? err);
693
- if (isDev) reportDevErrorFromCatch(err);
694
- // Render-phase errors fall through to deepest boundary like a page error.
695
- return renderErrorPage(
696
- 500,
697
- "Internal Server Error",
698
- url,
699
- req,
700
- route,
701
- route.layoutModules.length,
702
- "page",
703
- layoutDataFull,
704
- nonce,
705
- );
706
- }
726
+ }
707
727
 
708
- // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
709
- const chunks: Uint8Array[] = [
710
- enc.encode(buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments)),
711
- enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
712
- enc.encode(
713
- buildHtmlTail(
714
- body,
715
- head,
716
- pageDataFull,
717
- layoutDataFull,
718
- data.csr,
719
- null,
720
- true,
721
- bodyEndExtras,
722
- nonce,
723
- data.pageDeps,
724
- data.layoutDeps,
725
- appHtmlSegments,
728
+ // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
729
+ const chunks: Uint8Array[] = [
730
+ enc.encode(buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments)),
731
+ enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
732
+ enc.encode(
733
+ buildHtmlTail(
734
+ body,
735
+ head,
736
+ pageDataFull,
737
+ layoutDataFull,
738
+ data.csr,
739
+ null,
740
+ true,
741
+ bodyEndExtras,
742
+ nonce,
743
+ data.pageDeps,
744
+ data.layoutDeps,
745
+ appHtmlSegments,
746
+ ),
726
747
  ),
727
- ),
728
- ];
729
-
730
- // ── Response cache: write after chunks built, before stream creation ──
731
- // Skip if the handler set cookies cached response can't reproduce
732
- // per-request Set-Cookie headers. Compression runs in a microtask so
733
- // the response goes out first.
734
- if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
735
- const fullBody = concatChunks(chunks);
736
- const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
737
- const keyForWrite = cacheKey;
738
- queueMicrotask(() => {
739
- const { gzip, brotli } = buildCompressedVariants(fullBody);
740
- cacheSet(keyForWrite, {
741
- raw: fullBody,
742
- gzip,
743
- brotli,
744
- contentType: "text/html; charset=utf-8",
745
- status: 200,
746
- extraHeaders: {},
747
- tags,
748
- });
749
- });
750
- }
751
-
752
- let i = 0;
753
- let cancelled = false;
754
- const onAbort = () => {
755
- cancelled = true;
756
- };
757
- req.signal.addEventListener("abort", onAbort, { once: true });
758
-
759
- const stream = new ReadableStream<Uint8Array>({
760
- pull(controller) {
761
- if (cancelled || i >= chunks.length) {
762
- controller.close();
763
- req.signal.removeEventListener("abort", onAbort);
764
- return;
765
- }
766
- controller.enqueue(chunks[i++]);
767
- if (i >= chunks.length) {
768
- controller.close();
769
- req.signal.removeEventListener("abort", onAbort);
748
+ ];
749
+
750
+ // ── Response cache: write after chunks built, before stream creation ──
751
+ // Skip if the handler set cookies cached response can't reproduce
752
+ // per-request Set-Cookie headers. Compression runs in a microtask so
753
+ // the response goes out first.
754
+ if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
755
+ const fullBody = concatChunks(chunks);
756
+ // Oversized bodies skip early so they never pay compression; cacheSet
757
+ // re-checks as the authoritative guard.
758
+ if (CACHE_MAX_BODY_BYTES === 0 || fullBody.length <= CACHE_MAX_BODY_BYTES) {
759
+ const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
760
+ const keyForWrite = cacheKey;
761
+ // Hand the gate release to the write microtask: waiters resume only
762
+ // after cacheSet ran, so their re-check hits.
763
+ const rel = releaseMiss;
764
+ releaseMiss = null;
765
+ queueMicrotask(() => {
766
+ try {
767
+ const { gzip, brotli } = buildCompressedVariants(fullBody);
768
+ cacheSet(
769
+ keyForWrite,
770
+ {
771
+ raw: fullBody,
772
+ gzip,
773
+ brotli,
774
+ contentType: "text/html; charset=utf-8",
775
+ status: 200,
776
+ extraHeaders: {},
777
+ tags,
778
+ },
779
+ cookies,
780
+ );
781
+ } finally {
782
+ rel?.();
783
+ }
784
+ });
770
785
  }
771
- },
772
- cancel() {
786
+ }
787
+
788
+ let i = 0;
789
+ let cancelled = false;
790
+ const onAbort = () => {
773
791
  cancelled = true;
774
- req.signal.removeEventListener("abort", onAbort);
775
- },
776
- });
792
+ };
793
+ req.signal.addEventListener("abort", onAbort, { once: true });
794
+
795
+ const stream = new ReadableStream<Uint8Array>({
796
+ pull(controller) {
797
+ if (cancelled || i >= chunks.length) {
798
+ controller.close();
799
+ req.signal.removeEventListener("abort", onAbort);
800
+ return;
801
+ }
802
+ controller.enqueue(chunks[i++]);
803
+ if (i >= chunks.length) {
804
+ controller.close();
805
+ req.signal.removeEventListener("abort", onAbort);
806
+ }
807
+ },
808
+ cancel() {
809
+ cancelled = true;
810
+ req.signal.removeEventListener("abort", onAbort);
811
+ },
812
+ });
777
813
 
778
- return new Response(stream, {
779
- headers: { "Content-Type": "text/html; charset=utf-8" },
780
- });
814
+ return new Response(stream, {
815
+ headers: { "Content-Type": "text/html; charset=utf-8" },
816
+ });
817
+ } finally {
818
+ releaseMiss?.();
819
+ }
781
820
  }
782
821
 
783
822
  // ─── Form Action Page Renderer ───────────────────────────
@@ -94,7 +94,6 @@ export function generateRoutesFile(manifest: RouteManifest): void {
94
94
  lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
95
95
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
96
96
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
97
- lines.push(' scope: "public" | "private";');
98
97
  lines.push("}> = [");
99
98
  for (const r of pages) {
100
99
  const layoutImports = r.layouts
@@ -122,7 +121,6 @@ export function generateRoutesFile(manifest: RouteManifest): void {
122
121
  lines.push(` layoutServers: [${layoutServerImports}],`);
123
122
  lines.push(` errorPages: [${errorPageImports}],`);
124
123
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
125
- lines.push(` scope: ${JSON.stringify(r.scope)},`);
126
124
  lines.push(" },");
127
125
  }
128
126
  lines.push("];\n");
@@ -46,7 +46,6 @@ export function scanRoutes(): RouteManifest {
46
46
  layoutServerChain: { path: string; depth: number }[],
47
47
  errorPageChain: { path: string; depth: number }[],
48
48
  inheritedTrailingSlash: TrailingSlash,
49
- inheritedScope: "public" | "private",
50
49
  ) {
51
50
  const fullDir = join(ROUTES_DIR, dir);
52
51
  if (!existsSync(fullDir)) return;
@@ -110,7 +109,6 @@ export function scanRoutes(): RouteManifest {
110
109
  layoutServers: [...currentLayoutServers],
111
110
  errorPages: [...currentErrorPages],
112
111
  trailingSlash: effectiveTs,
113
- scope: inheritedScope,
114
112
  });
115
113
  }
116
114
 
@@ -122,9 +120,6 @@ export function scanRoutes(): RouteManifest {
122
120
  const dirName = entry.name;
123
121
  // Route groups like (public), (auth) are invisible in URLs
124
122
  const isGroup = /^\(.*\)$/.test(dirName);
125
- // `(private)` anywhere in the chain marks descendants as per-user (dedup off)
126
- const childScope: "public" | "private" =
127
- inheritedScope === "private" || dirName === "(private)" ? "private" : "public";
128
123
 
129
124
  walk(
130
125
  dir ? join(dir, dirName) : dirName,
@@ -133,12 +128,11 @@ export function scanRoutes(): RouteManifest {
133
128
  currentLayoutServers,
134
129
  currentErrorPages,
135
130
  currentTrailingSlash,
136
- childScope,
137
131
  );
138
132
  }
139
133
  }
140
134
 
141
- walk("", [], [], [], [], "never", "public");
135
+ walk("", [], [], [], [], "never");
142
136
 
143
137
  // Warn when a catch-all exists but no exact route covers its prefix.
144
138
  // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
@@ -24,15 +24,19 @@ import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } f
24
24
  import { isDev, compress, isStaticPath } from "./html.ts";
25
25
  import { dev500WithPlugins } from "./dev-500.ts";
26
26
  import { OUT_DIR } from "./paths.ts";
27
- import { buildStaticManifest, lookupStatic } from "./staticManifest.ts";
28
- import { dedup, dedupKey } from "./dedup.ts";
27
+ import { buildPrerenderManifest, buildStaticManifest, lookupStatic } from "./staticManifest.ts";
28
+ import { dedup } from "./dedup.ts";
29
29
  import {
30
30
  CACHE_ENABLED,
31
+ CACHE_KEYS,
32
+ CACHE_MAX_BODY_BYTES,
31
33
  buildCompressedVariants,
32
34
  cacheGet,
33
35
  cacheSet,
36
+ coalesceMiss,
34
37
  computeCacheKey,
35
38
  serveCached,
39
+ warnUncoveredCookies,
36
40
  } from "./cache.ts";
37
41
  import { reportDevErrorFromCatch } from "./devErrorReport.ts";
38
42
  import {
@@ -187,6 +191,7 @@ function parseActionName(url: URL): string {
187
191
  // Dev keeps the per-request fallthrough so files dropped into `public/` mid-session
188
192
  // are served without a restart (dev's watcher doesn't fire on `public/`).
189
193
  const staticManifest = isDev ? null : buildStaticManifest(OUT_DIR);
194
+ const prerenderManifest = isDev ? null : buildPrerenderManifest(OUT_DIR);
190
195
 
191
196
  async function resolve(event: RequestEvent): Promise<Response> {
192
197
  const { request, url, locals, cookies } = event;
@@ -284,15 +289,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
284
289
  return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
285
290
  };
286
291
 
287
- // Dedup public routes by URL + mask. `(private)` scope routes (per-user
288
- // content) skip the cache to prevent cross-user data leaks. The mask is
289
- // part of the key so concurrent requests for the same URL with different
290
- // invalidation patterns don't collapse onto each other. See dedup.ts.
291
- const dedupK = invalidatedBits
292
- ? `${dedupKey(routeUrl)}|m=${invalidatedBits}`
293
- : dedupKey(routeUrl);
294
- const result =
295
- pageMatch?.route.scope === "private" ? await runLoad() : await dedup(dedupK, runLoad);
292
+ // Dedup concurrent identical requests. The key includes the CACHE_KEYS
293
+ // identity hash, so different users never share a loader result same
294
+ // isolation contract as the response cache. The mask is part of the key
295
+ // so concurrent requests for the same URL with different invalidation
296
+ // patterns don't collapse onto each other. See dedup.ts.
297
+ const dedupK =
298
+ computeCacheKey(routeUrl, request, cookies as CookieJar) +
299
+ (invalidatedBits ? `|m=${invalidatedBits}` : "");
300
+ const result = await dedup(dedupK, runLoad);
301
+ // Identity only covers CACHE_KEYS — warn if a loader read a session
302
+ // cookie outside that list (dedup could then mix users' results).
303
+ warnUncoveredCookies(cookies);
296
304
 
297
305
  const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
298
306
  const cc = cookiesWereAccessed ? "private, no-cache" : "public, max-age=0, must-revalidate";
@@ -359,6 +367,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
359
367
  // `.webp` URLs that would otherwise be intercepted by isStaticPath).
360
368
  const apiMatch = await resolveApiMatch(apiRoutes, path);
361
369
  if (apiMatch) {
370
+ // INVARIANT: once set, releaseApiMiss must fire exactly once — a missed
371
+ // release() hangs coalesced waiters for the process lifetime. The cache
372
+ // write path hands it off to its microtask by nulling it first.
373
+ let releaseApiMiss: (() => void) | null = null;
362
374
  try {
363
375
  const mod = await apiMatch.route.module();
364
376
  const handler = mod[method];
@@ -383,10 +395,20 @@ async function resolve(event: RequestEvent): Promise<Response> {
383
395
  CACHE_ENABLED && !CSP_ENABLED && (mod as any).cache !== false && method === "GET";
384
396
  let apiCacheKey: string | null = null;
385
397
  if (apiCacheable) {
386
- apiCacheKey = computeCacheKey(url, request, cookies);
398
+ apiCacheKey = computeCacheKey(url, request, cookies as CookieJar);
387
399
  if (!url.searchParams.has("_invalidated")) {
388
400
  const hit = cacheGet(apiCacheKey);
389
401
  if (hit) return serveCached(hit, request);
402
+ // Miss coalescing: first miss runs the handler; concurrent misses
403
+ // wait, re-check the cache, and on a still-miss run independently.
404
+ const gate = coalesceMiss(apiCacheKey);
405
+ if (gate.wait) {
406
+ await gate.wait;
407
+ const rehit = cacheGet(apiCacheKey);
408
+ if (rehit) return serveCached(rehit, request);
409
+ } else {
410
+ releaseApiMiss = gate.release;
411
+ }
390
412
  }
391
413
  }
392
414
 
@@ -433,23 +455,36 @@ async function resolve(event: RequestEvent): Promise<Response> {
433
455
  const extraHeaders = captureCacheableHeaders(response.headers);
434
456
  const contentType = responseContentType || "application/octet-stream";
435
457
  const keyForWrite = apiCacheKey;
458
+ // Hand the gate release to the write microtask: waiters resume only
459
+ // after the cacheSet attempt, so their re-check hits.
460
+ const rel = releaseApiMiss;
461
+ releaseApiMiss = null;
436
462
  queueMicrotask(async () => {
437
463
  try {
438
464
  const buf = new Uint8Array(await cloned.arrayBuffer());
465
+ // Oversized bodies skip early so they never pay compression;
466
+ // cacheSet re-checks as the authoritative guard.
467
+ if (CACHE_MAX_BODY_BYTES > 0 && buf.length > CACHE_MAX_BODY_BYTES) return;
439
468
  const { gzip, brotli } = buildCompressedVariants(buf);
440
469
  // API endpoints have no LoaderDeps in v0.6 — invalidation is
441
470
  // URL/prefix only. See ROADMAP for deferred tag support.
442
- cacheSet(keyForWrite, {
443
- raw: buf,
444
- gzip,
445
- brotli,
446
- contentType,
447
- status: 200,
448
- extraHeaders,
449
- tags: [],
450
- });
471
+ cacheSet(
472
+ keyForWrite,
473
+ {
474
+ raw: buf,
475
+ gzip,
476
+ brotli,
477
+ contentType,
478
+ status: 200,
479
+ extraHeaders,
480
+ tags: [],
481
+ },
482
+ cookies,
483
+ );
451
484
  } catch {
452
485
  /* drop silently — cache population is best-effort */
486
+ } finally {
487
+ rel?.();
453
488
  }
454
489
  });
455
490
  }
@@ -480,6 +515,8 @@ async function resolve(event: RequestEvent): Promise<Response> {
480
515
  });
481
516
  }
482
517
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
518
+ } finally {
519
+ releaseApiMiss?.();
483
520
  }
484
521
  }
485
522
 
@@ -497,11 +534,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
497
534
  return new Response("Not Found", { status: 404 });
498
535
  }
499
536
  // Dev: keep the per-request fallthrough so files dropped into `public/`
500
- // mid-session are served without a restart.
501
- if (path.startsWith("/dist/client/")) {
537
+ // mid-session are served without a restart. Decode once — filenames on
538
+ // disk are raw; safePath still runs after so traversal stays blocked.
539
+ let decodedPath: string;
540
+ try {
541
+ decodedPath = decodeURIComponent(path);
542
+ } catch {
543
+ return new Response("Not Found", { status: 404 });
544
+ }
545
+ if (decodedPath.startsWith("/dist/client/")) {
502
546
  const resolved = safePath(
503
547
  `${OUT_DIR}/client`,
504
- path.split("?")[0].slice("/dist/client".length),
548
+ decodedPath.split("?")[0].slice("/dist/client".length),
505
549
  );
506
550
  if (resolved) {
507
551
  const file = Bun.file(resolved);
@@ -511,17 +555,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
511
555
  }
512
556
  return new Response("Not Found", { status: 404 });
513
557
  }
514
- const pubPath = safePath("./public", path);
558
+ const pubPath = safePath("./public", decodedPath);
515
559
  if (pubPath) {
516
560
  const pub = Bun.file(pubPath);
517
561
  if (await pub.exists()) return new Response(pub);
518
562
  }
519
- const distPath = safePath(OUT_DIR, path);
563
+ const distPath = safePath(OUT_DIR, decodedPath);
520
564
  if (distPath) {
521
565
  const dist = Bun.file(distPath);
522
566
  if (await dist.exists()) return new Response(dist);
523
567
  }
524
- const staticPath = safePath(`${OUT_DIR}/static`, path);
568
+ const staticPath = safePath(`${OUT_DIR}/static`, decodedPath);
525
569
  if (staticPath) {
526
570
  const staticFile = Bun.file(staticPath);
527
571
  if (await staticFile.exists()) return new Response(staticFile);
@@ -535,22 +579,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
535
579
  // in dev would mask errors (the badge stays empty, the SSE reload script
536
580
  // isn't injected, and the page can't auto-recover when the source is fixed).
537
581
  // Live SSR every request in dev so /about behaves like every other route.
538
- if (!isDev) {
539
- // Try both `<path>/index.html` (always/ignore mode) and `<path>.html` (never mode)
540
- const prerenderCandidates =
541
- path === "/" ? ["index.html"] : [`${path}/index.html`, `${path.replace(/\/$/, "")}.html`];
542
- for (const candidate of prerenderCandidates) {
543
- const prerenderPath = safePath(`${OUT_DIR}/prerendered`, candidate);
544
- if (!prerenderPath) continue;
545
- const prerenderFile = Bun.file(prerenderPath);
546
- if (await prerenderFile.exists()) {
547
- return new Response(prerenderFile, {
548
- headers: {
549
- "Content-Type": "text/html; charset=utf-8",
550
- "Cache-Control": "public, max-age=3600",
551
- },
552
- });
553
- }
582
+ if (prerenderManifest) {
583
+ // Keys come from a boot-time walk of `dist/prerendered/`, not from the
584
+ // URL, so no safePath needed — a non-matching path is just a miss.
585
+ const key = path === "/" ? "/" : path.replace(/\/$/, "");
586
+ const abs = prerenderManifest.get(key);
587
+ if (abs) {
588
+ return new Response(Bun.file(abs), {
589
+ headers: {
590
+ "Content-Type": "text/html; charset=utf-8",
591
+ "Cache-Control": "public, max-age=3600",
592
+ },
593
+ });
554
594
  }
555
595
  }
556
596
 
@@ -978,6 +1018,7 @@ if (Number.isFinite(MAX_INFLIGHT)) {
978
1018
  // ─── Graceful Shutdown State ──────────────────────────────
979
1019
 
980
1020
  let shuttingDown = false;
1021
+ let firstSignalAt = 0;
981
1022
  let inFlight = 0;
982
1023
  let drainResolve: (() => void) | null = null;
983
1024
 
@@ -1007,7 +1048,6 @@ function loadBuiltManifest(): RouteManifest {
1007
1048
  layoutServers: [],
1008
1049
  errorPages: [],
1009
1050
  trailingSlash: r.trailingSlash,
1010
- scope: r.scope,
1011
1051
  })),
1012
1052
  apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
1013
1053
  errorPage: null,
@@ -1094,11 +1134,29 @@ for (const plugin of plugins) {
1094
1134
  app.listen(PORT, () => {
1095
1135
  // In dev mode the proxy owns the user-facing port — don't print the internal port
1096
1136
  if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
1137
+ // Last line of startup on purpose — the cache identity contract is the one
1138
+ // config mistake that leaks one user's page to another, so it stays visible.
1139
+ if (CACHE_ENABLED) {
1140
+ console.log(
1141
+ `\n🔑 Response cache tells users apart ONLY by these cookies/headers: [${CACHE_KEYS.join(", ")}]\n` +
1142
+ ` Using a different session cookie or auth header? Add its name to CACHE_KEYS,\n` +
1143
+ ` or one user's personalised page can be served to another. Routes personalised\n` +
1144
+ ` by anything else should set \`export const cache = false\`.\n`,
1145
+ );
1146
+ }
1097
1147
  });
1098
1148
 
1099
1149
  async function shutdown() {
1100
- if (shuttingDown) return;
1150
+ if (shuttingDown) {
1151
+ // One ^C arrives multiple times (process group + `bun run` forwarding
1152
+ // to its child) — only a genuinely later signal is a second ^C.
1153
+ if (Date.now() - firstSignalAt > 200) process.exit(130); // second ^C = force quit
1154
+ return;
1155
+ }
1101
1156
  shuttingDown = true;
1157
+ firstSignalAt = Date.now();
1158
+ // Dev: nothing worth draining — exit instantly so ^C feels immediate.
1159
+ if (isDev) process.exit(0);
1102
1160
  console.log("⏳ Shutting down — draining in-flight requests...");
1103
1161
 
1104
1162
  if (inFlight > 0) {
@@ -100,10 +100,44 @@ export function buildStaticManifest(outDir: string): StaticManifest {
100
100
  }
101
101
 
102
102
  export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
103
- const key = urlPath.split("?")[0];
103
+ const raw = urlPath.split("?")[0];
104
+ // Manifest keys are raw filenames; URLs arrive percent-encoded.
105
+ let key: string;
106
+ try {
107
+ key = decodeURIComponent(raw);
108
+ } catch {
109
+ return null; // malformed encoding → 404
110
+ }
104
111
  return manifest.get(key) ?? null;
105
112
  }
106
113
 
114
+ /**
115
+ * Boot-time map of URL path → absolute file path for `dist/prerendered/`.
116
+ * `index.html` → `/`, `foo/index.html` → `/foo`, `foo.html` → `/foo`.
117
+ * On collision the `…/index.html` variant wins (mirrors the old runtime
118
+ * candidate order). Replaces per-request `Bun.file().exists()` probes.
119
+ */
120
+ export function buildPrerenderManifest(outDir: string): Map<string, string> {
121
+ const manifest = new Map<string, string>();
122
+ const root = join(resolvePath(outDir), "prerendered");
123
+ for (const { abs, rel } of walk(root)) {
124
+ if (!rel.endsWith(".html")) continue;
125
+ let key: string;
126
+ let isIndex = false;
127
+ if (rel === "index.html") {
128
+ key = "/";
129
+ isIndex = true;
130
+ } else if (rel.endsWith("/index.html")) {
131
+ key = `/${rel.slice(0, -"/index.html".length)}`;
132
+ isIndex = true;
133
+ } else {
134
+ key = `/${rel.slice(0, -".html".length)}`;
135
+ }
136
+ if (isIndex || !manifest.has(key)) manifest.set(key, abs);
137
+ }
138
+ return manifest;
139
+ }
140
+
107
141
  // Re-export for tests that want to confirm a file-on-disk exists at the entry.
108
142
  export function entryFileExists(entry: StaticEntry): boolean {
109
143
  try {
package/src/core/types.ts CHANGED
@@ -26,12 +26,6 @@ export interface PageRoute {
26
26
  errorPages: { path: string; depth: number }[];
27
27
  /** Effective trailing-slash mode (page wins over layout chain). Defaults to "never". */
28
28
  trailingSlash: TrailingSlash;
29
- /**
30
- * Dedup scope. `"public"` (default) → loader runs once for concurrent identical
31
- * URLs. `"private"` → loader runs per-request (use for per-user routes).
32
- * Set by placing the route under a `(private)` group folder anywhere in the chain.
33
- */
34
- scope: "public" | "private";
35
29
  }
36
30
 
37
31
  /** An API route discovered from the file system */