bosia 1.2.4 → 1.2.6

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.
@@ -8,15 +8,22 @@ import type { Cookies, LoaderDeps } from "./hooks.ts";
8
8
  import { CSP_ENABLED } from "./csp.ts";
9
9
  import {
10
10
  CACHE_ENABLED,
11
- CACHE_MAX_BODY_BYTES,
11
+ bodyFitsCache,
12
12
  buildCompressedVariants,
13
13
  cacheGet,
14
14
  cacheSet,
15
- coalesceMiss,
15
+ claimMiss,
16
16
  collectTags,
17
+ depsReadIgnoredParams,
18
+ markWholeQueryRead,
19
+ LOAD_TIMEOUT,
20
+ METADATA_TIMEOUT,
21
+ stripIgnoredParams,
17
22
  computeCacheKey,
18
23
  deferCacheWrite,
24
+ isNoStoreCacheControl,
19
25
  serveCached,
26
+ varyBlocksCache,
20
27
  } from "./cache.ts";
21
28
  import type { CookieJar } from "./cookies.ts";
22
29
  import { HttpError, Redirect, isHttpError, isRedirect } from "./errors.ts";
@@ -137,15 +144,6 @@ class LoadTimeoutError extends Error {
137
144
  }
138
145
  }
139
146
 
140
- function parseTimeout(raw: string | undefined, fallback: number): number {
141
- if (!raw || raw === "Infinity") return 0;
142
- const n = parseInt(raw, 10);
143
- return Number.isFinite(n) && n > 0 ? n : fallback;
144
- }
145
-
146
- const LOAD_TIMEOUT = parseTimeout(process.env.LOAD_TIMEOUT, 5000);
147
- const METADATA_TIMEOUT = parseTimeout(process.env.METADATA_TIMEOUT, 3000);
148
-
149
147
  // A plain-function load() hands back a value, not a promise; wrap it first.
