bosia 1.1.1 → 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.1",
3
+ "version": "1.1.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
package/src/core/build.ts CHANGED
@@ -17,7 +17,9 @@ import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
17
17
  import { makeBosiaPlugin } from "./plugin.ts";
18
18
  import { makeBosiaSvelteCompiler, svelteMapCache } from "./svelteCompiler.ts";
19
19
  import { finalizeComponentCss } from "./componentCss.ts";
20
+ import { buildPreloadMap } from "./preloadMap.ts";
20
21
  import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
22
+ import { precompressDir } from "./precompress.ts";
21
23
  import { loadEnv, classifyEnvVars } from "./env.ts";
22
24
  import { generateEnvModules } from "./envCodegen.ts";
23
25
  import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin, toPosix } from "./paths.ts";
@@ -252,6 +254,8 @@ const clientPromise = Bun.build({
252
254
  // Chunks are named after their source, which for routes is `+page` — and
253
255
  // Cloudflare's asset server answers a `+` in the path with a redirect.
254
256
  naming: { entry: "[name]-[hash].[ext]", chunk: "chunk-[hash].[ext]" },
257
+ // Read below to map each route to the chunks it needs (see preloadMap.ts).
258
+ metafile: true,
255
259
  minify: isProduction,
256
260
  sourcemap: isProduction ? "none" : "linked",
257
261
  define: {
@@ -346,14 +350,20 @@ const serverEntry = serverEntryOutput ? basename(serverEntryOutput.path) : "inde
346
350
 
347
351
  // 8. Write dist/manifest.json
348
352
  mkdirSync(OUT_DIR, { recursive: true });
353
+ const entryFile =
354
+ clientEntry ??
355
+ jsFiles.find((f) => f === "hydrate.js") ??
356
+ jsFiles.find((f) => f.startsWith("hydrate")) ??
357
+ "hydrate.js";
349
358
  const distManifest = {
350
359
  js: jsFiles,
351
360
  css: cssFiles,
352
- entry:
353
- clientEntry ??
354
- jsFiles.find((f) => f === "hydrate.js") ??
355
- jsFiles.find((f) => f.startsWith("hydrate")) ??
356
- "hydrate.js",
361
+ entry: entryFile,
362
+ // Per-route modulepreload list, so a page's code downloads with the entry
363
+ // instead of after it.
364
+ preload: clientResult.metafile
365
+ ? buildPreloadMap(clientResult.metafile, manifest.pages, entryFile)
366
+ : {},
357
367
  serverEntry,
358
368
  target,
359
369
  tw: twFile,
@@ -389,6 +399,25 @@ await prerenderStaticRoutes(manifest);
389
399
  // 10. Generate static site output (HTML + client assets + public → dist/static/)
390
400
  generateStaticSite();
391
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
+
392
421
  // 11. Workers target: a second server bundle for Cloudflare. The Bun one above
393
422
  // still exists — prerender just booted it to crawl static routes.
394
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";
@@ -24,6 +24,8 @@ export const distManifest: {
24
24
  basePath?: string;
25
25
  /** PUBLIC_* (non-static) names declared in .env files, stamped by the build. */
26
26
  publicEnv?: string[];
27
+ /** Client chunks per route pattern, stamped by the build (see preloadMap.ts). */
28
+ preload?: Record<string, string[]>;
27
29
  } = readArtifact("manifest.json") ?? { js: [], css: [], entry: "hydrate.js" };
28
30
 
29
31
  export const isDev = process.env.NODE_ENV !== "production";
@@ -55,6 +57,17 @@ export function baseScript(nonce?: string): string {
55
57
  : "";
56
58
  }
57
59
 
60
+ /** modulepreload links for the chunks `pattern` needs to hydrate, so they
61
+ * download alongside the entry instead of after it has run. Hashed names, so
62
+ * no buster (same reason as ENTRY). Unknown pattern or an older dist/ without
63
+ * the `preload` field → nothing, and the page loads as it did before. */
64
+ export function routePreloadLinks(pattern?: string): string {
65
+ if (!pattern) return "";
66
+ return (distManifest.preload?.[pattern] ?? [])
67
+ .map((f) => `\n <link rel="modulepreload" href="${DIST}/${f}">`)
68
+ .join("");
69
+ }
70
+
58
71
  /** Tailwind stylesheet link. Content-hashed name needs no cache buster — the
59
72
  * hash IS the buster. Fallback keeps older dist/ artifacts (no `tw` field) styled. */
60
73
  function twCssLink(): string {
@@ -161,6 +174,7 @@ export function buildHtml(
161
174
  bodyEndExtras?: string[],
162
175
  segments?: AppHtmlSegments,
163
176
  metadata?: Metadata | null,
177
+ pattern?: string,
164
178
  ): string {
165
179
  // An app writes <a href="/masuk">; under a base the browser has to be handed
166
180
  // /sso/masuk or it walks off this app entirely. Only the rendered markup is
@@ -208,6 +222,11 @@ export function buildHtml(
208
222
 
209
223
  const bodyEnd = bodyEndExtras?.length ? "\n " + bodyEndExtras.join("\n ") : "";
210
224
 
225
+ // Same hints the streaming shell sends; skipped when no JS runs at all.
226
+ const preloads = csr
227
+ ? ` <link rel="modulepreload" href="${ENTRY}">${routePreloadLinks(pattern)}\n`
228
+ : "";
229
+
211
230
  if (segments) {
212
231
  const safeKey = safeLang(lang);
213
232
  const headOpenInterpolated = interpolateSegment(segments.headOpen, {
@@ -225,6 +244,7 @@ export function buildHtml(
225
244
  `\n ${faviconLine}${twCssLink()}\n` +
226
245
  componentCssLinks() +
227
246
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
247
+ preloads +
228
248
  ` ${fallbackTitle}${metaTags}${head}` +
229
249
  headCloseInterpolated +
230
250
  (body ? "" : `\n${SPINNER}`) +
@@ -243,7 +263,7 @@ export function buildHtml(
243
263
  ${metaTags} ${head}
244
264
  ${twCssLink()}
245
265
  ${componentCssLinks()} <script${n}>${THEME_INIT_JS}</script>
246
- </head>
266
+ ${preloads}</head>
247
267
  <body>
248
268
  <div id="app">${body}</div>${scripts}${bodyEnd}
249
269
  </body>
@@ -257,6 +277,7 @@ export function buildHtmlShellOpen(
257
277
  lang?: string,
258
278
  nonce?: string,
259
279
  segments?: AppHtmlSegments,
280
+ pattern?: string,
260
281
  ): string {
261
282
  const key = safeLang(lang);
262
283
  const n = nonceAttr(nonce);
@@ -270,7 +291,8 @@ export function buildHtmlShellOpen(
270
291
  `\n ${faviconLine}${twCssLink()}\n` +
271
292
  componentCssLinks() +
272
293
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
273
- ` <link rel="modulepreload" href="${ENTRY}">`
294
+ ` <link rel="modulepreload" href="${ENTRY}">` +
295
+ routePreloadLinks(pattern)
274
296
  );
275
297
  }
276
298
 
@@ -282,7 +304,8 @@ export function buildHtmlShellOpen(
282
304
  ` ${twCssLink()}\n` +
283
305
  componentCssLinks() +
284
306
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
285
- ` <link rel="modulepreload" href="${ENTRY}">`
307
+ ` <link rel="modulepreload" href="${ENTRY}">` +
308
+ routePreloadLinks(pattern)
286
309
  );
287
310
  }
288
311
 
@@ -441,12 +464,43 @@ export function disableCompression(): void {
441
464
  // Shared, stateless — one instance instead of a fresh allocation per response.
442
465
  const textEncoder = new TextEncoder();
443
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
+
444
488
  export function compress(
445
489
  body: string,
446
490
  contentType: string,
447
491
  req: Request,
448
492
  status = 200,
449
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>,
450
504
  ): Response {
451
505
  // Base keys lowercased so lowercased extraHeaders (e.g. loader setHeaders)
452
506
  // override them instead of getting comma-joined by Headers.
@@ -455,15 +509,14 @@ export function compress(
455
509
  vary: "Accept-Encoding",
456
510
  ...extraHeaders,
457
511
  };
458
- const accept = req.headers.get("accept-encoding") ?? "";
459
- const bytes = textEncoder.encode(body);
512
+ const enc = pickEncoding(req.headers.get("accept-encoding"));
460
513
  // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
461
514
  // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
462
- if (compressionOn && !isDev && bytes.length > GZIP_MIN_BYTES && accept.includes("gzip")) {
463
- return new Response(gzipSync(bytes), {
515
+ if (compressionOn && !isDev && enc && bytes.length > GZIP_MIN_BYTES) {
516
+ return new Response(encodeBytes(bytes, enc), {
464
517
  ...PRECOMPRESSED,
465
518
  status,
466
- headers: { ...headers, "content-encoding": "gzip" },
519
+ headers: { ...headers, "content-encoding": enc },
467
520
  });
468
521
  }
469
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
+ }
@@ -0,0 +1,58 @@
1
+ import { toPosix } from "./paths.ts";
2
+ import type { PageRoute } from "./types.ts";
3
+
4
+ /** The slice of Bun's `BuildMetafile` this module reads. */
5
+ export interface PreloadMetafile {
6
+ outputs: Record<string, { entryPoint?: string; imports?: { path: string; kind: string }[] }>;
7
+ }
8
+
9
+ /**
10
+ * Client chunks each page route needs before it can hydrate, keyed by route
11
+ * pattern, as paths relative to `dist/client` (same shape as `manifest.js`).
12
+ *
13
+ * Without this the browser only learns about a route's code after the entry has
14
+ * run and `import()`ed it, and only learns about that chunk's own imports after
15
+ * it arrives — a serial waterfall during which clicks hit dead buttons. Emitting
16
+ * these as modulepreload links lets them download alongside the entry.
17
+ *
18
+ * Route chunks are found by their metafile `entryPoint` (Bun makes every
19
+ * dynamically imported route file its own entry chunk, named relative to the
20
+ * app root), then followed through static imports only — a `dynamic-import`
21
+ * edge is code the page may never ask for. The client entry is left out: the
22
+ * shell already preloads it.
23
+ */
24
+ export function buildPreloadMap(
25
+ metafile: PreloadMetafile,
26
+ pages: Pick<PageRoute, "pattern" | "page" | "layouts">[],
27
+ entry: string,
28
+ ): Record<string, string[]> {
29
+ const clean = (p: string) => toPosix(p).replace(/^\.\//, "");
30
+ const outputs = new Map<string, { path: string; kind: string }[]>();
31
+ const chunkBySource = new Map<string, string>();
32
+ for (const [out, info] of Object.entries(metafile.outputs)) {
33
+ if (!out.endsWith(".js")) continue;
34
+ outputs.set(clean(out), info.imports ?? []);
35
+ if (info.entryPoint) chunkBySource.set(clean(info.entryPoint), clean(out));
36
+ }
37
+
38
+ const map: Record<string, string[]> = {};
39
+ for (const route of pages) {
40
+ const seen = new Set<string>([entry]);
41
+ const files: string[] = [];
42
+ const visit = (chunk: string) => {
43
+ if (seen.has(chunk)) return;
44
+ seen.add(chunk);
45
+ files.push(chunk);
46
+ for (const imp of outputs.get(chunk) ?? []) {
47
+ if (imp.kind === "import-statement") visit(clean(imp.path));
48
+ }
49
+ };
50
+ // Layouts first: they wrap the page, so hydration needs them just as much.
51
+ for (const src of [...route.layouts, route.page]) {
52
+ const chunk = chunkBySource.get(`src/routes/${toPosix(src)}`);
53
+ if (chunk) visit(chunk);
54
+ }
55
+ if (files.length) map[route.pattern] = files;
56
+ }
57
+ return map;
58
+ }
@@ -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.
@@ -761,7 +763,7 @@ export async function renderSSRStream(
761
763
  );
762
764
  }
763
765
  const html =
764
- buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments) +
766
+ buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments, route.pattern) +
765
767
  buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
766
768
  buildHtmlTail(
767
769
  "",
@@ -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,9 +817,19 @@ 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
- enc.encode(buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments)),
825
+ enc.encode(
826
+ buildHtmlShellOpen(
827
+ metadata?.lang,
828
+ nonce,
829
+ appHtmlSegments,
830
+ data.csr ? route.pattern : undefined,
831
+ ),
832
+ ),
823
833
  enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
824
834
  enc.encode(
825
835
  buildHtmlTail(
@@ -839,22 +849,23 @@ export async function renderSSRStream(
839
849
  ),
840
850
  ];
841
851
 
842
- // ── 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 ──
843
855
  // Skip if the handler set cookies — cached response can't reproduce
844
- // per-request Set-Cookie headers. Compression runs in a microtask so
845
- // 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.
846
858
  if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
847
- const fullBody = concatChunks(chunks);
848
859
  // Oversized bodies skip early so they never pay compression; cacheSet
849
860
  // re-checks as the authoritative guard.
850
861
  if (CACHE_MAX_BODY_BYTES === 0 || fullBody.length <= CACHE_MAX_BODY_BYTES) {
851
862
  const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
852
863
  const keyForWrite = cacheKey;
853
- // Hand the gate release to the write microtask: waiters resume only
864
+ // Hand the gate release to the deferred write: waiters resume only
854
865
  // after cacheSet ran, so their re-check hits.
855
866
  const rel = releaseMiss;
856
867
  releaseMiss = null;
857
- queueMicrotask(() => {
868
+ deferCacheWrite(() => {
858
869
  try {
859
870
  const { gzip, brotli } = buildCompressedVariants(fullBody);
860
871
  cacheSet(
@@ -877,35 +888,7 @@ export async function renderSSRStream(
877
888
  }
878
889
  }
879
890
 
880
- let i = 0;
881
- let cancelled = false;
882
- const onAbort = () => {
883
- cancelled = true;
884
- };
885
- req.signal.addEventListener("abort", onAbort, { once: true });
886
-
887
- const stream = new ReadableStream<Uint8Array>({
888
- pull(controller) {
889
- if (cancelled || i >= chunks.length) {
890
- controller.close();
891
- req.signal.removeEventListener("abort", onAbort);
892
- return;
893
- }
894
- controller.enqueue(chunks[i++]);
895
- if (i >= chunks.length) {
896
- controller.close();
897
- req.signal.removeEventListener("abort", onAbort);
898
- }
899
- },
900
- cancel() {
901
- cancelled = true;
902
- req.signal.removeEventListener("abort", onAbort);
903
- },
904
- });
905
-
906
- return new Response(stream, {
907
- headers: { "content-type": "text/html; charset=utf-8", ...data.loaderHeaders },
908
- });
891
+ return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, data.loaderHeaders);
909
892
  } finally {
910
893
  releaseMiss?.();
911
894
  }
@@ -1031,6 +1014,7 @@ export async function renderPageWithFormData(
1031
1014
  bodyEndExtras,
1032
1015
  appHtmlSegments,
1033
1016
  metadata,
1017
+ route.pattern,
1034
1018
  );
1035
1019
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
1036
1020
  }
@@ -1065,6 +1049,7 @@ export async function renderPageWithFormData(
1065
1049
  bodyEndExtras,
1066
1050
  appHtmlSegments,
1067
1051
  metadata,
1052
+ route.pattern,
1068
1053
  );
1069
1054
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
1070
1055
  }
@@ -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
  }