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 +1 -1
- package/src/core/assetCache.ts +124 -0
- package/src/core/build.ts +20 -0
- package/src/core/cache.ts +36 -20
- package/src/core/env.ts +18 -1
- package/src/core/html.ts +64 -8
- package/src/core/plugins/server-timing.ts +3 -3
- package/src/core/precompress.ts +82 -0
- package/src/core/renderer.ts +31 -41
- package/src/core/server.bun.ts +4 -0
- package/src/core/server.ts +17 -23
- package/src/core/staticManifest.ts +133 -8
- package/templates/default/.env.example +7 -0
- package/templates/demo/.env.example +7 -0
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
284
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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 (!
|
|
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
|
-
|
|
482
|
-
|
|
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":
|
|
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` —
|
|
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,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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
852
|
-
//
|
|
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
|
|
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
|
-
|
|
873
|
+
const encoded = encodeForRequest(fullBody, req, "cache");
|
|
874
|
+
sent = encoded;
|
|
875
|
+
deferCacheWrite(() => {
|
|
865
876
|
try {
|
|
866
|
-
const { gzip, brotli } = buildCompressedVariants(
|
|
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
|
-
|
|
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
|
}
|
package/src/core/server.bun.ts
CHANGED
|
@@ -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;
|
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,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
|
-
|
|
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
|
|
53
|
-
const cacheControl = HASHED_BASENAME.test(basename(rel))
|
|
54
|
-
|
|
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,
|
|
121
|
-
const manifest = new Map<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
|