150
148
  function withTimeout<T>(value: T | Promise<T>, ms: number, label: string): Promise<T> {
151
149
  const promise = Promise.resolve(value);
@@ -254,7 +252,7 @@ function stampErrorContext(
254
252
  // cache uses these records to skip re-runs on subsequent navigations
255
253
  // when none of the tracked inputs changed.
256
254
 
257
- function emptyDeps(): LoaderDeps {
255
+ export function emptyDeps(): LoaderDeps {
258
256
  return {
259
257
  keys: [],
260
258
  urls: [],
@@ -282,13 +280,16 @@ function trackedUrl(url: URL, deps: LoaderDeps): URL {
282
280
  const trackedSearch = new Proxy(url.searchParams, {
283
281
  get(target, prop) {
284
282
  if (prop === "get" || prop === "has" || prop === "getAll") {
285
- return (key: string) => {
283
+ // All arguments: `has(name, value)` checks the value too.
284
+ return (key: string, ...rest: unknown[]) => {
286
285
  if (typeof key === "string" && !deps.searchParams.includes(key)) {
287
286
  deps.searchParams.push(key);
288
287
  }
289
- return (target as any)[prop](key);
288
+ return (target as any)[prop](key, ...rest);
290
289
  };
291
290
  }
291
+ // entries(), size, toString(), iteration…: may depend on any param.
292
+ markWholeQueryRead(deps);
292
293
  const value = Reflect.get(target, prop);
293
294
  return typeof value === "function" ? value.bind(target) : value;
294
295
  },
@@ -299,8 +300,12 @@ function trackedUrl(url: URL, deps: LoaderDeps): URL {
299
300
  if (prop === "search") {
300
301
  // Whole search string read → treat as broad searchParams dep
301
302
  deps.uses_url = true;
303
+ markWholeQueryRead(deps);
302
304
  return target.search;
303
305
  }
306
+ if (prop === "href" || prop === "toString" || prop === "toJSON") {
307
+ markWholeQueryRead(deps);
308
+ }
304
309
  if (typeof prop === "string" && URL_TRACKED_PROPS.has(prop)) {
305
310
  deps.uses_url = true;
306
311
  }
@@ -532,6 +537,7 @@ export async function loadRouteData(
532
537
 
533
538
  // Settle root → leaf. The first failure wins, carrying the data of the
534
539
  // layers above it — the same error a serial run would have produced.
540
+ let omittedReadIgnored = false;
535
541
  for (let i = 0; i < route.layoutServers.length; i++) {
536
542
  const ls = route.layoutServers[i]!;
537
543
  // Runs regardless of the mask — see the trust note above loadRouteData.
@@ -540,6 +546,10 @@ export async function loadRouteData(
540
546
  const { data, deps } = await layers[i]!;
541
547
  layoutData[ls.depth] = omit ? null : data;
542
548
  layoutDeps[ls.depth] = omit ? null : deps;
549
+ // A left-out layout still feeds the layers below it via parent(): its
550
+ // ignored-param reads count for every one that is sent.
551
+ if (omit && depsReadIgnoredParams(null, deps)) omittedReadIgnored = true;
552
+ else if (!omit && omittedReadIgnored) markWholeQueryRead(deps);
543
553
  } catch (err) {
544
554
  // Loaders below this one already started; dev warns about the guard.
545
555
  const thrown = isDev ? guardLabel(err) : null;
@@ -606,6 +616,8 @@ export async function loadRouteData(
606
616
  }
607
617
  }
608
618
 
619
+ if (omittedReadIgnored && pageDeps) markWholeQueryRead(pageDeps);
620
+
609
621
  // `params` are always attached to pageData for client-side router consumption.
610
622
  // When pageData is skipped, the client merges its cached pageData with current
611
623
  // route params separately, so we keep the `null` sentinel here.
@@ -615,6 +627,14 @@ export async function loadRouteData(
615
627
 
616
628
  // ─── Metadata Loader ─────────────────────────────────────
617
629
 
630
+ /** metadata() calls that failed or timed out (keyed by their deps): the page
631
+ * still renders, without title/meta/canonical, but isn't cached that way. */
632
+ const failedMetadata = new WeakSet<LoaderDeps>();
633
+
634
+ export function metadataFailed(deps: LoaderDeps): boolean {
635
+ return failedMetadata.has(deps);
636
+ }
637
+
618
638
  export async function loadMetadata(
619
639
  route: any,
620
640
  params: Record<string, string>,
@@ -622,6 +642,8 @@ export async function loadMetadata(
622
642
  locals: Record<string, any>,
623
643
  cookies: Cookies,
624
644
  req: Request,
645
+ /** Records which query params metadata() read, for the response cache. */
646
+ deps?: LoaderDeps,
625
647
  ): Promise<Metadata | null> {
626
648
  if (!route.pageServer) return null;
627
649
  try {
@@ -633,7 +655,7 @@ export async function loadMetadata(
633
655
  mod.metadata({
634
656
  params,
635
657
  route: { id: route.id },
636
- url,
658
+ url: deps ? trackedUrl(url, deps) : url,
637
659
  locals,
638
660
  cookies,
639
661
  fetch,
@@ -647,6 +669,9 @@ export async function loadMetadata(
647
669
  } catch (err) {
648
670
  // Control flow thrown from metadata() is intent, not failure — swallowing it
649
671
  // here made every caller's Redirect/HttpError branch dead code.
672
+ // Also marked for control flow: the data endpoint swallows it and must
673
+ // not cache the 200 it sends instead.
674
+ if (deps) failedMetadata.add(deps);
650
675
  if (isRedirect(err) || isHttpError(err)) throw err;
651
676
  if (isDev) console.error("Metadata load error:", err);
652
677
  else console.error("Metadata load error:", (err as Error).message ?? err);
@@ -706,21 +731,25 @@ export async function renderSSRStream(
706
731
  if (cacheRead) {
707
732
  cacheKey = computeCacheKey(url, req, cookies as CookieJar);
708
733
  if (!cacheBypass) {
734
+ // Tracking params (CACHE_IGNORE_PARAMS): the plain URL's entry first,
735
+ // then this URL's own, as before — see cache.ts.
736
+ const plainUrl = stripIgnoredParams(url);
737
+ if (plainUrl !== url) {
738
+ const plain = cacheGet(computeCacheKey(plainUrl, req, cookies as CookieJar));
739
+ if (plain && !plain.queryAware) return serveCached(plain, req);
740
+ }
709
741
  const hit = cacheGet(cacheKey);
710
742
  if (hit) return serveCached(hit, req);
711
743
  }
712
744
  if (cacheable && !cacheBypass) {
713
745
  // Miss coalescing: the first miss leads the build; concurrent misses
714
- // wait, then re-check the cache. A waiter that still misses (leader
715
- // skipped the write) builds independently — no re-leadering.
716
- const gate = coalesceMiss(cacheKey);
717
- if (gate.wait) {
718
- await gate.wait;
719
- const rehit = cacheGet(cacheKey);
720
- if (rehit) return serveCached(rehit, req);
721
- } else {
722
- releaseMiss = gate.release;
723
- }
746
+ // wait, then re-check the cache — see claimMiss().
747
+ const claim = await claimMiss(cacheKey, req.signal);
748
+ if (claim.hit) return serveCached(claim.hit, req);
749
+ // The client left while waiting: nobody reads this. 499 (nginx's
750
+ // "client closed request"), so logs don't count it as a server error.
751
+ if (claim.aborted) return new Response(null, { status: 499 });
752
+ if (claim.lead) releaseMiss = claim.lead.release;
724
753
  }
725
754
  }
726
755
 
@@ -733,7 +762,8 @@ export async function renderSSRStream(
733
762
  // fragments all start together, and all settle before committing to a 200,
734
763
  // so a Redirect/HttpError from any of them still gets a proper response.
735
764
  // The page load() alone waits for metadata.data (see loadRouteData).
736
- const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
765
+ const metaDeps = emptyDeps();
766
+ const metadataP = loadMetadata(route, params, url, locals, cookies, req, metaDeps).then(
737
767
  (value) => ({ ok: true as const, value }),
738
768
  (err: unknown) => ({ ok: false as const, err }),
739
769
  );
@@ -968,11 +998,21 @@ export async function renderSSRStream(
968
998
  // Skip if the handler set cookies — cached response can't reproduce
969
999
  // per-request Set-Cookie headers. deferCacheWrite runs the variant
970
1000
  // compression after this response is flushed, not ahead of it.
971
- if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
1001
+ // A loader that said not to store its response is taken at its word, as
1002
+ // on the data endpoint: a `private` page must not be replayed from cache.
1003
+ const loaderOptOut =
1004
+ isNoStoreCacheControl(data.loaderHeaders?.["cache-control"]) ||
1005
+ varyBlocksCache(data.loaderHeaders?.["vary"]) ||
1006
+ metadataFailed(metaDeps);
1007
+ if (cacheable && cacheKey && !loaderOptOut && (cookies as any).outgoing?.length === 0) {
972
1008
  // Oversized bodies skip early so they never pay compression; cacheSet
973
1009
  // re-checks as the authoritative guard.
974
- if (CACHE_MAX_BODY_BYTES === 0 || fullBody.length <= CACHE_MAX_BODY_BYTES) {
1010
+ if (bodyFitsCache(fullBody.length)) {
975
1011
  const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
1012
+ const queryAware = depsReadIgnoredParams(
1013
+ [...(data.layoutDeps ?? []), metaDeps],
1014
+ data.pageDeps ?? null,
1015
+ );
976
1016
  const keyForWrite = cacheKey;
977
1017
  // Hand the gate release to the deferred write: waiters resume only
978
1018
  // after cacheSet ran, so their re-check hits.
@@ -1000,6 +1040,7 @@ export async function renderSSRStream(
1000
1040
  status: 200,
1001
1041
  extraHeaders: headers,
1002
1042
  tags,
1043
+ queryAware,
1003
1044
  },
1004
1045
  cookies,
1005
1046
  );
@@ -46,21 +46,29 @@ import {
46
46
  import { dedup } from "./dedup.ts";
47
47
  import {
48
48
  CACHE_ENABLED,
49
- CACHE_MAX_BODY_BYTES,
49
+ CACHE_WAIT_TIMEOUT,
50
+ bodyFitsCache,
50
51
  buildCompressedVariants,
51
52
  cacheGet,
52
53
  cacheSet,
53
- coalesceMiss,
54
+ claimMiss,
54
55
  collectTags,
56
+ depsReadIgnoredParams,
57
+ stripIgnoredParams,
55
58
  computeCacheKey,
56
59
  deferCacheWrite,
60
+ isNoStoreCacheControl,
61
+ readBodyForCache,
57
62
  serveCached,
63
+ varyBlocksCache,
58
64
  warnUncoveredCookies,
59
65
  } from "./cache.ts";
60
66
  import { reportDevErrorFromCatch } from "./devErrorReport.ts";
61
67
  import {
62
68
  loadRouteData,
69
+ emptyDeps,
63
70
  loadMetadata,
71
+ metadataFailed,
64
72
  renderSSRStream,
65
73
  renderErrorPage,
66
74
  renderPageWithFormData,
@@ -78,13 +86,14 @@ let userHandle: Handle | null = null;
78
86
  // Headers that must not be baked into a cache entry. Content-Length is
79
87
  // recomputed by Bun, content-encoding/transfer-encoding depend on the chosen
80
88
  // variant, and security/CORS/Set-Cookie headers are applied by handleRequest.
89
+ // A handler's own Vary is kept: serveCached adds Accept-Encoding to it, so a
90
+ // hit varies on the same headers as the miss did.
81
91
  const NON_CACHEABLE_HEADERS = new Set([
82
92
  "content-length",
83
93
  "content-encoding",
84
94
  "transfer-encoding",
85
95
  "content-type",
86
96
  "set-cookie",
87
- "vary",
88
97
  "x-bosia-cache",
89
98
  ]);
90
99
 
@@ -406,6 +415,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
406
415
  const runLoad = async () => {
407
416
  // metadata() runs alongside the loaders; the page load() gets its
408
417
  // `data`, as on SSR. A failure stays non-fatal here.
418
+ const metaDeps = emptyDeps();
409
419
  const metaP = pageMatch
410
420
  ? loadMetadata(
411
421
  pageMatch.route,
@@ -414,6 +424,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
414
424
  locals,
415
425
  cookies,
416
426
  request,
427
+ metaDeps,
417
428
  ).catch(() => null)
418
429
  : Promise.resolve(null);
419
430
  const data = await loadRouteData(
@@ -438,7 +449,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
438
449
  }
439
450
  : null;
440
451
 
441
- return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
452
+ return {
453
+ data,
454
+ metadata,
455
+ metaDeps,
456
+ cookiesAccessed: (cookies as CookieJar).accessed,
457
+ };
442
458
  };
443
459
 
444
460
  // ── Response cache for client-navigation data ──
@@ -446,9 +462,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
446
462
  // (different masks return different slots), tag/path invalidation, and
447
463
  // no CSP. Only GETs use it (the client router only sends GETs); a
448
464
  // `_fresh` fetch after invalidate() skips the read only.
449
- const cacheKey =
450
- computeCacheKey(routeUrl, request, cookies as CookieJar) +
451
- `|data${invalidatedBits ? `|m=${invalidatedBits}` : ""}`;
465
+ // Set when this request replaced a stuck leader: its dedup() call,
466
+ // started right after that gate, is replaced rather than joined.
467
+ let replaceDedupBefore = -Infinity;
468
+ const keySuffix = `|data${invalidatedBits ? `|m=${invalidatedBits}` : ""}`;
469
+ const cacheKey = computeCacheKey(routeUrl, request, cookies as CookieJar) + keySuffix;
452
470
  const dataCacheable =
453
471
  CACHE_ENABLED &&
454
472
  !CSP_ENABLED &&
@@ -457,15 +475,27 @@ async function resolve(event: RequestEvent): Promise<Response> {
457
475
  pageMatch !== null &&
458
476
  (await pageRouteCacheable(pageMatch.route));
459
477
  if (dataCacheable && !fresh) {
478
+ // Tracking params (CACHE_IGNORE_PARAMS): the plain URL's entry
479
+ // first, then this URL's own, as before — see cache.ts.
480
+ const plainUrl = stripIgnoredParams(routeUrl);
481
+ if (plainUrl !== routeUrl) {
482
+ const plain = cacheGet(
483
+ computeCacheKey(plainUrl, request, cookies as CookieJar) + keySuffix,
484
+ );
485
+ if (plain && !plain.queryAware) return serveCached(plain, request);
486
+ }
460
487
  const hit = cacheGet(cacheKey);
461
488
  if (hit) return serveCached(hit, request);
462
- const gate = coalesceMiss(cacheKey);
463
- if (gate.wait) {
464
- await gate.wait;
465
- const rehit = cacheGet(cacheKey);
466
- if (rehit) return serveCached(rehit, request);
467
- } else {
468
- releaseDataMiss = gate.release;
489
+ const claim = await claimMiss(cacheKey, request.signal);
490
+ if (claim.hit) return serveCached(claim.hit, request);
491
+ if (claim.aborted) return new Response(null, { status: 499 });
492
+ if (claim.lead) {
493
+ releaseDataMiss = claim.lead.release;
494
+ // It replaced a stuck leader: start a new loader run, which
495
+ // later requests join, instead of joining the stuck one (a
496
+ // call that began within 1s of the stuck gate, not a later
497
+ // healthy one).
498
+ if (claim.lead.stale) replaceDedupBefore = claim.lead.staleStart + 1000;
469
499
  }
470
500
  }
471
501
 
@@ -477,7 +507,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
477
507
  const dedupK =
478
508
  computeCacheKey(routeUrl, request, cookies as CookieJar) +
479
509
  (invalidatedBits ? `|m=${invalidatedBits}` : "");
480
- const result = await dedup(dedupK, runLoad);
510
+ const result = await dedup(dedupK, runLoad, CACHE_WAIT_TIMEOUT, replaceDedupBefore);
481
511
  // Identity only covers CACHE_KEYS — warn if a loader read a session
482
512
  // cookie outside that list (dedup could then mix users' results).
483
513
  warnUncoveredCookies(cookies);
@@ -503,16 +533,23 @@ async function resolve(event: RequestEvent): Promise<Response> {
503
533
  const body = jsonEncoder.encode(JSON.stringify({ ...payload, metadata: result.metadata }));
504
534
 
505
535
  // A loader that said not to store its response is taken at its word.
506
- const loaderCc = (loaderHeaders["cache-control"] ?? "").toLowerCase();
507
- const loaderOptOut = /no-store|no-cache|private/.test(loaderCc);
536
+ const loaderOptOut =
537
+ isNoStoreCacheControl(loaderHeaders["cache-control"]) ||
538
+ varyBlocksCache(loaderHeaders["vary"]) ||
539
+ // Failed or timed out: don't keep a copy without its title/meta.
540
+ metadataFailed(result.metaDeps);
508
541
  let sent: Encoded | null | undefined;
509
542
  if (
510
543
  dataCacheable &&
511
544
  !loaderOptOut &&
512
545
  (cookies as CookieJar).outgoing.length === 0 &&
513
- (CACHE_MAX_BODY_BYTES === 0 || body.length <= CACHE_MAX_BODY_BYTES)
546
+ bodyFitsCache(body.length)
514
547
  ) {
515
548
  const tags = collectTags(result.data.layoutDeps ?? null, result.data.pageDeps ?? null);
549
+ const queryAware = depsReadIgnoredParams(
550
+ [...(result.data.layoutDeps ?? []), result.metaDeps],
551
+ result.data.pageDeps ?? null,
552
+ );
516
553
  const rel = releaseDataMiss;
517
554
  releaseDataMiss = null;
518
555
  // Encoded once at cache quality and shared by response and entry.
@@ -538,6 +575,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
538
575
  status: 200,
539
576
  extraHeaders: extra,
540
577
  tags,
578
+ queryAware,
541
579
  },
542
580
  cookies,
543
581
  );
@@ -649,14 +687,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
649
687
  if (apiCacheable && !url.searchParams.has("_invalidated")) {
650
688
  // Miss coalescing: first miss runs the handler; concurrent misses
651
689
  // wait, re-check the cache, and on a still-miss run independently.
652
- const gate = coalesceMiss(apiCacheKey);
653
- if (gate.wait) {
654
- await gate.wait;
655
- const rehit = cacheGet(apiCacheKey);
656
- if (rehit) return serveCached(rehit, request);
657
- } else {
658
- releaseApiMiss = gate.release;
659
- }
690
+ const claim = await claimMiss(apiCacheKey, request.signal);
691
+ if (claim.hit) return serveCached(claim.hit, request);
692
+ if (claim.aborted) return new Response(null, { status: 499 });
693
+ if (claim.lead) releaseApiMiss = claim.lead.release;
660
694
  }
661
695
  }
662
696
 
@@ -690,11 +724,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
690
724
 
691
725
  // Respect handler opt-out via Cache-Control. Standard HTTP semantics:
692
726
  // no-store / private / no-cache all signal "don't reuse this response".
693
- const cacheControl = (response.headers.get("cache-control") ?? "").toLowerCase();
727
+ // A Vary the key can't honor, or a body the handler already encoded
728
+ // (the entry would replay it without its Content-Encoding), isn't reused.
694
729
  const noStore =
695
- cacheControl.includes("no-store") ||
696
- cacheControl.includes("private") ||
697
- cacheControl.includes("no-cache");
730
+ isNoStoreCacheControl(response.headers.get("cache-control")) ||
731
+ varyBlocksCache(response.headers.get("vary")) ||
732
+ response.headers.has("content-encoding");
698
733
 
699
734
  if (
700
735
  apiCacheable &&
@@ -702,7 +737,9 @@ async function resolve(event: RequestEvent): Promise<Response> {
702
737
  response.status === 200 &&
703
738
  !isEventStream &&
704
739
  !noStore &&
705
- (cookies as CookieJar).outgoing.length === 0
740
+ (cookies as CookieJar).outgoing.length === 0 &&
741
+ // A declared size that can't fit: skip the copy and the read entirely.
742
+ bodyFitsCache(parseInt(response.headers.get("content-length") ?? "", 10) || 0)
706
743
  ) {
707
744
  const cloned = response.clone();
708
745
  const extraHeaders = captureCacheableHeaders(response.headers);
@@ -714,10 +751,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
714
751
  releaseApiMiss = null;
715
752
  deferCacheWrite(async () => {
716
753
  try {
717
- const buf = new Uint8Array(await cloned.arrayBuffer());
718
- // Oversized bodies skip early so they never pay compression;
719
- // cacheSet re-checks as the authoritative guard.
720
- if (CACHE_MAX_BODY_BYTES > 0 && buf.length > CACHE_MAX_BODY_BYTES) return;
754
+ // Stops reading once the body can't fit, so an oversized body
755
+ // never sits in memory or pays compression; cacheSet re-checks.
756
+ const buf = await readBodyForCache(cloned);
757
+ if (buf === null) return;
721
758
  const { gzip, brotli } = buildCompressedVariants(buf);
722
759
  // API endpoints have no LoaderDeps in v0.6 — invalidation is
723
760
  // URL/prefix only. See ROADMAP for deferred tag support.
@@ -785,6 +822,25 @@ async function resolve(event: RequestEvent): Promise<Response> {
785
822
  );
786
823
  }
787
824
 
825
+ // Matched once before the hooks; reused for trailing-slash, form-action, and SSR phases.
826
+ const pageMatch = routes.page;
827
+
828
+ // Trailing-slash canonicalization — 308 preserves method (form POSTs included).
829
+ // Before the prerendered lookup, which ignores the slash: `/about/` on a `never`
830
+ // page would otherwise answer 200 from the saved file.
831
+ if (pageMatch) {
832
+ const canonical = canonicalPathname(path, (pageMatch.route as any).trailingSlash ?? "never");
833
+ if (canonical !== null) {
834
+ return new Response(null, {
835
+ status: 308,
836
+ // `path` is app-space — the base came off at the top of handleRequest.
837
+ // This Location goes back to the browser, so it has to be put back on,
838
+ // or a `trailingSlash: "always"` route 308s every request off the mount.
839
+ headers: { Location: withBase(currentBase(), canonical) + url.search + url.hash },
840
+ });
841
+ }
842
+ }
843
+
788
844
  // Prerendered pages — serve static HTML built at build time.
789
845
  // SKIP in dev: prerender runs with NODE_ENV=production, which disables the
790
846
  // inspector plugin and the dev-only error pipeline. Serving its output back
@@ -808,23 +864,6 @@ async function resolve(event: RequestEvent): Promise<Response> {
808
864
  }
809
865
  }
810
866
 
811
- // Matched once before the hooks; reused for trailing-slash, form-action, and SSR phases.
812
- const pageMatch = routes.page;
813
-
814
- // Trailing-slash canonicalization — 308 preserves method (form POSTs included)
815
- if (pageMatch) {
816
- const canonical = canonicalPathname(path, (pageMatch.route as any).trailingSlash ?? "never");
817
- if (canonical !== null) {
818
- return new Response(null, {
819
- status: 308,
820
- // `path` is app-space — the base came off at the top of handleRequest.
821
- // This Location goes back to the browser, so it has to be put back on,
822
- // or a `trailingSlash: "always"` route 308s every request off the mount.
823
- headers: { Location: withBase(currentBase(), canonical) + url.search + url.hash },
824
- });
825
- }
826
- }
827
-
828
867
  // Form actions — POST to page routes with `actions` export
829
868
  if (method === "POST") {
830
869
  if (pageMatch?.route.pageServer) {
@@ -1093,7 +1132,10 @@ function finalizeResponse(
1093
1132
  applyCorsVary(headers);
1094
1133
  const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
1095
1134
  if (corsHeaders) {
1096
- for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
1135
+ // Vary: Origin is already merged in above; set() would drop Accept-Encoding.
1136
+ for (const [k, v] of Object.entries(corsHeaders)) {
1137
+ if (k.toLowerCase() !== "vary") headers.set(k, v);
1138
+ }
1097
1139
  }
1098
1140
  }
1099
1141
  // Apply any Set-Cookie headers accumulated during the request
@@ -29,7 +29,7 @@ const quiet = { log: console.log, warn: console.warn, error: console.error };
29
29
  console.log = console.warn = console.error = () => {};
30
30
  setWarmingUp(true);
31
31
  try {
32
- await app.fetch(new Request("http://localhost/"));
32
+ await app.fetchUnobserved(new Request("http://localhost/"));
33
33
  } catch {
34
34
  // best effort
35
35
  } finally {
@@ -35,7 +35,8 @@ export interface BosiaPlugin {
35
35
 
36
36
  /**
37
37
  * Mount points around the framework's HTTP backend — Bosia's own small,
38
- * Elysia-shaped app (`get/post/…`, `onRequest`, `onAfterHandle`, `onError`).
38
+ * Elysia-shaped app (`get/post/…`, `onRequest`, `onAfterHandle`, `onError`,
39
+ * and observe-only `onResponse` for logging/metrics on the final response).
39
40
  */
40
41
  backend?: {
41
42
  /** Runs before framework middleware/routes — can register routes that bypass Bosia. */
@@ -48,6 +48,20 @@ PUBLIC_STATIC_APP_NAME=My Bosia App
48
48
  #
49
49
  # Max cache entries (LRU). Set to 0 to disable the cache entirely. Defaults to 500.
50
50
  # CACHE_MAX_ENTRIES=500
51
+ # Memory for all cached responses together, in bytes (0 = entry count only).
52
+ # CACHE_MAX_BYTES=104857600
53
+ # Query params left out of the page cache key: a link with ?utm_source=… or
54
+ # ?fbclid=… gets the cached page of the same URL without them, instead of a new
55
+ # render per ad click. Comma-separated; `name*` matches a prefix; `none` turns
56
+ # it off. Without that cached page, or when load()/metadata() reads one of
57
+ # these params, the full URL is cached on its own, as before.
58
+ # Defaults to the list below. Setting it replaces the list.
59
+ # CACHE_IGNORE_PARAMS=utm_*,fbclid,gclid,gbraid,wbraid,dclid,msclkid,twclid,ttclid,li_fat_id,igshid,yclid,mc_cid,mc_eid,_ga,_gl
60
+ # How long a request waits for another one building the same cached response
61
+ # before building it itself, in ms, so one that hangs can't block its URL.
62
+ # 0 or Infinity = wait for it. Defaults to 60000 (60s), or METADATA_TIMEOUT +
63
+ # LOAD_TIMEOUT + 10s when that is longer.
64
+ # CACHE_WAIT_TIMEOUT=60000
51
65
 
52
66
  # Static files and prerendered pages are kept in memory after their first
53
67
  # request (Bun only; ignored on Cloudflare Workers). Total budget in bytes,
@@ -56,13 +70,14 @@ PUBLIC_STATIC_APP_NAME=My Bosia App
56
70
  # Largest file kept in memory, in bytes. Defaults to 1048576 (1MB).
57
71
  # ASSET_CACHE_MAX_FILE_BYTES=1048576
58
72
 
59
- # Timeout for load() functions (layout + page) in milliseconds. Defaults to 5000 (5s).
60
- # Set to 0 or Infinity to disable.
61
- # LOAD_TIMEOUT=5000
73
+ # Timeout for load() functions (layout + page) in milliseconds. Defaults to 30000 (30s).
74
+ # A page's time includes its `await parent()`. Bun drops a silent connection after
75
+ # IDLE_TIMEOUT (10s): raise it too for slower loaders. Set to 0 or Infinity to disable.
76
+ # LOAD_TIMEOUT=30000
62
77
 
63
- # Timeout for metadata() functions in milliseconds. Defaults to 3000 (3s).
78
+ # Timeout for metadata() functions in milliseconds. Defaults to 10000 (10s).
64
79
  # Set to 0 or Infinity to disable.
65
- # METADATA_TIMEOUT=3000
80
+ # METADATA_TIMEOUT=10000
66
81
 
67
82
  # Timeout for prerender fetches during build in milliseconds. Defaults to 5000 (5s).
68
83
  # Set to 0 or Infinity to disable.
@@ -9,7 +9,7 @@
9
9
  "allowImportingTsExtensions": true,
10
10
  "noEmit": true,
11
11
  "verbatimModuleSyntax": true,
12
- "types": ["bun-types"],
12
+ "types": ["bun"],
13
13
  "lib": ["dom", "dom.iterable", "esnext"],
14
14
  "rootDirs": [".", ".bosia/types"],
15
15
  "paths": {
@@ -15,6 +15,20 @@ BODY_SIZE_LIMIT=512K
15
15
  # Per-user pages stay isolated. Disable per-route with `export const cache = false`.
16
16
  # CACHE_KEYS=session,sid,auth,token,jwt,Authorization
17
17
  # CACHE_MAX_ENTRIES=500
18
+ # Memory for all cached responses together, in bytes (0 = entry count only).
19
+ # CACHE_MAX_BYTES=104857600
20
+ # Query params left out of the page cache key: a link with ?utm_source=… or
21
+ # ?fbclid=… gets the cached page of the same URL without them, instead of a new
22
+ # render per ad click. Comma-separated; `name*` matches a prefix; `none` turns
23
+ # it off. Without that cached page, or when load()/metadata() reads one of
24
+ # these params, the full URL is cached on its own, as before.
25
+ # Defaults to the list below. Setting it replaces the list.
26
+ # CACHE_IGNORE_PARAMS=utm_*,fbclid,gclid,gbraid,wbraid,dclid,msclkid,twclid,ttclid,li_fat_id,igshid,yclid,mc_cid,mc_eid,_ga,_gl
27
+ # How long a request waits for another one building the same cached response
28
+ # before building it itself, in ms, so one that hangs can't block its URL.
29
+ # 0 or Infinity = wait for it. Defaults to 60000 (60s), or METADATA_TIMEOUT +
30
+ # LOAD_TIMEOUT + 10s when that is longer.
31
+ # CACHE_WAIT_TIMEOUT=60000
18
32
 
19
33
  # Static files and prerendered pages are kept in memory after their first
20
34
  # request (Bun only; ignored on Cloudflare Workers). Total budget in bytes,
@@ -59,13 +73,14 @@ CORS_ALLOWED_ORIGINS=
59
73
  # Default: 86400 (24 hours)
60
74
  # CORS_MAX_AGE=86400
61
75
 
62
- # Timeout for load() functions (layout + page) in milliseconds. Defaults to 5000 (5s).
63
- # Set to 0 or Infinity to disable.
64
- # LOAD_TIMEOUT=5000
76
+ # Timeout for load() functions (layout + page) in milliseconds. Defaults to 30000 (30s).
77
+ # A page's time includes its `await parent()`. Bun drops a silent connection after
78
+ # IDLE_TIMEOUT (10s): raise it too for slower loaders. Set to 0 or Infinity to disable.
79
+ # LOAD_TIMEOUT=30000
65
80
 
66
- # Timeout for metadata() functions in milliseconds. Defaults to 3000 (3s).
81
+ # Timeout for metadata() functions in milliseconds. Defaults to 10000 (10s).
67
82
  # Set to 0 or Infinity to disable.
68
- # METADATA_TIMEOUT=3000
83
+ # METADATA_TIMEOUT=10000
69
84
 
70
85
  # Timeout for prerender fetches during build in milliseconds. Defaults to 5000 (5s).
71
86
  # Set to 0 or Infinity to disable.
@@ -1,4 +1,3 @@
1
- import { sequence } from "bosia";
2
1
  import type { Handle } from "bosia";
3
2
 
4
3
  // Sets locals that every loader and API handler can read
@@ -8,14 +7,6 @@ const authHandle: Handle = async ({ event, resolve }) => {
8
7
  return resolve(event);
9
8
  };
10
9
 
11
- // Logs each request with method, path, status, and duration
12
- const loggingHandle: Handle = async ({ event, resolve }) => {
13
- const start = Date.now();
14
- const res = await resolve(event);
15
- const ms = Date.now() - start;
16
- console.log(`[${event.request.method}] ${event.url.pathname} ${res.status} (${ms}ms)`);
17
- res.headers.set("X-Response-Time", `${ms}ms`);
18
- return res;
19
- };
20
-
21
- export const handle = sequence(authHandle, loggingHandle);
10
+ // Need an access log? Let nginx/Caddy keep it, or add `accessLog()` from
11
+ // "bosia/plugins/access-log" to bosia.config.ts.
12
+ export const handle = authHandle;
@@ -9,7 +9,7 @@
9
9
  "allowImportingTsExtensions": true,
10
10
  "noEmit": true,
11
11
  "verbatimModuleSyntax": true,
12
- "types": ["bun-types"],
12
+ "types": ["bun"],
13
13
  "lib": ["dom", "dom.iterable", "esnext"],
14
14
  "rootDirs": [".", ".bosia/types"],
15
15
  "paths": {