bosia 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -1008,7 +1048,6 @@ function loadBuiltManifest(): RouteManifest {
1008
1048
  layoutServers: [],
1009
1049
  errorPages: [],
1010
1050
  trailingSlash: r.trailingSlash,
1011
- scope: r.scope,
1012
1051
  })),
1013
1052
  apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
1014
1053
  errorPage: null,
@@ -1095,6 +1134,19 @@ for (const plugin of plugins) {
1095
1134
  app.listen(PORT, () => {
1096
1135
  // In dev mode the proxy owns the user-facing port — don't print the internal port
1097
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
+ ` Note: the runtime auto-warns only on uncovered *cookie* reads — it CANNOT\n` +
1146
+ ` detect header-based personalisation, so custom auth headers (X-Api-Key,\n` +
1147
+ ` X-Auth-Token, …) must be added to CACHE_KEYS by hand.\n`,
1148
+ );
1149
+ }
1098
1150
  });
1099
1151
 
1100
1152
  async function shutdown() {
@@ -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 {
@@ -0,0 +1,18 @@
1
+ import { readFileSync, renameSync } from "fs";
2
+ import { join, dirname } from "path";
3
+
4
+ /** Temp filename Tailwind CLI writes to before the content-hash rename. */
5
+ export const TW_TEMP_BASENAME = ".bosia-tw.build.css";
6
+
7
+ /**
8
+ * Content-hash the compiled Tailwind CSS and rename it to its final
9
+ * `bosia-tw-<hash>.css` name (matches staticManifest's HASHED_BASENAME rule,
10
+ * so it gets immutable caching). Returns the final basename.
11
+ */
12
+ export function finalizeTailwindCss(tempPath: string): string {
13
+ const bytes = readFileSync(tempPath);
14
+ const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 10);
15
+ const name = `bosia-tw-${hash}.css`;
16
+ renameSync(tempPath, join(dirname(tempPath), name));
17
+ return name;
18
+ }
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 */