bosia 1.1.2 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
@@ -0,0 +1,124 @@
1
+ // ─── In-memory Asset Cache ───────────────────────────────
2
+ // Keeps small static files and prerendered pages (and their .br/.gz variants)
3
+ // in memory after their first request, so later hits skip the per-request
4
+ // open/stat/read of `Bun.file`. Filled lazily — boot does no extra I/O, so a
5
+ // cold start costs the same as before. Byte-budget LRU, keyed by absolute path.
6
+ //
7
+ // Bun only: on Workers, static files are served by Workers Static Assets
8
+ // before the worker runs, so this module never sees them.
9
+
10
+ type Bytes = Uint8Array<ArrayBuffer>;
11
+ export type AssetHit = { bytes: Bytes; type: string };
12
+
13
+ function parseBytes(raw: string | undefined, fallback: number): number {
14
+ if (!raw) return fallback;
15
+ const n = parseInt(raw, 10);
16
+ if (!Number.isFinite(n) || n < 0) return fallback;
17
+ return n;
18
+ }
19
+
20
+ export const DEFAULT_ASSET_CACHE_MAX_BYTES = 52_428_800; // 50MB; 0 = disabled
21
+ export const DEFAULT_ASSET_CACHE_MAX_FILE_BYTES = 1_048_576; // 1MB
22
+
23
+ export function parseAssetCacheEnv(env: Record<string, string | undefined>): {
24
+ maxBytes: number;
25
+ maxFileBytes: number;
26
+ } {
27
+ return {
28
+ maxBytes: parseBytes(env.ASSET_CACHE_MAX_BYTES, DEFAULT_ASSET_CACHE_MAX_BYTES),
29
+ maxFileBytes: parseBytes(env.ASSET_CACHE_MAX_FILE_BYTES, DEFAULT_ASSET_CACHE_MAX_FILE_BYTES),
30
+ };
31
+ }
32
+
33
+ export class AssetCache {
34
+ private map = new Map<string, AssetHit>();
35
+ private inFlight = new Map<string, Promise<void>>();
36
+ private used = 0;
37
+
38
+ constructor(
39
+ readonly maxBytes: number,
40
+ readonly maxFileBytes: number,
41
+ ) {}
42
+
43
+ get enabled(): boolean {
44
+ return this.maxBytes > 0 && this.maxFileBytes > 0;
45
+ }
46
+
47
+ get usedBytes(): number {
48
+ return this.used;
49
+ }
50
+
51
+ get size(): number {
52
+ return this.map.size;
53
+ }
54
+
55
+ /** Hit promotes the entry (Map insertion order = LRU order). */
56
+ get(path: string): AssetHit | undefined {
57
+ const hit = this.map.get(path);
58
+ if (hit === undefined) return undefined;
59
+ this.map.delete(path);
60
+ this.map.set(path, hit);
61
+ return hit;
62
+ }
63
+
64
+ /**
65
+ * Read `path` into memory in the background. `type` is the Content-Type of
66
+ * the original file — a `.br` variant must not report its own. Errors are
67
+ * swallowed: the caller already answered from disk, and will again.
68
+ */
69
+ fill(path: string, type: string): void {
70
+ if (!this.enabled || this.map.has(path) || this.inFlight.has(path)) return;
71
+ const job = (async () => {
72
+ const file = Bun.file(path);
73
+ if (file.size > this.maxFileBytes || file.size > this.maxBytes) return;
74
+ const bytes = (await file.bytes()) as Bytes;
75
+ if (bytes.byteLength > this.maxFileBytes || bytes.byteLength > this.maxBytes) return;
76
+ this.store(path, { bytes, type });
77
+ })()
78
+ .catch(() => {})
79
+ .finally(() => this.inFlight.delete(path));
80
+ this.inFlight.set(path, job);
81
+ }
82
+
83
+ /** Resolves once every fill started so far has settled. For tests. */
84
+ async idle(): Promise<void> {
85
+ while (this.inFlight.size > 0) await Promise.all(this.inFlight.values());
86
+ }
87
+
88
+ clear(): void {
89
+ this.map.clear();
90
+ this.used = 0;
91
+ }
92
+
93
+ private store(path: string, hit: AssetHit): void {
94
+ const prev = this.map.get(path);
95
+ if (prev) {
96
+ this.used -= prev.bytes.byteLength;
97
+ this.map.delete(path);
98
+ }
99
+ const size = hit.bytes.byteLength;
100
+ for (const [oldPath, old] of this.map) {
101
+ if (this.used + size <= this.maxBytes) break;
102
+ this.map.delete(oldPath);
103
+ this.used -= old.bytes.byteLength;
104
+ }
105
+ this.map.set(path, hit);
106
+ this.used += size;
107
+ }
108
+ }
109
+
110
+ const env: Record<string, string | undefined> =
111
+ typeof process !== "undefined" && process.env ? process.env : {};
112
+ const limits = parseAssetCacheEnv(env);
113
+
114
+ export const assetCache = new AssetCache(limits.maxBytes, limits.maxFileBytes);
115
+
116
+ const mb = (n: number) => `${Math.round((n / 1_048_576) * 10) / 10}MB`;
117
+
118
+ export function logAssetCache(): void {
119
+ console.log(
120
+ assetCache.enabled
121
+ ? `🗂️ Asset cache: max ${mb(assetCache.maxBytes)}, max file ${mb(assetCache.maxFileBytes)}`
122
+ : "🗂️ Asset cache: disabled",
123
+ );
124
+ }
package/src/core/build.ts CHANGED
@@ -19,6 +19,7 @@ import { makeBosiaSvelteCompiler, svelteMapCache } from "./svelteCompiler.ts";
19
19
  import { finalizeComponentCss } from "./componentCss.ts";
