bosia 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
@@ -0,0 +1,124 @@
1
+ // ─── In-memory Asset Cache ───────────────────────────────
2
+ // Keeps small static files and prerendered pages (and their .br/.gz variants)
3
+ // in memory after their first request, so later hits skip the per-request
4
+ // open/stat/read of `Bun.file`. Filled lazily — boot does no extra I/O, so a
5
+ // cold start costs the same as before. Byte-budget LRU, keyed by absolute path.
6
+ //
7
+ // Bun only: on Workers, static files are served by Workers Static Assets
8
+ // before the worker runs, so this module never sees them.
9
+
10
+ type Bytes = Uint8Array<ArrayBuffer>;
11
+ export type AssetHit = { bytes: Bytes; type: string };
12
+
13
+ function parseBytes(raw: string | undefined, fallback: number): number {
14
+ if (!raw) return fallback;
15
+ const n = parseInt(raw, 10);
16
+ if (!Number.isFinite(n) || n < 0) return fallback;
17
+ return n;
18
+ }
19
+
20
+ export const DEFAULT_ASSET_CACHE_MAX_BYTES = 52_428_800; // 50MB; 0 = disabled
21
+ export const DEFAULT_ASSET_CACHE_MAX_FILE_BYTES = 1_048_576; // 1MB
22
+
23
+ export function parseAssetCacheEnv(env: Record<string, string | undefined>): {
24
+ maxBytes: number;
25
+ maxFileBytes: number;
26
+ } {
27
+ return {
28
+ maxBytes: parseBytes(env.ASSET_CACHE_MAX_BYTES, DEFAULT_ASSET_CACHE_MAX_BYTES),
29
+ maxFileBytes: parseBytes(env.ASSET_CACHE_MAX_FILE_BYTES, DEFAULT_ASSET_CACHE_MAX_FILE_BYTES),
30
+ };
31
+ }
32
+
33
+ export class AssetCache {
34
+ private map = new Map<string, AssetHit>();
35
+ private inFlight = new Map<string, Promise<void>>();
36
+ private used = 0;
37
+
38
+ constructor(
39
+ readonly maxBytes: number,
40
+ readonly maxFileBytes: number,
41
+ ) {}
42
+
43
+ get enabled(): boolean {
44
+ return this.maxBytes > 0 && this.maxFileBytes > 0;
45
+ }
46
+
47
+ get usedBytes(): number {
48
+ return this.used;
49
+ }
50
+
51
+ get size(): number {
52
+ return this.map.size;
53
+ }
54
+
55
+ /** Hit promotes the entry (Map insertion order = LRU order). */
56
+ get(path: string): AssetHit | undefined {
57
+ const hit = this.map.get(path);
58
+ if (hit === undefined) return undefined;
59
+ this.map.delete(path);
60
+ this.map.set(path, hit);
61
+ return hit;
62
+ }
63
+
64
+ /**
65
+ * Read `path` into memory in the background. `type` is the Content-Type of
66
+ * the original file — a `.br` variant must not report its own. Errors are
67
+ * swallowed: the caller already answered from disk, and will again.
68
+ */
69
+ fill(path: string, type: string): void {
70
+ if (!this.enabled || this.map.has(path) || this.inFlight.has(path)) return;
71
+ const job = (async () => {
72
+ const file = Bun.file(path);
73
+ if (file.size > this.maxFileBytes || file.size > this.maxBytes) return;
74
+ const bytes = (await file.bytes()) as Bytes;
75
+ if (bytes.byteLength > this.maxFileBytes || bytes.byteLength > this.maxBytes) return;
76
+ this.store(path, { bytes, type });
77
+ })()
78
+ .catch(() => {})
79
+ .finally(() => this.inFlight.delete(path));
80
+ this.inFlight.set(path, job);
81
+ }
82
+
83
+ /** Resolves once every fill started so far has settled. For tests. */
84
+ async idle(): Promise<void> {
85
+ while (this.inFlight.size > 0) await Promise.all(this.inFlight.values());
86
+ }
87
+
88
+ clear(): void {
89
+ this.map.clear();
90
+ this.used = 0;
91
+ }
92
+
93
+ private store(path: string, hit: AssetHit): void {
94
+ const prev = this.map.get(path);
95
+ if (prev) {
96
+ this.used -= prev.bytes.byteLength;
97
+ this.map.delete(path);
98
+ }
99
+ const size = hit.bytes.byteLength;
100
+ for (const [oldPath, old] of this.map) {
101
+ if (this.used + size <= this.maxBytes) break;
102
+ this.map.delete(oldPath);
103
+ this.used -= old.bytes.byteLength;
104
+ }
105
+ this.map.set(path, hit);
106
+ this.used += size;
107
+ }
108
+ }
109
+
110
+ const env: Record<string, string | undefined> =
111
+ typeof process !== "undefined" && process.env ? process.env : {};
112
+ const limits = parseAssetCacheEnv(env);
113
+
114
+ export const assetCache = new AssetCache(limits.maxBytes, limits.maxFileBytes);
115
+
116
+ const mb = (n: number) => `${Math.round((n / 1_048_576) * 10) / 10}MB`;
117
+
118
+ export function logAssetCache(): void {
119
+ console.log(
120
+ assetCache.enabled
121
+ ? `🗂️ Asset cache: max ${mb(assetCache.maxBytes)}, max file ${mb(assetCache.maxFileBytes)}`
122
+ : "🗂️ Asset cache: disabled",
123
+ );
124
+ }
package/src/core/cache.ts CHANGED
@@ -279,26 +279,35 @@ function cacheDeleteIndexOnly(key: string, entry: CacheEntry): void {
279
279
 
280
280
  // ─── Compression helpers ─────────────────────────────────
281
281
 
282
- /** Build gzip + brotli copies of body. Sync — call it from `deferCacheWrite`. */
283
- export function buildCompressedVariants(body: Bytes): {
282
+ /**
283
+ * Build gzip + brotli copies of body. Sync — call it from `deferCacheWrite`.
284
+ * `prebuilt`: a variant already encoded for the response (at cache quality),
285
+ * reused as-is so a miss compresses each encoding once.
286
+ */
287
+ export function buildCompressedVariants(
288
+ body: Bytes,
289
+ prebuilt: { gzip?: Bytes; brotli?: Bytes } = {},
290
+ ): {
284
291
  gzip: Bytes | null;
285
292
  brotli: Bytes | null;
286
293
  } {
287
294
  const COMPRESS_MIN_BYTES = 2048;
288
295
  if (!compressionOn || body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
289
- let gzip: Bytes | null = null;
290
- let brotli: Bytes | null = null;
291
- try {
292
- gzip = encodeBytes(body, "gzip");
293
- } catch {
294
- gzip = null;
296
+ let gzip: Bytes | null = prebuilt.gzip ?? null;
297
+ let brotli: Bytes | null = prebuilt.brotli ?? null;
298
+ if (!gzip) {
299
+ try {
300
+ gzip = encodeBytes(body, "gzip", "cache");
301
+ } catch {
302
+ gzip = null;
303
+ }
295
304
  }
296
- try {
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");
300
- } catch {
301
- brotli = null;
305
+ if (!brotli) {
306
+ try {
307
+ brotli = encodeBytes(body, "br", "cache");
308
+ } catch {
309
+ brotli = null;
310
+ }
302
311
  }
303
312
  return { gzip, brotli };
304
313
  }
@@ -309,9 +318,6 @@ export function buildCompressedVariants(body: Bytes): {
309
318
  * the body ran ahead of the first byte. `setImmediate` yields to I/O first.
310
319
  * Workers keeps the microtask — it compresses nothing (the edge does), and a
311
320
  * 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
321
  */
316
322
  export function deferCacheWrite(fn: () => void | Promise<void>): void {
317
323
  if (compressionOn) setImmediate(fn);
package/src/core/env.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
 
4
4
  // ─── Framework-reserved vars ─────────────────────────────
5
5
  // These are controlled by Bosia itself — users access them via process.env directly.
6
+ // `test/env.test.ts` scans `src/` and fails when Bosia reads a name missing here.
6
7
  const FRAMEWORK_VARS = new Set([
7
8
  "PORT",
8
9
  "NODE_ENV",
@@ -20,8 +21,24 @@ const FRAMEWORK_VARS = new Set([
20
21
  "LOAD_TIMEOUT",
21
22
  "METADATA_TIMEOUT",
22
23
  "PRERENDER_TIMEOUT",
24
+ "PRERENDER_CONCURRENCY",
25
+ "IDLE_TIMEOUT",
26
+ "MAX_INFLIGHT",
27
+ "TRUST_PROXY",
28
+ "CSP_DIRECTIVES",
29
+ "DISABLE_X_FRAME_OPTIONS",
30
+ "CACHE_KEYS",
31
+ "CACHE_MAX_ENTRIES",
32
+ "CACHE_MAX_BODY_BYTES",
33
+ "ASSET_CACHE_MAX_BYTES",
34
+ "ASSET_CACHE_MAX_FILE_BYTES",
23
35
  ]);
24
36
 
37
+ /** Framework-owned: a listed name, or anything under the reserved `BOSIA_` prefix. */
38
+ export function isFrameworkVar(name: string): boolean {
39
+ return FRAMEWORK_VARS.has(name) || name.startsWith("BOSIA_");
40
+ }
41
+
25
42
  // ─── .env File Parser ────────────────────────────────────
26
43
 
27
44
  /** Valid JS/TS identifier: starts with letter/underscore, then alphanumeric/underscore. */
@@ -173,7 +190,7 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
173
190
  // Return only non-framework vars
174
191
  const result: Record<string, string> = {};
175
192
  for (const key of declaredNames) {
176
- if (!FRAMEWORK_VARS.has(key)) {
193
+ if (!isFrameworkVar(key)) {
177
194
  result[key] = process.env[key] ?? merged[key] ?? "";
178
195
  }
179
196
  }
package/src/core/html.ts CHANGED
@@ -475,16 +475,42 @@ export function pickEncoding(accept: string | null): Encoding | null {
475
475
  return null;
476
476
  }
477
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);
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);
485
496
  return new Uint8Array(out) as Uint8Array<ArrayBuffer>;
486
497
  }
487
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
+
488
514
  export function compress(
489
515
  body: string,
490
516
  contentType: string,
@@ -495,12 +521,15 @@ export function compress(
495
521
  return compressBytes(textEncoder.encode(body), contentType, req, status, extraHeaders);
496
522
  }
497
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. */
498
526
  export function compressBytes(
499
527
  bytes: Uint8Array<ArrayBuffer>,
500
528
  contentType: string,
501
529
  req: Request,
502
530
  status = 200,
503
531
  extraHeaders?: Record<string, string>,
532
+ encoded: Encoded | null = encodeForRequest(bytes, req),
504
533
  ): Response {
505
534
  // Base keys lowercased so lowercased extraHeaders (e.g. loader setHeaders)
506
535
  // override them instead of getting comma-joined by Headers.
@@ -509,14 +538,11 @@ export function compressBytes(
509
538
  vary: "Accept-Encoding",
510
539
  ...extraHeaders,
511
540
  };
512
- const enc = pickEncoding(req.headers.get("accept-encoding"));
513
- // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
514
- // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
515
- if (compressionOn && !isDev && enc && bytes.length > GZIP_MIN_BYTES) {
516
- return new Response(encodeBytes(bytes, enc), {
541
+ if (encoded) {
542
+ return new Response(encoded.encoded, {
517
543
  ...PRECOMPRESSED,
518
544
  status,
519
- headers: { ...headers, "content-encoding": enc },
545
+ headers: { ...headers, "content-encoding": encoded.enc },
520
546
  });
521
547
  }
522
548
  return new Response(bytes, { status, headers });
@@ -33,7 +33,9 @@ import {
33
33
  buildHtmlTail,
34
34
  compress,
35
35
  compressBytes,
36
+ encodeForRequest,
36
37
  isDev,
38
+ type Encoded,
37
39
  } from "./html.ts";
38
40
  import type { Metadata } from "./hooks.ts";
39
41
  import { loadPlugins } from "./config.ts";
@@ -850,6 +852,9 @@ export async function renderSSRStream(
850
852
  ];
851
853
 
852
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;
853
858
 
854
859
  // ── Response cache: write after the response has gone out ──
855
860
  // Skip if the handler set cookies — cached response can't reproduce
@@ -865,9 +870,18 @@ export async function renderSSRStream(
865
870
  // after cacheSet ran, so their re-check hits.
866
871
  const rel = releaseMiss;
867
872
  releaseMiss = null;
873
+ const encoded = encodeForRequest(fullBody, req, "cache");
874
+ sent = encoded;
868
875
  deferCacheWrite(() => {
869
876
  try {
870
- const { gzip, brotli } = buildCompressedVariants(fullBody);
877
+ const { gzip, brotli } = buildCompressedVariants(
878
+ fullBody,
879
+ encoded?.enc === "br"
880
+ ? { brotli: encoded.encoded }
881
+ : encoded
882
+ ? { gzip: encoded.encoded }
883
+ : {},
884
+ );
871
885
  cacheSet(
872
886
  keyForWrite,
873
887
  {
@@ -888,7 +902,7 @@ export async function renderSSRStream(
888
902
  }
889
903
  }
890
904
 
891
- return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, data.loaderHeaders);
905
+ return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, data.loaderHeaders, sent);
892
906
  } finally {
893
907
  releaseMiss?.();
894
908
  }
@@ -11,6 +11,7 @@ import { isDev } from "./html.ts";
11
11
  import { OUT_DIR } from "./paths.ts";
12
12
  import { pidsOnPort } from "./port.ts";
13
13
  import { CACHE_ENABLED, CACHE_KEYS } from "./cache.ts";
14
+ import { logAssetCache } from "./assetCache.ts";
14
15
 
15
16
  // ─── User Hooks ──────────────────────────────────────────
16
17
  // Production prefers the pre-bundled `${OUT_DIR}/hooks.server.js` emitted by the
@@ -44,6 +45,9 @@ if (hooksPath) {
44
45
 
45
46
  const app = await createApp({ handle });
46
47
 
48
+ // Dev serves files straight from disk (no manifest), so the cache never runs there.
49
+ if (!isDev) logAssetCache();
50
+
47
51
  // ─── Listen ──────────────────────────────────────────────
48
52
 
49
53
  const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 : 9000;
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, statSync } from "fs";
2
2
  import { basename, join, resolve as resolvePath } from "path";
3
3
  import { PRECOMPRESSED, pickEncoding } from "./html.ts";
4
+ import { AssetCache, assetCache } from "./assetCache.ts";
4
5
 
5
6
  /** `br`/`gz`: absolute paths of build-time precompressed siblings (precompress.ts). */
6
7
  export type StaticEntry = { absPath: string; cacheControl?: string; br?: string; gz?: string };
@@ -136,24 +137,91 @@ function withSiblings(
136
137
  * client accepts. Content-Type comes from the original — `Bun.file("x.js.br")`
137
138
  * would infer the wrong one. `Vary` only when variants exist: a raw-only file
138
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.
139
149
  */
140
150
  export function serveStatic(
141
151
  entry: StaticEntry,
142
152
  req: Request,
143
153
  headers: Record<string, string> = {},
154
+ cache: AssetCache = assetCache,
144
155
  ): Response {
145
156
  const out: Record<string, string> = { ...headers };
146
157
  if (entry.cacheControl) out["Cache-Control"] = entry.cacheControl;
147
- if (!entry.br && !entry.gz) return new Response(Bun.file(entry.absPath), { headers: out });
148
158
 
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 });
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
+ }
153
196
 
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 });
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;
157
225
  }
158
226
 
159
227
  export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
@@ -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