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 +1 -1
- package/src/core/build.ts +20 -0
- package/src/core/cache.ts +21 -11
- package/src/core/html.ts +36 -6
- package/src/core/plugins/server-timing.ts +3 -3
- package/src/core/precompress.ts +82 -0
- package/src/core/renderer.ts +16 -40
- package/src/core/server.ts +17 -23
- package/src/core/staticManifest.ts +65 -8
package/package.json
CHANGED
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
|
|
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 =
|
|
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
|
|
299
|
-
//
|
|
300
|
-
brotli =
|
|
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
|
|
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
|
|
486
|
-
return new Response(
|
|
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":
|
|
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` —
|
|
14
|
-
* streaming
|
|
15
|
-
* true end-to-end value cannot be reported
|
|
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
|
+
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
852
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/server.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
650
|
-
if (
|
|
651
|
-
return
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
const cacheControl = HASHED_BASENAME.test(basename(rel))
|
|
54
|
-
|
|
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,
|
|
121
|
-
const manifest = new Map<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
|
}
|