20
20
  import { buildPreloadMap } from "./preloadMap.ts";
21
21
  import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
22
+ import { precompressDir } from "./precompress.ts";
22
23
  import { loadEnv, classifyEnvVars } from "./env.ts";
23
24
  import { generateEnvModules } from "./envCodegen.ts";
24
25
  import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin, toPosix } from "./paths.ts";
@@ -398,6 +399,25 @@ await prerenderStaticRoutes(manifest);
398
399
  // 10. Generate static site output (HTML + client assets + public → dist/static/)
399
400
  generateStaticSite();
400
401
 
402
+ // 10b. Precompress client assets + prerendered HTML (.br/.gz siblings) for the
403
+ // Bun server. Runs after the static mirror so dist/static — the Workers upload
404
+ // and static-host output — stays free of them; Workers' edge compresses itself.
405
+ // public/ is left raw: mostly already-compressed images, and it is the app's
406
+ // source dir. Precompress into dist/static and serve from there if that bites.
407
+ if (isProduction && target !== "workers") {
408
+ const t0 = performance.now();
409
+ const results = await Promise.all([
410
+ precompressDir(join(OUT_DIR, "client")),
411
+ precompressDir(join(OUT_DIR, "prerendered")),
412
+ ]);
413
+ const files = results.reduce((n, r) => n + r.files, 0);
414
+ const raw = results.reduce((n, r) => n + r.rawBytes, 0);
415
+ const br = results.reduce((n, r) => n + r.brBytes, 0);
416
+ console.log(
417
+ `✅ Precompressed ${files} files: ${Math.round(raw / 1024)}KB → ${Math.round(br / 1024)}KB br (${Math.round(performance.now() - t0)}ms)`,
418
+ );
419
+ }
420
+
401
421
  // 11. Workers target: a second server bundle for Cloudflare. The Bun one above
402
422
  // still exists — prerender just booted it to crawl static routes.
403
423
  if (target === "workers") await buildWorker();
package/src/core/cache.ts CHANGED
@@ -8,11 +8,10 @@
8
8
  // node:crypto / node:zlib rather than Bun.* — the same code runs on Bun and on
9
9
  // Cloudflare Workers (nodejs_compat), and both stay sync there.
10
10
  import { createHash } from "node:crypto";
11
- import { brotliCompressSync, gzipSync, constants as zlibConstants } from "node:zlib";
12
11
  import type { Cookies, LoaderDeps } from "./hooks.ts";
13
12
  import type { CookieJar } from "./cookies.ts";
14
13
  import { dedupKey } from "./dedup.ts";
15
- import { compressionOn, PRECOMPRESSED } from "./html.ts";
14
+ import { compressionOn, encodeBytes, PRECOMPRESSED } from "./html.ts";
16
15
 
17
16
  // ─── Config ──────────────────────────────────────────────
18
17
 
