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.
- package/README.md +5 -6
- package/package.json +2 -1
- package/src/core/backend.ts +56 -2
- package/src/core/cache.ts +378 -30
- package/src/core/client/App.svelte +17 -9
- package/src/core/client/prefetch.ts +57 -24
- package/src/core/cors.ts +17 -1
- package/src/core/dedup.ts +51 -7
- package/src/core/env.ts +3 -0
- package/src/core/hooks.ts +1 -1
- package/src/core/html.ts +13 -1
- package/src/core/plugins/access-log.ts +128 -0
- package/src/core/prerender.ts +475 -79
- package/src/core/renderer.ts +69 -28
- package/src/core/server.ts +95 -53
- package/src/core/server.workers.ts +1 -1
- package/src/core/types/plugin.ts +2 -1
- package/templates/default/.env.example +20 -5
- package/templates/default/tsconfig.json +1 -1
- package/templates/demo/.env.example +20 -5
- package/templates/demo/src/hooks.server.ts +3 -12
- package/templates/demo/tsconfig.json +1 -1
- package/templates/shop/src/app.d.ts +0 -1
- package/templates/shop/src/hooks.server.ts +3 -11
- package/templates/shop/src/routes/+layout.server.ts +0 -1
- package/templates/shop/tsconfig.json +1 -1
- package/templates/store/src/app.d.ts +0 -1
- package/templates/store/src/hooks.server.ts +3 -11
- package/templates/store/src/routes/+layout.server.ts +0 -1
- package/templates/store/tsconfig.json +1 -1
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
+
bodyFitsCache,
|
|
12
12
|
buildCompressedVariants,
|
|
13
13
|
cacheGet,
|
|
14
14
|
cacheSet,
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
);
|
package/src/core/server.ts
CHANGED
|
@@ -46,21 +46,29 @@ import {
|
|
|
46
46
|
import { dedup } from "./dedup.ts";
|
|
47
47
|
import {
|
|
48
48
|
CACHE_ENABLED,
|
|
49
|
-
|
|
49
|
+
CACHE_WAIT_TIMEOUT,
|
|
50
|
+
bodyFitsCache,
|
|
50
51
|
buildCompressedVariants,
|
|
51
52
|
cacheGet,
|
|
52
53
|
cacheSet,
|
|
53
|
-
|
|
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 {
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
507
|
-
|
|
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
|
-
(
|
|
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
|
|
653
|
-
if (
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
if (
|
|
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
|
-
|
|
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.
|
|
32
|
+
await app.fetchUnobserved(new Request("http://localhost/"));
|
|
33
33
|
} catch {
|
|
34
34
|
// best effort
|
|
35
35
|
} finally {
|
package/src/core/types/plugin.ts
CHANGED
|
@@ -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
|
|
60
|
-
#
|
|
61
|
-
#
|
|
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
|
|
78
|
+
# Timeout for metadata() functions in milliseconds. Defaults to 10000 (10s).
|
|
64
79
|
# Set to 0 or Infinity to disable.
|
|
65
|
-
# METADATA_TIMEOUT=
|
|
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.
|
|
@@ -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
|
|
63
|
-
#
|
|
64
|
-
#
|
|
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
|
|
81
|
+
# Timeout for metadata() functions in milliseconds. Defaults to 10000 (10s).
|
|
67
82
|
# Set to 0 or Infinity to disable.
|
|
68
|
-
# METADATA_TIMEOUT=
|
|
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
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
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;
|