bosia 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
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,7 +279,7 @@ 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. */
282
+ /** Build gzip + brotli copies of body. Sync — call it from `deferCacheWrite`. */
284
283
  export function buildCompressedVariants(body: Bytes): {
285
284
  gzip: Bytes | null;
286
285
  brotli: Bytes | null;
@@ -290,24 +289,35 @@ export function buildCompressedVariants(body: Bytes): {
290
289
  let gzip: Bytes | null = null;
291
290
  let brotli: Bytes | null = null;
292
291
  try {
293
- gzip = new Uint8Array(gzipSync(body)) as Bytes;
292
+ gzip = encodeBytes(body, "gzip");
294
293
  } catch {
295
294
  gzip = null;
296
295
  }
297
296
  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;
297
+ // Quality 5 (set in encodeBytes): ~20x faster than the default 11, still
298
+ // beats gzip on size. 11 would block the event loop ~17ms/500KB.
299
+ brotli = encodeBytes(body, "br");
305
300
  } catch {
306
301
  brotli = null;
307
302
  }
308
303
  return { gzip, brotli };
309
304
  }
310
305
 
306
+ /**
307
+ * Run a cache write after the current response has gone out. A microtask
308
+ * would not: it fires before the awaiting caller resumes, so gzip + brotli of
309
+ * the body ran ahead of the first byte. `setImmediate` yields to I/O first.
310
+ * Workers keeps the microtask — it compresses nothing (the edge does), and a
311
+ * timer after the response returns is not guaranteed to run there.
312
+ *
313
+ * On a miss the variant sent to the client is compressed twice (response +
314
+ * cache). Off the critical path; pass the built variant in if it ever shows up.
315
+ */
316
+ export function deferCacheWrite(fn: () => void | Promise<void>): void {
317
+ if (compressionOn) setImmediate(fn);
318
+ else queueMicrotask(fn);
319
+ }
320
+
311
321
  /** Concatenate multiple Uint8Array chunks into one buffer. */