@@ -280,34 +279,51 @@ function cacheDeleteIndexOnly(key: string, entry: CacheEntry): void {
280
279
 
281
280
  // ─── Compression helpers ─────────────────────────────────
282
281
 
283
- /** Build gzip + brotli copies of body. Sync, runs in microtask. */
284
- export function buildCompressedVariants(body: Bytes): {
282
+ /**
283
+ * Build gzip + brotli copies of body. Sync — call it from `deferCacheWrite`.
284
+ * `prebuilt`: a variant already encoded for the response (at cache quality),
285
+ * reused as-is so a miss compresses each encoding once.
286
+ */
287
+ export function buildCompressedVariants(
288
+ body: Bytes,
289
+ prebuilt: { gzip?: Bytes; brotli?: Bytes } = {},
290
+ ): {
285
291
  gzip: Bytes | null;
286
292
  brotli: Bytes | null;
287
293
  } {
288
294
  const COMPRESS_MIN_BYTES = 2048;
289
295
  if (!compressionOn || body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
290
- let gzip: Bytes | null = null;
291
- let brotli: Bytes | null = null;
292
- try {
293
- gzip = new Uint8Array(gzipSync(body)) as Bytes;
294
- } catch {
295
- gzip = null;
296
+ let gzip: Bytes | null = prebuilt.gzip ?? null;
297
+ let brotli: Bytes | null = prebuilt.brotli ?? null;
298
+ if (!gzip) {
299
+ try {
300
+ gzip = encodeBytes(body, "gzip", "cache");
301
+ } catch {
302
+ gzip = null;
303
+ }
296
304
  }
297
- try {
298
- // Quality 5: ~20x faster than the default 11, still beats gzip on size.
299
- // This runs sync in a microtask — 11 would block the event loop ~17ms/500KB.
300
- brotli = new Uint8Array(
301
- brotliCompressSync(body, {
302
- params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 },
303
- }),
304
- ) as Bytes;
305
- } catch {
306
- brotli = null;
305
+ if (!brotli) {
306
+ try {
307
+ brotli = encodeBytes(body, "br", "cache");
308
+ } catch {
309
+ brotli = null;
310
+ }
307
311
  }
308
312
  return { gzip, brotli };
309
313
  }
310
314
 
315
+ /**
316
+ * Run a cache write after the current response has gone out. A microtask
317
+ * would not: it fires before the awaiting caller resumes, so gzip + brotli of
318
+ * the body ran ahead of the first byte. `setImmediate` yields to I/O first.
319
+ * Workers keeps the microtask — it compresses nothing (the edge does), and a
320
+ * timer after the response returns is not guaranteed to run there.
321
+ */
322
+ export function deferCacheWrite(fn: () => void | Promise<void>): void {
323
+ if (compressionOn) setImmediate(fn);
324
+ else queueMicrotask(fn);
325
+ }
326
+
311
327
  /** Concatenate multiple Uint8Array chunks into one buffer. */
312
328
  export function concatChunks(chunks: Uint8Array[]): Bytes {
313
329
  let total = 0;
package/src/core/env.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
 
4
4
  // ─── Framework-reserved vars ─────────────────────────────
5
5
  // These are controlled by Bosia itself — users access them via process.env directly.
6
+ // `test/env.test.ts` scans `src/` and fails when Bosia reads a name missing here.
6
7
  const FRAMEWORK_VARS = new Set([
7
8
  "PORT",
8
9
  "NODE_ENV",
@@ -20,8 +21,24 @@ const FRAMEWORK_VARS = new Set([
20
21
  "LOAD_TIMEOUT",
21
22
  "METADATA_TIMEOUT",
22
23
  "PRERENDER_TIMEOUT",
24
+ "PRERENDER_CONCURRENCY",
25
+ "IDLE_TIMEOUT",
26
+ "MAX_INFLIGHT",
27
+ "TRUST_PROXY",
28
+ "CSP_DIRECTIVES",
29
+ "DISABLE_X_FRAME_OPTIONS",
30
+ "CACHE_KEYS",
31
+ "CACHE_MAX_ENTRIES",
32
+ "CACHE_MAX_BODY_BYTES",
33
+ "ASSET_CACHE_MAX_BYTES",
34
+ "ASSET_CACHE_MAX_FILE_BYTES",
23
35
  ]);
24
36
 
37
+ /** Framework-owned: a listed name, or anything under the reserved `BOSIA_` prefix. */
38
+ export function isFrameworkVar(name: string): boolean {
39
+ return FRAMEWORK_VARS.has(name) || name.startsWith("BOSIA_");
40
+ }
41
+
25
42
  // ─── .env File Parser ────────────────────────────────────
26
43
 
27
44
  /** Valid JS/TS identifier: starts with letter/underscore, then alphanumeric/underscore. */
@@ -173,7 +190,7 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
173
190
  // Return only non-framework vars
174
191
  const result: Record<string, string> = {};
175
192
  for (const key of declaredNames) {
176
- if (!FRAMEWORK_VARS.has(key)) {
193
+ if (!isFrameworkVar(key)) {
177
194
  result[key] = process.env[key] ?? merged[key] ?? "";
178
195
  }
179
196
  }
package/src/core/html.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { gzipSync } from "node:zlib";
1
+ import { brotliCompressSync, gzipSync, constants as zlibConstants } from "node:zlib";
2
2
 
3
3
  import { readArtifact } from "./artifacts.ts";
4
4
  import { nonceAttr } from "./csp.ts";
@@ -464,12 +464,72 @@ export function disableCompression(): void {
464
464
  // Shared, stateless — one instance instead of a fresh allocation per response.
465
465
  const textEncoder = new TextEncoder();
466
466
 
467
+ export type Encoding = "br" | "gzip";
468
+
469
+ /** Best encoding the client accepts — brotli over gzip, null for identity.
470
+ * A substring check, not q-value parsing: no browser sends `br;q=0`. */
471
+ export function pickEncoding(accept: string | null): Encoding | null {
472
+ if (!accept) return null;
473
+ if (accept.includes("br")) return "br";
474
+ if (accept.includes("gzip")) return "gzip";
475
+ return null;
476
+ }
477
+
478
+ // Per-request brotli runs once per response, so it favors speed: q3 is ~2x
479
+ // faster than q5 for ~5–25% bigger output. Cached variants are built once and
480
+ // served many times, so they take q5. 11 (the default) would block the event
481
+ // loop ~17ms per 500KB.
482
+ export type Quality = "request" | "cache";
483
+ const BROTLI = {
484
+ request: { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 3 } },
485
+ cache: { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } },
486
+ };
487
+
488
+ /** The one place runtime compression quality is set — cache.ts builds its
489
+ * stored variants through here too. gzip keeps zlib's default level. */
490
+ export function encodeBytes(
491
+ bytes: Uint8Array,
492
+ enc: Encoding,
493
+ quality: Quality = "request",
494
+ ): Uint8Array<ArrayBuffer> {
495
+ const out = enc === "br" ? brotliCompressSync(bytes, BROTLI[quality]) : gzipSync(bytes);
496
+ return new Uint8Array(out) as Uint8Array<ArrayBuffer>;
497
+ }
498
+
499
+ export type Encoded = { enc: Encoding; encoded: Uint8Array<ArrayBuffer> };
500
+
501
+ /** The body this client gets, or null when it goes out uncompressed. */
502
+ export function encodeForRequest(
503
+ bytes: Uint8Array<ArrayBuffer>,
504
+ req: Request,
505
+ quality: Quality = "request",
506
+ ): Encoded | null {
507
+ const enc = pickEncoding(req.headers.get("accept-encoding"));
508
+ // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
509
+ // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
510
+ if (!compressionOn || isDev || !enc || bytes.length <= GZIP_MIN_BYTES) return null;
511
+ return { enc, encoded: encodeBytes(bytes, enc, quality) };
512
+ }
513
+
467
514
  export function compress(
468
515
  body: string,
469
516
  contentType: string,
470
517
  req: Request,
471
518
  status = 200,
472
519
  extraHeaders?: Record<string, string>,
520
+ ): Response {
521
+ return compressBytes(textEncoder.encode(body), contentType, req, status, extraHeaders);
522
+ }
523
+
524
+ /** `encoded`: the result of `encodeForRequest` when the caller already has it
525
+ * (a cache write reuses the same bytes); omitted, it is built here. */
526
+ export function compressBytes(
527
+ bytes: Uint8Array<ArrayBuffer>,
528
+ contentType: string,
529
+ req: Request,
530
+ status = 200,
531
+ extraHeaders?: Record<string, string>,
532
+ encoded: Encoded | null = encodeForRequest(bytes, req),
473
533
  ): Response {
474
534
  // Base keys lowercased so lowercased extraHeaders (e.g. loader setHeaders)
475
535
  // override them instead of getting comma-joined by Headers.
@@ -478,15 +538,11 @@ export function compress(
478
538
  vary: "Accept-Encoding",
479
539
  ...extraHeaders,
480
540
  };
481
- const accept = req.headers.get("accept-encoding") ?? "";
482
- const bytes = textEncoder.encode(body);
483
- // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
484
- // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
485
- if (compressionOn && !isDev && bytes.length > GZIP_MIN_BYTES && accept.includes("gzip")) {
486
- return new Response(gzipSync(bytes), {
541
+ if (encoded) {
542
+ return new Response(encoded.encoded, {
487
543
  ...PRECOMPRESSED,
488
544
  status,
489
- headers: { ...headers, "content-encoding": "gzip" },
545
+ headers: { ...headers, "content-encoding": encoded.enc },
490
546
  });
491
547
  }
492
548
  return new Response(bytes, { status, headers });
@@ -10,9 +10,9 @@ export interface ServerTimingOptions {
10
10
  /**
11
11
  * Adds a `Server-Timing` response header that reports how long each request
12
12
  * spent inside the framework's handler. Measured from `onRequest` to
13
- * `onAfterHandle` — so for streaming SSR routes this is "time to start
14
- * streaming," not full end-to-end. Headers must flush before the body, so a
15
- * true end-to-end value cannot be reported in a response header.
13
+ * `onAfterHandle` — the time to build the response, not to deliver it. A
14
+ * streaming `+server.ts` route reports "time to start streaming". Headers must
15
+ * flush before the body, so a true end-to-end value cannot be reported here.
16
16
  */
17
17
  export function serverTiming(options: ServerTimingOptions = {}): BosiaPlugin {
18
18
  const headerName = options.header ?? "Server-Timing";
@@ -0,0 +1,82 @@
1
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "fs";
2
+ import { extname, join } from "path";
3
+ import { promisify } from "util";
4
+ import { brotliCompress, gzip, constants as zlibConstants } from "node:zlib";
5
+
6
+ // ─── Build-time Precompression ───────────────────────────
7
+ // Writes `file.br` + `file.gz` next to each compressible build output so the
8
+ // server hands out ready-made bytes (staticManifest.ts `serveStatic`) instead of
9
+ // raw ones. Build time is paid once, so both run at max quality.
10
+
11
+ const brotliAsync = promisify(brotliCompress);
12
+ const gzipAsync = promisify(gzip);
13
+
14
+ const COMPRESSIBLE = new Set([
15
+ ".js",
16
+ ".css",
17
+ ".html",
18
+ ".json",
19
+ ".svg",
20
+ ".txt",
21
+ ".xml",
22
+ ".webmanifest",
23
+ ".map",
24
+ ]);
25
+
26
+ // Same floor as the runtime compress(): below it the overhead beats the saving.
27
+ const MIN_BYTES = 2048;
28
+
29
+ function* files(dir: string): Generator<string> {
30
+ let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
31
+ try {
32
+ entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" }) as unknown as Array<{
33
+ name: string;
34
+ isDirectory(): boolean;
35
+ isFile(): boolean;
36
+ }>;
37
+ } catch {
38
+ return;
39
+ }
40
+ for (const ent of entries) {
41
+ const abs = join(dir, ent.name);
42
+ if (ent.isDirectory()) yield* files(abs);
43
+ else if (ent.isFile()) yield abs;
44
+ }
45
+ }
46
+
47
+ export type PrecompressStats = { files: number; rawBytes: number; brBytes: number };
48
+
49
+ /**
50
+ * Precompress every compressible file under `dir`. A variant is only written
51
+ * when it is smaller than the original, so the server can trust any sibling it
52
+ * finds. Missing `dir` is a no-op.
53
+ */
54
+ export async function precompressDir(dir: string): Promise<PrecompressStats> {
55
+ const stats: PrecompressStats = { files: 0, rawBytes: 0, brBytes: 0 };
56
+ const jobs: Promise<void>[] = [];
57
+ for (const abs of files(dir)) {
58
+ if (!COMPRESSIBLE.has(extname(abs))) continue;
59
+ if (statSync(abs).size < MIN_BYTES) continue;
60
+ jobs.push(
61
+ (async () => {
62
+ const raw = readFileSync(abs);
63
+ const [br, gz] = await Promise.all([
64
+ brotliAsync(raw, {
65
+ params: {
66
+ [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY,
67
+ [zlibConstants.BROTLI_PARAM_SIZE_HINT]: raw.length,
68
+ },
69
+ }),
70
+ gzipAsync(raw, { level: 9 }),
71
+ ]);
72
+ if (br.length < raw.length) writeFileSync(`${abs}.br`, br);
73
+ if (gz.length < raw.length) writeFileSync(`${abs}.gz`, gz);
74
+ stats.files++;
75
+ stats.rawBytes += raw.length;
76
+ stats.brBytes += Math.min(br.length, raw.length);
77
+ })(),
78
+ );
79
+ }
80
+ await Promise.all(jobs);
81
+ return stats;
82
+ }
@@ -16,6 +16,7 @@ import {
16
16
  collectTags,
17
17
  computeCacheKey,
18
18
  concatChunks,
19
+ deferCacheWrite,
19
20
  serveCached,
20
21
  } from "./cache.ts";
21
22
  import type { CookieJar } from "./cookies.ts";
@@ -31,7 +32,10 @@ import {
31
32
  buildMetadataChunk,
32
33
  buildHtmlTail,
33
34
  compress,
35
+ compressBytes,
36
+ encodeForRequest,
34
37
  isDev,
38
+ type Encoded,
35
39
  } from "./html.ts";
36
40
  import type { Metadata } from "./hooks.ts";
37
41
  import { loadPlugins } from "./config.ts";
@@ -640,7 +644,7 @@ export async function renderSSRStream(
640
644
  // INVARIANT: releaseMiss must fire exactly once — a missed release() hangs
641
645
  // coalesced waiters for the process lifetime. Every early return and throw
642
646
  // below must stay inside this try; the cache-write path hands the release
643
- // off to its cacheSet microtask by nulling releaseMiss first.
647
+ // off to its deferred cacheSet by nulling releaseMiss first.
644
648
  try {
645
649
  // ── Pre-stream phase: resolve metadata before committing to a 200 ──
646
650
  // Errors here return a proper error response with correct status code.
@@ -777,9 +781,7 @@ export async function renderSSRStream(
777
781
  data.layoutDeps,
778
782
  appHtmlSegments,
779
783
  );
780
- return new Response(html, {
781
- headers: { "content-type": "text/html; charset=utf-8", ...data.loaderHeaders },
782
- });
784
+ return compress(html, "text/html; charset=utf-8", req, 200, data.loaderHeaders);
783
785
  }
784
786
 
785
787
  // Render-first: run render() before committing to a 200. Failure → proper error page
@@ -817,7 +819,10 @@ export async function renderSSRStream(
817
819
  );
818
820
  }
819
821
 
820
- // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
822
+ // Every chunk is built before the first byte, so the body goes out whole and
823
+ // compressed — a stream here never streamed, it only skipped compression.
824
+ // Truly progressive SSR (ROADMAP) would pipe a stream through
825
+ // CompressionStream instead.
821
826
  const chunks: Uint8Array[] = [
822
827
  enc.encode(
823
828
  buildHtmlShellOpen(
@@ -846,24 +851,37 @@ export async function renderSSRStream(
846
851
  ),
847
852
  ];
848
853
 
849
- // ── Response cache: write after chunks built, before stream creation ──
854
+ const fullBody = concatChunks(chunks);
855
+ // Set only on a cache write: the client's variant, encoded once at cache
856
+ // quality and shared by the response and the cache entry.
857
+ let sent: Encoded | null | undefined;
858
+
859
+ // ── Response cache: write after the response has gone out ──
850
860
  // Skip if the handler set cookies — cached response can't reproduce
851
- // per-request Set-Cookie headers. Compression runs in a microtask so
852
- // the response goes out first.
861
+ // per-request Set-Cookie headers. deferCacheWrite runs the variant
862
+ // compression after this response is flushed, not ahead of it.
853
863
  if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
854
- const fullBody = concatChunks(chunks);
855
864
  // Oversized bodies skip early so they never pay compression; cacheSet
856
865
  // re-checks as the authoritative guard.
857
866
  if (CACHE_MAX_BODY_BYTES === 0 || fullBody.length <= CACHE_MAX_BODY_BYTES) {
858
867
  const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
859
868
  const keyForWrite = cacheKey;
860
- // Hand the gate release to the write microtask: waiters resume only
869
+ // Hand the gate release to the deferred write: waiters resume only
861
870
  // after cacheSet ran, so their re-check hits.
862
871
  const rel = releaseMiss;
863
872
  releaseMiss = null;
864
- queueMicrotask(() => {
873
+ const encoded = encodeForRequest(fullBody, req, "cache");
874
+ sent = encoded;
875
+ deferCacheWrite(() => {
865
876
  try {
866
- const { gzip, brotli } = buildCompressedVariants(fullBody);
877
+ const { gzip, brotli } = buildCompressedVariants(
878
+ fullBody,
879
+ encoded?.enc === "br"
880
+ ? { brotli: encoded.encoded }
881
+ : encoded
882
+ ? { gzip: encoded.encoded }
883
+ : {},
884
+ );
867
885
  cacheSet(
868
886
  keyForWrite,
869
887
  {
@@ -884,35 +902,7 @@ export async function renderSSRStream(
884
902
  }
885
903
  }
886
904
 
887
- let i = 0;
888
- let cancelled = false;
889
- const onAbort = () => {
890
- cancelled = true;
891
- };
892
- req.signal.addEventListener("abort", onAbort, { once: true });
893
-
894
- const stream = new ReadableStream<Uint8Array>({
895
- pull(controller) {
896
- if (cancelled || i >= chunks.length) {
897
- controller.close();
898
- req.signal.removeEventListener("abort", onAbort);
899
- return;
900
- }
901
- controller.enqueue(chunks[i++]);
902
- if (i >= chunks.length) {
903
- controller.close();
904
- req.signal.removeEventListener("abort", onAbort);
905
- }
906
- },
907
- cancel() {
908
- cancelled = true;
909
- req.signal.removeEventListener("abort", onAbort);
910
- },
911
- });
912
-
913
- return new Response(stream, {
914
- headers: { "content-type": "text/html; charset=utf-8", ...data.loaderHeaders },
915
- });
905
+ return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, data.loaderHeaders, sent);
916
906
  } finally {
917
907
  releaseMiss?.();
918
908
  }
@@ -11,6 +11,7 @@ import { isDev } from "./html.ts";
11
11
  import { OUT_DIR } from "./paths.ts";
12
12
  import { pidsOnPort } from "./port.ts";
13
13
  import { CACHE_ENABLED, CACHE_KEYS } from "./cache.ts";
14
+ import { logAssetCache } from "./assetCache.ts";
14
15
 
15
16
  // ─── User Hooks ──────────────────────────────────────────
16
17
  // Production prefers the pre-bundled `${OUT_DIR}/hooks.server.js` emitted by the
@@ -44,6 +45,9 @@ if (hooksPath) {
44
45
 
45
46
  const app = await createApp({ handle });
46
47
 
48
+ // Dev serves files straight from disk (no manifest), so the cache never runs there.
49
+ if (!isDev) logAssetCache();
50
+
47
51
  // ─── Listen ──────────────────────────────────────────────
48
52
 
49
53
  const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 : 9000;
@@ -25,7 +25,12 @@ import { dev500WithPlugins } from "./dev-500.ts";
25
25
  import { OUT_DIR } from "./paths.ts";
26
26
  import { stripBase, withBase } from "./basePath.ts";
27
27
  import { currentBase } from "./appBase.ts";
28
- import { buildPrerenderManifest, buildStaticManifest, lookupStatic } from "./staticManifest.ts";
28
+ import {
29
+ buildPrerenderManifest,
30
+ buildStaticManifest,
31
+ lookupStatic,
32
+ serveStatic,
33
+ } from "./staticManifest.ts";
29
34
  import { dedup } from "./dedup.ts";
30
35
  import {
31
36
  CACHE_ENABLED,
@@ -35,6 +40,7 @@ import {
35
40
  cacheSet,
36
41
  coalesceMiss,
37
42
  computeCacheKey,
43
+ deferCacheWrite,
38
44
  serveCached,
39
45
  warnUncoveredCookies,
40
46
  } from "./cache.ts";
@@ -410,12 +416,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
410
416
  // api-before-static ordering intact for every user-facing path.
411
417
  if (staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
412
418
  const hit = lookupStatic(staticManifest, path);
413
- if (hit) {
414
- return new Response(
415
- Bun.file(hit.absPath),
416
- hit.cacheControl ? { headers: { "Cache-Control": hit.cacheControl } } : undefined,
417
- );
418
- }
419
+ if (hit) return serveStatic(hit, request);
419
420
  }
420
421
 
421
422
  // API routes (+server.ts) — resolve with `.json` alias preference.
@@ -428,7 +429,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
428
429
  if (warmingUp) return new Response(null, { status: 404 });
429
430
  // INVARIANT: once set, releaseApiMiss must fire exactly once — a missed
430
431
  // release() hangs coalesced waiters for the process lifetime. The cache
431
- // write path hands it off to its microtask by nulling it first.
432
+ // write path hands it off to its deferred write by nulling it first.
432
433
  let releaseApiMiss: (() => void) | null = null;
433
434
  try {
434
435
  const mod = await apiMatch.route.module();
@@ -518,11 +519,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
518
519
  const extraHeaders = captureCacheableHeaders(response.headers);
519
520
  const contentType = responseContentType || "application/octet-stream";
520
521
  const keyForWrite = apiCacheKey;
521
- // Hand the gate release to the write microtask: waiters resume only
522
+ // Hand the gate release to the deferred write: waiters resume only
522
523
  // after the cacheSet attempt, so their re-check hits.
523
524
  const rel = releaseApiMiss;
524
525
  releaseApiMiss = null;
525
- queueMicrotask(async () => {
526
+ deferCacheWrite(async () => {
526
527
  try {
527
528
  const buf = new Uint8Array(await cloned.arrayBuffer());
528
529
  // Oversized bodies skip early so they never pay compression;
@@ -588,12 +589,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
588
589
  // Prod fast path: single Map lookup, no per-request stat calls.
589
590
  if (staticManifest) {
590
591
  const hit = lookupStatic(staticManifest, path);
591
- if (hit) {
592
- return new Response(
593
- Bun.file(hit.absPath),
594
- hit.cacheControl ? { headers: { "Cache-Control": hit.cacheControl } } : undefined,
595
- );
596
- }
592
+ if (hit) return serveStatic(hit, request);
597
593
  return new Response("Not Found", { status: 404 });
598
594
  }
599
595
  // Dev: keep the per-request fallthrough so files dropped into `public/`
@@ -646,13 +642,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
646
642
  // Keys come from a boot-time walk of `dist/prerendered/`, not from the
647
643
  // URL, so no safePath needed — a non-matching path is just a miss.
648
644
  const key = path === "/" ? "/" : path.replace(/\/$/, "");
649
- const abs = prerenderManifest.get(key);
650
- if (abs) {
651
- return new Response(Bun.file(abs), {
652
- headers: {
653
- "Content-Type": "text/html; charset=utf-8",
654
- "Cache-Control": "public, max-age=3600",
655
- },
645
+ const hit = prerenderManifest.get(key);
646
+ if (hit) {
647
+ return serveStatic(hit, request, {
648
+ "Content-Type": "text/html; charset=utf-8",
649
+ "Cache-Control": "public, max-age=3600",
656
650
  });
657
651
  }
658
652
  }
@@ -1,7 +1,10 @@
1
1
  import { existsSync, readdirSync, statSync } from "fs";
2
2
  import { basename, join, resolve as resolvePath } from "path";
3
+ import { PRECOMPRESSED, pickEncoding } from "./html.ts";
4
+ import { AssetCache, assetCache } from "./assetCache.ts";
3
5
 
4
- export type StaticEntry = { absPath: string; cacheControl?: string };
6
+ /** `br`/`gz`: absolute paths of build-time precompressed siblings (precompress.ts). */
7
+ export type StaticEntry = { absPath: string; cacheControl?: string; br?: string; gz?: string };
5
8
  export type StaticManifest = Map<string, StaticEntry>;
6
9
 
7
10
  const HASHED_BASENAME = /\-[a-z0-9]{8,}\.[a-z]+$/;
@@ -49,9 +52,16 @@ export function buildStaticManifest(outDir: string): StaticManifest {
49
52
 
50
53
  const clientRoot = join(outAbs, "client");
51
54
  if (existsSync(clientRoot)) {
52
- for (const { abs, rel } of walk(clientRoot)) {
53
- const cacheControl = HASHED_BASENAME.test(basename(rel)) ? IMMUTABLE_CACHE : DEFAULT_CACHE;
54
- addOnce(manifest, `/dist/client/${rel}`, { absPath: abs, cacheControl });
55
+ for (const entry of withSiblings(walk(clientRoot))) {
56
+ const cacheControl = HASHED_BASENAME.test(basename(entry.rel))
57
+ ? IMMUTABLE_CACHE
58
+ : DEFAULT_CACHE;
59
+ addOnce(manifest, `/dist/client/${entry.rel}`, {
60
+ absPath: entry.abs,
61
+ cacheControl,
62
+ br: entry.br,
63
+ gz: entry.gz,
64
+ });
55
65
  }
56
66
  }
57
67
 
@@ -99,6 +109,121 @@ export function buildStaticManifest(outDir: string): StaticManifest {
99
109
  return manifest;
100
110
  }
101
111
 
112
+ /**
113
+ * Fold `x.br` / `x.gz` into the entry for `x`. The variants are not URLs of
114
+ * their own — a request for `/chunk.js.br` is a miss, like any file the build
115
+ * never meant to publish. A `.br` with no original is kept as a plain file.
116
+ */
117
+ function withSiblings(
118
+ all: Iterable<{ abs: string; rel: string }>,
119
+ ): Array<{ abs: string; rel: string; br?: string; gz?: string }> {
120
+ const list = [...all];
121
+ const byRel = new Map(list.map((f) => [f.rel, f]));
122
+ const out: Array<{ abs: string; rel: string; br?: string; gz?: string }> = [];
123
+ for (const f of list) {
124
+ const ext = f.rel.endsWith(".br") ? ".br" : f.rel.endsWith(".gz") ? ".gz" : null;
125
+ if (ext && byRel.has(f.rel.slice(0, -ext.length))) continue;
126
+ out.push({
127
+ ...f,
128
+ br: byRel.get(`${f.rel}.br`)?.abs,
129
+ gz: byRel.get(`${f.rel}.gz`)?.abs,
130
+ });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Response for a manifest hit, precompressed when the build left a variant the
137
+ * client accepts. Content-Type comes from the original — `Bun.file("x.js.br")`
138
+ * would infer the wrong one. `Vary` only when variants exist: a raw-only file
139
+ * answers every client the same way.
140
+ *
141
+ * Small files are answered from `assetCache` once a first request has read
142
+ * them; the miss itself is served from disk as before and fills the cache in
143
+ * the background. Range requests always go to disk — Bun answers those with a
144
+ * 206 for `Bun.file` bodies, not for bytes.
145
+ *
146
+ * Every answer carries an `ETag` (Bun's `Bun.file` responses have none), so a
147
+ * `no-cache` file or an expired prerendered page revalidates with a 304
148
+ * instead of downloading again.
149
+ */
150
+ export function serveStatic(
151
+ entry: StaticEntry,
152
+ req: Request,
153
+ headers: Record<string, string> = {},
154
+ cache: AssetCache = assetCache,
155
+ ): Response {
156
+ const out: Record<string, string> = { ...headers };
157
+ if (entry.cacheControl) out["Cache-Control"] = entry.cacheControl;
158
+
159
+ let path = entry.absPath;
160
+ let init: ResponseInit = {};
161
+ if (entry.br || entry.gz) {
162
+ out["Vary"] = "Accept-Encoding";
163
+ const enc = pickEncoding(req.headers.get("accept-encoding"));
164
+ const variant = enc === "br" ? (entry.br ?? entry.gz) : enc === "gzip" ? entry.gz : undefined;
165
+ if (variant) {
166
+ path = variant;
167
+ init = PRECOMPRESSED;
168
+ out["Content-Encoding"] = variant === entry.br ? "br" : "gzip";
169
+ }
170
+ }
171
+
172
+ const etag = etagFor(path, out["Content-Encoding"]);
173
+ if (etag) {
174
+ out["ETag"] = etag;
175
+ if (matchesEtag(req.headers.get("if-none-match"), etag)) {
176
+ delete out["Content-Encoding"];
177
+ delete out["Content-Type"];
178
+ return new Response(null, { status: 304, headers: out });
179
+ }
180
+ }
181
+
182
+ if (cache.enabled && !req.headers.has("range")) {
183
+ const hit = cache.get(path);
184
+ if (hit) {
185
+ out["Content-Type"] ??= hit.type;
186
+ return new Response(hit.bytes, { ...init, headers: out });
187
+ }
188
+ const type = out["Content-Type"] ?? Bun.file(entry.absPath).type;
189
+ cache.fill(path, type);
190
+ if (path !== entry.absPath) out["Content-Type"] = type;
191
+ } else if (path !== entry.absPath) {
192
+ out["Content-Type"] ??= Bun.file(entry.absPath).type;
193
+ }
194
+ return new Response(Bun.file(path), { ...init, headers: out });
195
+ }
196
+
197
+ // One stat per served path per process. Files under dist/ and public/ are not
198
+ // expected to change while the server runs; a redeploy restarts the process.
199
+ // If that ever stops holding, key the map on mtime too (one stat per request).
200
+ const etags = new Map<string, string | null>();
201
+
202
+ /** Strong ETag from size + mtime. The encoding suffix keeps br/gzip/raw apart. */
203
+ function etagFor(path: string, encoding: string | undefined): string | null {
204
+ let tag = etags.get(path);
205
+ if (tag === undefined) {
206
+ try {
207
+ const st = statSync(path);
208
+ const suffix = encoding === "br" ? "-br" : encoding === "gzip" ? "-gz" : "";
209
+ tag = `"${st.size.toString(36)}-${Math.floor(st.mtimeMs).toString(36)}${suffix}"`;
210
+ } catch {
211
+ tag = null;
212
+ }
213
+ etags.set(path, tag);
214
+ }
215
+ return tag;
216
+ }
217
+
218
+ function matchesEtag(header: string | null, etag: string): boolean {
219
+ if (!header) return false;
220
+ for (const raw of header.split(",")) {
221
+ const t = raw.trim();
222
+ if (t === "*" || t === etag || (t.startsWith("W/") && t.slice(2) === etag)) return true;
223
+ }
224
+ return false;
225
+ }
226
+
102
227
  export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
103
228
  const raw = urlPath.split("?")[0];
104
229
  // Manifest keys are raw filenames; URLs arrive percent-encoded.
@@ -117,10 +242,10 @@ export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticE
117
242
  * On collision the `…/index.html` variant wins (mirrors the old runtime
118
243
  * candidate order). Replaces per-request `Bun.file().exists()` probes.
119
244
  */
120
- export function buildPrerenderManifest(outDir: string): Map<string, string> {
121
- const manifest = new Map<string, string>();
245
+ export function buildPrerenderManifest(outDir: string): Map<string, StaticEntry> {
246
+ const manifest = new Map<string, StaticEntry>();
122
247
  const root = join(resolvePath(outDir), "prerendered");
123
- for (const { abs, rel } of walk(root)) {
248
+ for (const { abs, rel, br, gz } of withSiblings(walk(root))) {
124
249
  if (!rel.endsWith(".html")) continue;
125
250
  let key: string;
126
251
  let isIndex = false;
@@ -133,7 +258,7 @@ export function buildPrerenderManifest(outDir: string): Map<string, string> {
133
258
  } else {
134
259
  key = `/${rel.slice(0, -".html".length)}`;
135
260
  }
136
- if (isIndex || !manifest.has(key)) manifest.set(key, abs);
261
+ if (isIndex || !manifest.has(key)) manifest.set(key, { absPath: abs, br, gz });
137
262
  }
138
263
  return manifest;
139
264
  }
@@ -49,6 +49,13 @@ PUBLIC_STATIC_APP_NAME=My Bosia App
49
49
  # Max cache entries (LRU). Set to 0 to disable the cache entirely. Defaults to 500.
50
50
  # CACHE_MAX_ENTRIES=500
51
51
 
52
+ # Static files and prerendered pages are kept in memory after their first
53
+ # request (Bun only; ignored on Cloudflare Workers). Total budget in bytes,
54
+ # 0 = always read from disk. Defaults to 52428800 (50MB).
55
+ # ASSET_CACHE_MAX_BYTES=52428800
56
+ # Largest file kept in memory, in bytes. Defaults to 1048576 (1MB).
57
+ # ASSET_CACHE_MAX_FILE_BYTES=1048576
58
+
52
59
  # Timeout for load() functions (layout + page) in milliseconds. Defaults to 5000 (5s).
53
60
  # Set to 0 or Infinity to disable.
54
61
  # LOAD_TIMEOUT=5000
@@ -16,6 +16,13 @@ BODY_SIZE_LIMIT=512K
16
16
  # CACHE_KEYS=session,sid,auth,token,jwt,Authorization
17
17
  # CACHE_MAX_ENTRIES=500
18
18
 
19
+ # Static files and prerendered pages are kept in memory after their first
20
+ # request (Bun only; ignored on Cloudflare Workers). Total budget in bytes,
21
+ # 0 = always read from disk. Defaults to 52428800 (50MB).
22
+ # ASSET_CACHE_MAX_BYTES=52428800
23
+ # Largest file kept in memory, in bytes. Defaults to 1048576 (1MB).
24
+ # ASSET_CACHE_MAX_FILE_BYTES=1048576
25
+
19
26
  # Comma-separated list of allowed CORS origins for CSRF validation.
20
27
  # Leave unset to allow same-origin requests only.
21
28
  # Example: https://app.example.com, https://admin.example.com