312
322
  export function concatChunks(chunks: Uint8Array[]): Bytes {
313
323
  let total = 0;
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,43 @@ 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
+ // Quality 5: close to gzip's speed with a better ratio — runs per request.
479
+ const BROTLI_RUNTIME = { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } };
480
+
481
+ /** Compress bytes on the request path. The one place runtime quality is set —
482
+ * cache.ts builds its stored variants through here too. */
483
+ export function encodeBytes(bytes: Uint8Array, enc: Encoding): Uint8Array<ArrayBuffer> {
484
+ const out = enc === "br" ? brotliCompressSync(bytes, BROTLI_RUNTIME) : gzipSync(bytes);
485
+ return new Uint8Array(out) as Uint8Array<ArrayBuffer>;
486
+ }
487
+
467
488
  export function compress(
468
489
  body: string,
469
490
  contentType: string,
470
491
  req: Request,
471
492
  status = 200,
472
493
  extraHeaders?: Record<string, string>,
494
+ ): Response {
495
+ return compressBytes(textEncoder.encode(body), contentType, req, status, extraHeaders);
496
+ }
497
+
498
+ export function compressBytes(
499
+ bytes: Uint8Array<ArrayBuffer>,
500
+ contentType: string,
501
+ req: Request,
502
+ status = 200,
503
+ extraHeaders?: Record<string, string>,
473
504
  ): Response {
474
505
  // Base keys lowercased so lowercased extraHeaders (e.g. loader setHeaders)
475
506
  // override them instead of getting comma-joined by Headers.
@@ -478,15 +509,14 @@ export function compress(
478
509
  vary: "Accept-Encoding",
479
510
  ...extraHeaders,
480
511
  };
481
- const accept = req.headers.get("accept-encoding") ?? "";
482
- const bytes = textEncoder.encode(body);
512
+ const enc = pickEncoding(req.headers.get("accept-encoding"));
483
513
  // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
484
514
  // 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), {
515
+ if (compressionOn && !isDev && enc && bytes.length > GZIP_MIN_BYTES) {
516
+ return new Response(encodeBytes(bytes, enc), {
487
517
  ...PRECOMPRESSED,
488
518
  status,
489
- headers: { ...headers, "content-encoding": "gzip" },
519
+ headers: { ...headers, "content-encoding": enc },
490
520
  });
491
521
  }
492
522
  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,6 +32,7 @@ import {
31
32
  buildMetadataChunk,
32
33
  buildHtmlTail,
33
34
  compress,
35
+ compressBytes,
34
36
  isDev,
35
37
  } from "./html.ts";
36
38
  import type { Metadata } from "./hooks.ts";
@@ -640,7 +642,7 @@ export async function renderSSRStream(
640
642
  // INVARIANT: releaseMiss must fire exactly once — a missed release() hangs
641
643
  // coalesced waiters for the process lifetime. Every early return and throw
642
644
  // below must stay inside this try; the cache-write path hands the release
643
- // off to its cacheSet microtask by nulling releaseMiss first.
645
+ // off to its deferred cacheSet by nulling releaseMiss first.
644
646
  try {
645
647
  // ── Pre-stream phase: resolve metadata before committing to a 200 ──
646
648
  // Errors here return a proper error response with correct status code.
@@ -777,9 +779,7 @@ export async function renderSSRStream(
777
779
  data.layoutDeps,
778
780
  appHtmlSegments,
779
781
  );
780
- return new Response(html, {
781
- headers: { "content-type": "text/html; charset=utf-8", ...data.loaderHeaders },
782
- });
782
+ return compress(html, "text/html; charset=utf-8", req, 200, data.loaderHeaders);
783
783
  }
784
784
 
785
785
  // Render-first: run render() before committing to a 200. Failure → proper error page
@@ -817,7 +817,10 @@ export async function renderSSRStream(
817
817
  );
818
818
  }
819
819
 
820
- // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
820
+ // Every chunk is built before the first byte, so the body goes out whole and
821
+ // compressed — a stream here never streamed, it only skipped compression.
822
+ // Truly progressive SSR (ROADMAP) would pipe a stream through
823
+ // CompressionStream instead.
821
824
  const chunks: Uint8Array[] = [
822
825
  enc.encode(
823
826
  buildHtmlShellOpen(
@@ -846,22 +849,23 @@ export async function renderSSRStream(
846
849
  ),
847
850
  ];
848
851
 
849
- // ── Response cache: write after chunks built, before stream creation ──
852
+ const fullBody = concatChunks(chunks);
853
+
854
+ // ── Response cache: write after the response has gone out ──
850
855
  // 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.
856
+ // per-request Set-Cookie headers. deferCacheWrite runs the variant
857
+ // compression after this response is flushed, not ahead of it.
853
858
  if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
854
- const fullBody = concatChunks(chunks);
855
859
  // Oversized bodies skip early so they never pay compression; cacheSet
856
860
  // re-checks as the authoritative guard.
857
861
  if (CACHE_MAX_BODY_BYTES === 0 || fullBody.length <= CACHE_MAX_BODY_BYTES) {
858
862
  const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
859
863
  const keyForWrite = cacheKey;
860
- // Hand the gate release to the write microtask: waiters resume only
864
+ // Hand the gate release to the deferred write: waiters resume only
861
865
  // after cacheSet ran, so their re-check hits.
862
866
  const rel = releaseMiss;
863
867
  releaseMiss = null;
864
- queueMicrotask(() => {
868
+ deferCacheWrite(() => {
865
869
  try {
866
870
  const { gzip, brotli } = buildCompressedVariants(fullBody);
867
871
  cacheSet(
@@ -884,35 +888,7 @@ export async function renderSSRStream(
884
888
  }
885
889
  }
886
890
 
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
- });
891
+ return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, data.loaderHeaders);
916
892
  } finally {
917
893
  releaseMiss?.();
918
894
  }
@@ -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,9 @@
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";
3
4
 
4
- export type StaticEntry = { absPath: string; cacheControl?: string };
5
+ /** `br`/`gz`: absolute paths of build-time precompressed siblings (precompress.ts). */
6
+ export type StaticEntry = { absPath: string; cacheControl?: string; br?: string; gz?: string };
5
7
  export type StaticManifest = Map<string, StaticEntry>;
6
8
 
7
9
  const HASHED_BASENAME = /\-[a-z0-9]{8,}\.[a-z]+$/;
@@ -49,9 +51,16 @@ export function buildStaticManifest(outDir: string): StaticManifest {
49
51
 
50
52
  const clientRoot = join(outAbs, "client");
51
53
  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 });
54
+ for (const entry of withSiblings(walk(clientRoot))) {
55
+ const cacheControl = HASHED_BASENAME.test(basename(entry.rel))
56
+ ? IMMUTABLE_CACHE
57
+ : DEFAULT_CACHE;
58
+ addOnce(manifest, `/dist/client/${entry.rel}`, {
59
+ absPath: entry.abs,
60
+ cacheControl,
61
+ br: entry.br,
62
+ gz: entry.gz,
63
+ });
55
64
  }
56
65
  }
57
66
 
@@ -99,6 +108,54 @@ export function buildStaticManifest(outDir: string): StaticManifest {
99
108
  return manifest;
100
109
  }
101
110
 
111
+ /**
112
+ * Fold `x.br` / `x.gz` into the entry for `x`. The variants are not URLs of
113
+ * their own — a request for `/chunk.js.br` is a miss, like any file the build
114
+ * never meant to publish. A `.br` with no original is kept as a plain file.
115
+ */
116
+ function withSiblings(
117
+ all: Iterable<{ abs: string; rel: string }>,
118
+ ): Array<{ abs: string; rel: string; br?: string; gz?: string }> {
119
+ const list = [...all];
120
+ const byRel = new Map(list.map((f) => [f.rel, f]));
121
+ const out: Array<{ abs: string; rel: string; br?: string; gz?: string }> = [];
122
+ for (const f of list) {
123
+ const ext = f.rel.endsWith(".br") ? ".br" : f.rel.endsWith(".gz") ? ".gz" : null;
124
+ if (ext && byRel.has(f.rel.slice(0, -ext.length))) continue;
125
+ out.push({
126
+ ...f,
127
+ br: byRel.get(`${f.rel}.br`)?.abs,
128
+ gz: byRel.get(`${f.rel}.gz`)?.abs,
129
+ });
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * Response for a manifest hit, precompressed when the build left a variant the
136
+ * client accepts. Content-Type comes from the original — `Bun.file("x.js.br")`
137
+ * would infer the wrong one. `Vary` only when variants exist: a raw-only file
138
+ * answers every client the same way.
139
+ */
140
+ export function serveStatic(
141
+ entry: StaticEntry,
142
+ req: Request,
143
+ headers: Record<string, string> = {},
144
+ ): Response {
145
+ const out: Record<string, string> = { ...headers };
146
+ if (entry.cacheControl) out["Cache-Control"] = entry.cacheControl;
147
+ if (!entry.br && !entry.gz) return new Response(Bun.file(entry.absPath), { headers: out });
148
+
149
+ out["Vary"] = "Accept-Encoding";
150
+ const enc = pickEncoding(req.headers.get("accept-encoding"));
151
+ const variant = enc === "br" ? (entry.br ?? entry.gz) : enc === "gzip" ? entry.gz : undefined;
152
+ if (!variant) return new Response(Bun.file(entry.absPath), { headers: out });
153
+
154
+ out["Content-Type"] ??= Bun.file(entry.absPath).type;
155
+ out["Content-Encoding"] = variant === entry.br ? "br" : "gzip";
156
+ return new Response(Bun.file(variant), { ...PRECOMPRESSED, headers: out });
157
+ }
158
+
102
159
  export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
103
160
  const raw = urlPath.split("?")[0];
104
161
  // Manifest keys are raw filenames; URLs arrive percent-encoded.
@@ -117,10 +174,10 @@ export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticE
117
174
  * On collision the `…/index.html` variant wins (mirrors the old runtime
118
175
  * candidate order). Replaces per-request `Bun.file().exists()` probes.
119
176
  */
120
- export function buildPrerenderManifest(outDir: string): Map<string, string> {
121
- const manifest = new Map<string, string>();
177
+ export function buildPrerenderManifest(outDir: string): Map<string, StaticEntry> {
178
+ const manifest = new Map<string, StaticEntry>();
122
179
  const root = join(resolvePath(outDir), "prerendered");
123
- for (const { abs, rel } of walk(root)) {
180
+ for (const { abs, rel, br, gz } of withSiblings(walk(root))) {
124
181
  if (!rel.endsWith(".html")) continue;
125
182
  let key: string;
126
183
  let isIndex = false;
@@ -133,7 +190,7 @@ export function buildPrerenderManifest(outDir: string): Map<string, string> {
133
190
  } else {
134
191
  key = `/${rel.slice(0, -".html".length)}`;
135
192
  }
136
- if (isIndex || !manifest.has(key)) manifest.set(key, abs);
193
+ if (isIndex || !manifest.has(key)) manifest.set(key, { absPath: abs, br, gz });
137
194
  }
138
195
  return manifest;
139
196
  }