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/README.md
CHANGED
|
@@ -144,16 +144,15 @@ const authHandle: Handle = async ({ event, resolve }) => {
|
|
|
144
144
|
return resolve(event);
|
|
145
145
|
};
|
|
146
146
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return res;
|
|
147
|
+
const langHandle: Handle = async ({ event, resolve }) => {
|
|
148
|
+
event.locals.lang = event.request.headers.get("accept-language")?.split(",")[0] ?? "en";
|
|
149
|
+
return resolve(event);
|
|
151
150
|
};
|
|
152
151
|
|
|
153
|
-
export const handle = sequence(authHandle,
|
|
152
|
+
export const handle = sequence(authHandle, langHandle);
|
|
154
153
|
```
|
|
155
154
|
|
|
156
|
-
`locals` set here are available in every loader and API handler.
|
|
155
|
+
`locals` set here are available in every loader and API handler. For an access log, use your reverse proxy's or add `accessLog()` from `bosia/plugins/access-log` to `bosia.config.ts` — not a `console.log` per request.
|
|
157
156
|
|
|
158
157
|
## Public API
|
|
159
158
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"bun": ">=1.4"
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"./client": "./src/lib/client.ts",
|
|
38
38
|
"./server": "./src/lib/server.ts",
|
|
39
39
|
"./plugins/server-timing": "./src/core/plugins/server-timing.ts",
|
|
40
|
+
"./plugins/access-log": "./src/core/plugins/access-log.ts",
|
|
40
41
|
"./plugins/inspector": "./src/core/plugins/inspector/index.ts"
|
|
41
42
|
},
|
|
42
43
|
"bin": {
|
package/src/core/backend.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// ─── Bosia Backend App ────────────────────────────────────
|
|
2
2
|
// A tiny Elysia-shaped HTTP app: chainable routes + onRequest / onAfterHandle /
|
|
3
|
-
// onError hooks, one `fetch(request)` entry for Bun.serve and
|
|
3
|
+
// onError / onResponse hooks, one `fetch(request)` entry for Bun.serve and
|
|
4
|
+
// Workers alike.
|
|
4
5
|
// Only the subset the framework and plugins use — not a general router.
|
|
5
6
|
|
|
6
7
|
/** Outbound response bag — merged into whatever the handler returns. */
|
|
@@ -34,6 +35,19 @@ export type AfterHandleHook = (ctx: {
|
|
|
34
35
|
|
|
35
36
|
export type ErrorHook = (ctx: { request: Request; error: unknown; set: ResponseSet }) => unknown;
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Sees the final Response — after onRequest short-circuits, after-handle
|
|
40
|
+
* replacements, error hooks and the HEAD body drop — exactly once per request.
|
|
41
|
+
* `start`: `performance.now()` when the request entered `fetch`. Observe only:
|
|
42
|
+
* the return value is ignored and a throw or rejection is swallowed. `response`
|
|
43
|
+
* is the one the client gets — don't read its body or change its headers.
|
|
44
|
+
*/
|
|
45
|
+
export type ResponseHook = (ctx: {
|
|
46
|
+
request: Request;
|
|
47
|
+
response: Response;
|
|
48
|
+
start: number;
|
|
49
|
+
}) => void | Promise<unknown>;
|
|
50
|
+
|
|
37
51
|
export interface ServeOptions {
|
|
38
52
|
maxRequestBodySize?: number;
|
|
39
53
|
idleTimeout?: number;
|
|
@@ -55,6 +69,7 @@ export class BosiaApp {
|
|
|
55
69
|
private requestHooks: RequestHook[] = [];
|
|
56
70
|
private afterHooks: AfterHandleHook[] = [];
|
|
57
71
|
private errorHooks: ErrorHook[] = [];
|
|
72
|
+
private responseHooks: ResponseHook[] = [];
|
|
58
73
|
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
59
74
|
|
|
60
75
|
constructor(private config: { serve?: ServeOptions } = {}) {}
|
|
@@ -100,6 +115,12 @@ export class BosiaApp {
|
|
|
100
115
|
return this;
|
|
101
116
|
}
|
|
102
117
|
|
|
118
|
+
/** Observes the final response of every request (see ResponseHook). */
|
|
119
|
+
onResponse(hook: ResponseHook): this {
|
|
120
|
+
this.responseHooks.push(hook);
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
// Paths match exactly, or "*" matches everything. Enough for plugin endpoints
|
|
104
125
|
// like /__bosia/locate; add `:param` matching here when a plugin needs it.
|
|
105
126
|
private route(method: Method, path: string, handler: Handler): this {
|
|
@@ -130,6 +151,39 @@ export class BosiaApp {
|
|
|
130
151
|
}
|
|
131
152
|
|
|
132
153
|
fetch = async (request: Request): Promise<Response> => {
|
|
154
|
+
if (this.responseHooks.length === 0) return this.respond(request);
|
|
155
|
+
const start = performance.now();
|
|
156
|
+
let response: Response;
|
|
157
|
+
try {
|
|
158
|
+
response = await this.respond(request);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// An error hook threw: the runtime answers 500, so observers see that.
|
|
161
|
+
this.observe(request, new Response(null, { status: 500 }), start);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
this.observe(request, response, start);
|
|
165
|
+
return response;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
private observe(request: Request, response: Response, start: number): void {
|
|
169
|
+
for (const hook of this.responseHooks) {
|
|
170
|
+
try {
|
|
171
|
+
const out = hook({ request, response, start });
|
|
172
|
+
// An async observer's rejection would be unhandled — fatal in prod.
|
|
173
|
+
if (out instanceof Promise) out.catch(() => {});
|
|
174
|
+
} catch {
|
|
175
|
+
// Observers must not change what the client gets.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Answers without running onResponse observers — for the Workers warm-up
|
|
181
|
+
* render, which no client made and no access log should count. */
|
|
182
|
+
fetchUnobserved(request: Request): Promise<Response> {
|
|
183
|
+
return this.respond(request);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async respond(request: Request): Promise<Response> {
|
|
133
187
|
const set: ResponseSet = { headers: {} };
|
|
134
188
|
const isHead = request.method === "HEAD";
|
|
135
189
|
const method = isHead ? "GET" : request.method.toUpperCase();
|
|
@@ -143,7 +197,7 @@ export class BosiaApp {
|
|
|
143
197
|
}
|
|
144
198
|
return new Response("Internal Server Error", { status: 500 });
|
|
145
199
|
}
|
|
146
|
-
}
|
|
200
|
+
}
|
|
147
201
|
|
|
148
202
|
private async dispatch(request: Request, method: string, set: ResponseSet): Promise<Response> {
|
|
149
203
|
for (const hook of this.requestHooks) {
|
package/src/core/cache.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { createHash } from "node:crypto";
|
|
|
11
11
|
import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
12
12
|
import type { CookieJar } from "./cookies.ts";
|
|
13
13
|
import { dedupKey } from "./dedup.ts";
|
|
14
|
-
import { compressionOn, encodeBytes, PRECOMPRESSED } from "./html.ts";
|
|
14
|
+
import { compressionOn, encodeBytes, PRECOMPRESSED, varyWithEncoding } from "./html.ts";
|
|
15
15
|
import { matchesEtag } from "./etag.ts";
|
|
16
16
|
|
|
17
17
|
// ─── Config ──────────────────────────────────────────────
|
|
@@ -34,14 +34,59 @@ function parseMaxEntries(raw: string | undefined): number {
|
|
|
34
34
|
return n;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
if (!raw) return DEFAULT;
|
|
37
|
+
function parseBytes(raw: string | undefined, fallback: number): number {
|
|
38
|
+
if (!raw) return fallback;
|
|
40
39
|
const n = parseInt(raw, 10);
|
|
41
|
-
if (!Number.isFinite(n) || n < 0) return
|
|
40
|
+
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
42
41
|
return n;
|
|
43
42
|
}
|
|
44
43
|
|
|
44
|
+
function parseMaxBodyBytes(raw: string | undefined): number {
|
|
45
|
+
return parseBytes(raw, 2_097_152); // 2MB; 0 = unlimited
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A duration env var in whole milliseconds. Unset or blank → `fallback`;
|
|
52
|
+
* `0` or `Infinity` → 0 (no limit). Anything else (`5s`, `1e4`, `-1`) warns
|
|
53
|
+
* and uses `fallback` — parseInt read `10s` as 10ms.
|
|
54
|
+
*/
|
|
55
|
+
export function parseMs(raw: string | undefined, fallback: number, name: string): number {
|
|
56
|
+
const value = raw?.trim();
|
|
57
|
+
if (!value) return fallback;
|
|
58
|
+
if (value.toLowerCase() === "infinity") return 0;
|
|
59
|
+
// setTimeout overflows above 2^31-1 and fires at once.
|
|
60
|
+
if (/^\d+$/.test(value)) return Math.min(Number(value), MAX_TIMER_MS);
|
|
61
|
+
if (typeof console !== "undefined") {
|
|
62
|
+
console.warn(`⚠️ ${name}="${raw}" is not a number of milliseconds; using ${fallback}`);
|
|
63
|
+
}
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Ad and social click parameters: they never change what a page shows. */
|
|
68
|
+
const DEFAULT_IGNORE_PARAMS =
|
|
69
|
+
"utm_*,fbclid,gclid,gbraid,wbraid,dclid,msclkid,twclid,ttclid,li_fat_id,igshid,yclid,mc_cid,mc_eid,_ga,_gl";
|
|
70
|
+
|
|
71
|
+
/** Comma-separated names; `name*` matches a prefix. `none` (or `-`) = ignore nothing. */
|
|
72
|
+
export function parseIgnoreParams(raw: string | undefined): {
|
|
73
|
+
names: Set<string>;
|
|
74
|
+
prefixes: string[];
|
|
75
|
+
} {
|
|
76
|
+
let value = raw?.trim();
|
|
77
|
+
if (value === undefined || value === "") value = DEFAULT_IGNORE_PARAMS;
|
|
78
|
+
const names = new Set<string>();
|
|
79
|
+
const prefixes: string[] = [];
|
|
80
|
+
if (value === "none" || value === "-") return { names, prefixes };
|
|
81
|
+
for (const part of value.split(",")) {
|
|
82
|
+
const p = part.trim();
|
|
83
|
+
if (!p || p === "*") continue;
|
|
84
|
+
if (p.endsWith("*")) prefixes.push(p.slice(0, -1));
|
|
85
|
+
else names.add(p);
|
|
86
|
+
}
|
|
87
|
+
return { names, prefixes };
|
|
88
|
+
}
|
|
89
|
+
|
|
45
90
|
// Server-only module (imported by core/renderer.ts, core/server.ts,
|
|
46
91
|
// lib/server.ts — never the client barrel; client `invalidate` lives in
|
|
47
92
|
// core/client/navigation.ts). The `process` guards below are kept as
|
|
@@ -53,12 +98,60 @@ const isServer = typeof process !== "undefined";
|
|
|
53
98
|
export const CACHE_KEYS: readonly string[] = parseCacheKeys(env.CACHE_KEYS);
|
|
54
99
|
export const CACHE_MAX_ENTRIES = parseMaxEntries(env.CACHE_MAX_ENTRIES);
|
|
55
100
|
export const CACHE_MAX_BODY_BYTES = parseMaxBodyBytes(env.CACHE_MAX_BODY_BYTES);
|
|
101
|
+
/** Budget for every entry together (raw + gzip + brotli). 0 = entry count only. */
|
|
102
|
+
export const CACHE_MAX_BYTES = parseBytes(env.CACHE_MAX_BYTES, 104_857_600); // 100MB
|
|
56
103
|
export const CACHE_ENABLED = CACHE_MAX_ENTRIES > 0;
|
|
104
|
+
// Unset means the default; `0` or `Infinity` turns the timeout off. Unset
|
|
105
|
+
// used to mean no timeout: a hung loader held its URL's cache gate (and dedup
|
|
106
|
+
// slot) until restart. 30s/10s leave room for slow-but-healthy loaders; a
|
|
107
|
+
// page's timer also covers its `await parent()`. Used by renderer.ts.
|
|
108
|
+
export const LOAD_TIMEOUT = parseMs(env.LOAD_TIMEOUT, 30_000, "LOAD_TIMEOUT");
|
|
109
|
+
export const METADATA_TIMEOUT = parseMs(env.METADATA_TIMEOUT, 10_000, "METADATA_TIMEOUT");
|
|
110
|
+
|
|
111
|
+
/** The longest a healthy page build can take (metadata, then load, plus
|
|
112
|
+
* render); 0 when LOAD_TIMEOUT is off. */
|
|
113
|
+
const LOADER_BUDGET = LOAD_TIMEOUT > 0 ? METADATA_TIMEOUT + LOAD_TIMEOUT + 10_000 : 0;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* How long a cache miss waits for another request already building the same
|
|
117
|
+
* key, counted from when that one started, before building itself (ms).
|
|
118
|
+
* Bounds a leader that never finishes (an API handler that hangs, or
|
|
119
|
+
* LOAD_TIMEOUT=0). When it fires every waiter builds at once, so it must
|
|
120
|
+
* outlast a healthy leader: at least LOADER_BUDGET. 0 = wait for the leader.
|
|
121
|
+
*/
|
|
122
|
+
export const CACHE_WAIT_TIMEOUT = parseMs(
|
|
123
|
+
env.CACHE_WAIT_TIMEOUT,
|
|
124
|
+
Math.min(Math.max(60_000, LOADER_BUDGET), MAX_TIMER_MS),
|
|
125
|
+
"CACHE_WAIT_TIMEOUT",
|
|
126
|
+
);
|
|
127
|
+
if (
|
|
128
|
+
CACHE_WAIT_TIMEOUT > 0 &&
|
|
129
|
+
CACHE_WAIT_TIMEOUT < Math.min(LOADER_BUDGET, MAX_TIMER_MS) &&
|
|
130
|
+
typeof console !== "undefined"
|
|
131
|
+
) {
|
|
132
|
+
console.warn(
|
|
133
|
+
`⚠️ CACHE_WAIT_TIMEOUT=${CACHE_WAIT_TIMEOUT} is shorter than METADATA_TIMEOUT + LOAD_TIMEOUT: requests waiting on a slow page build will start their own`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const IGNORE_PARAMS = parseIgnoreParams(env.CACHE_IGNORE_PARAMS);
|
|
137
|
+
/** Largest body worth reading or compressing for the cache: the per-body cap,
|
|
138
|
+
* and at most a quarter of the budget (raw + two compressed copies still fit,
|
|
139
|
+
* and one entry can't flush the rest). 0 = no limit. */
|
|
140
|
+
const CACHE_BUDGET_SHARE = CACHE_MAX_BYTES > 0 ? Math.floor(CACHE_MAX_BYTES / 4) : 0;
|
|
141
|
+
const CACHE_BODY_CAP =
|
|
142
|
+
CACHE_MAX_BODY_BYTES > 0 && CACHE_BUDGET_SHARE > 0
|
|
143
|
+
? Math.min(CACHE_MAX_BODY_BYTES, CACHE_BUDGET_SHARE)
|
|
144
|
+
: CACHE_MAX_BODY_BYTES || CACHE_BUDGET_SHARE;
|
|
145
|
+
|
|
146
|
+
/** Whether a body of `bytes` could be stored — checked before paying compression. */
|
|
147
|
+
export function bodyFitsCache(bytes: number): boolean {
|
|
148
|
+
return CACHE_BODY_CAP === 0 || bytes <= CACHE_BODY_CAP;
|
|
149
|
+
}
|
|
57
150
|
|
|
58
151
|
if (isServer) {
|
|
59
152
|
if (CACHE_ENABLED) {
|
|
60
153
|
console.log(
|
|
61
|
-
`💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, max body ${
|
|
154
|
+
`💾 Response cache: max ${CACHE_MAX_ENTRIES} entries${CACHE_MAX_BYTES === 0 ? "" : ` / ${CACHE_MAX_BYTES} bytes`}, max body ${CACHE_BODY_CAP === 0 ? "unlimited" : `${CACHE_BODY_CAP} bytes`}, identity keys [${CACHE_KEYS.join(", ")}]`,
|
|
62
155
|
);
|
|
63
156
|
} else {
|
|
64
157
|
console.log("💾 Response cache: disabled (CACHE_MAX_ENTRIES=0)");
|
|
@@ -80,14 +173,27 @@ export type CacheEntry = {
|
|
|
80
173
|
/** Digest of `raw`, filled in by `cacheSet`; the ETag is built from it per
|
|
81
174
|
* encoding so a gzip and a brotli copy are never mistaken for each other. */
|
|
82
175
|
etag?: string;
|
|
176
|
+
/** A loader read an ignored tracking param (or the whole query): not served
|
|
177
|
+
* to requests that carried one. See CACHE_IGNORE_PARAMS. */
|
|
178
|
+
queryAware?: boolean;
|
|
83
179
|
};
|
|
84
180
|
|
|
85
181
|
// ─── Tiny LRU ────────────────────────────────────────────
|
|
86
182
|
// Uses Map's insertion-order iteration. get() promotes by re-inserting.
|
|
183
|
+
// Bounded by entry count and, when `maxBytes` > 0, by the summed `weigh()` —
|
|
184
|
+
// 500 entries of up to 2MB plus two compressed copies each could otherwise
|
|
185
|
+
// reach gigabytes of heap.
|
|
87
186
|
|
|
88
187
|
class LRU<K, V> {
|
|
89
188
|
private map = new Map<K, V>();
|
|
90
|
-
|
|
189
|
+
// Weighed once on set, so a later change to a value can't skew the total.
|
|
190
|
+
private weights = new Map<K, number>();
|
|
191
|
+
private bytes = 0;
|
|
192
|
+
constructor(
|
|
193
|
+
private cap: number,
|
|
194
|
+
private maxBytes: number,
|
|
195
|
+
private weigh: (value: V) => number,
|
|
196
|
+
) {}
|
|
91
197
|
get(key: K): V | undefined {
|
|
92
198
|
const v = this.map.get(key);
|
|
93
199
|
if (v === undefined) return undefined;
|
|
@@ -95,20 +201,31 @@ class LRU<K, V> {
|
|
|
95
201
|
this.map.set(key, v);
|
|
96
202
|
return v;
|
|
97
203
|
}
|
|
98
|
-
|
|
99
|
-
|
|
204
|
+
/** Stores `value` and returns what was evicted to make room. A value
|
|
205
|
+
* heavier than the whole budget is not stored. */
|
|
206
|
+
set(key: K, value: V): { stored: boolean; evicted: { key: K; value: V }[] } {
|
|
207
|
+
this.delete(key);
|
|
208
|
+
const evicted: { key: K; value: V }[] = [];
|
|
209
|
+
const weight = this.weigh(value);
|
|
210
|
+
if (this.maxBytes > 0 && weight > this.maxBytes) return { stored: false, evicted };
|
|
100
211
|
this.map.set(key, value);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
212
|
+
this.weights.set(key, weight);
|
|
213
|
+
this.bytes += weight;
|
|
214
|
+
while (
|
|
215
|
+
this.map.size > this.cap ||
|
|
216
|
+
(this.maxBytes > 0 && this.bytes > this.maxBytes && this.map.size > 1)
|
|
217
|
+
) {
|
|
218
|
+
const oldest = this.map.keys().next().value as K;
|
|
219
|
+
const old = this.map.get(oldest) as V;
|
|
220
|
+
this.delete(oldest);
|
|
221
|
+
evicted.push({ key: oldest, value: old });
|
|
108
222
|
}
|
|
109
|
-
return
|
|
223
|
+
return { stored: true, evicted };
|
|
110
224
|
}
|
|
111
225
|
delete(key: K): boolean {
|
|
226
|
+
if (!this.map.has(key)) return false;
|
|
227
|
+
this.bytes -= this.weights.get(key) ?? 0;
|
|
228
|
+
this.weights.delete(key);
|
|
112
229
|
return this.map.delete(key);
|
|
113
230
|
}
|
|
114
231
|
keys(): IterableIterator<K> {
|
|
@@ -116,6 +233,8 @@ class LRU<K, V> {
|
|
|
116
233
|
}
|
|
117
234
|
clear(): void {
|
|
118
235
|
this.map.clear();
|
|
236
|
+
this.weights.clear();
|
|
237
|
+
this.bytes = 0;
|
|
119
238
|
}
|
|
120
239
|
get size(): number {
|
|
121
240
|
return this.map.size;
|
|
@@ -124,7 +243,11 @@ class LRU<K, V> {
|
|
|
124
243
|
|
|
125
244
|
// ─── Storage ─────────────────────────────────────────────
|
|
126
245
|
|
|
127
|
-
const htmlCache = new LRU<string, CacheEntry>(
|
|
246
|
+
const htmlCache = new LRU<string, CacheEntry>(
|
|
247
|
+
CACHE_MAX_ENTRIES || 1,
|
|
248
|
+
CACHE_MAX_BYTES,
|
|
249
|
+
(e) => e.raw.byteLength + (e.gzip?.byteLength ?? 0) + (e.brotli?.byteLength ?? 0),
|
|
250
|
+
);
|
|
128
251
|
const tagIndex = new Map<string, Set<string>>();
|
|
129
252
|
const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
|
|
130
253
|
|
|
@@ -155,6 +278,63 @@ export function computeCacheKey(url: URL, req: Request, cookies: Pick<CookieJar,
|
|
|
155
278
|
return `${dedupKey(url)}|i=${computeIdentityHash(req, cookies)}`;
|
|
156
279
|
}
|
|
157
280
|
|
|
281
|
+
// ─── Tracking parameters (CACHE_IGNORE_PARAMS) ───────────
|
|
282
|
+
// `/blog?utm_source=x` and `/blog?fbclid=…` are the same page as `/blog`, but
|
|
283
|
+
// every value made its own entry: a full render per ad click, and the misses
|
|
284
|
+
// pushed hot pages out of the LRU. Pages (SSR and data endpoint) look such a
|
|
285
|
+
// request up under the URL without those params; API routes keep the full URL.
|
|
286
|
+
//
|
|
287
|
+
// A request that carried them is served the plain URL's entry when there is
|
|
288
|
+
// one; otherwise it caches under its full URL, as before. It never writes the
|
|
289
|
+
// plain entry: a component may render `page.url` (a canonical link, a hidden
|
|
290
|
+
// gclid field), so its HTML can hold this visitor's values. An entry whose
|
|
291
|
+
// loaders or metadata() read an ignored param (or the whole query) is marked
|
|
292
|
+
// `queryAware` and isn't served to such requests.
|
|
293
|
+
|
|
294
|
+
export function isIgnoredParam(name: string): boolean {
|
|
295
|
+
if (IGNORE_PARAMS.names.has(name)) return true;
|
|
296
|
+
for (const p of IGNORE_PARAMS.prefixes) if (name.startsWith(p)) return true;
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** `url` without ignored params, or `url` itself when it has none. */
|
|
301
|
+
export function stripIgnoredParams(url: URL): URL {
|
|
302
|
+
if (url.search === "") return url;
|
|
303
|
+
if (IGNORE_PARAMS.names.size === 0 && IGNORE_PARAMS.prefixes.length === 0) return url;
|
|
304
|
+
let kept: [string, string][] | null = null;
|
|
305
|
+
let i = 0;
|
|
306
|
+
for (const [name, value] of url.searchParams) {
|
|
307
|
+
if (isIgnoredParam(name)) {
|
|
308
|
+
kept ??= [...url.searchParams].slice(0, i);
|
|
309
|
+
} else if (kept) kept.push([name, value]);
|
|
310
|
+
i++;
|
|
311
|
+
}
|
|
312
|
+
if (!kept) return url;
|
|
313
|
+
const out = new URL(url.href);
|
|
314
|
+
out.search = new URLSearchParams(kept).toString();
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Loaders that read the whole query string (`url.search`, `url.href`, iterating
|
|
319
|
+
* `url.searchParams`): their output may depend on any param. */
|
|
320
|
+
const wholeQueryDeps = new WeakSet<LoaderDeps>();
|
|
321
|
+
|
|
322
|
+
export function markWholeQueryRead(deps: LoaderDeps): void {
|
|
323
|
+
wholeQueryDeps.add(deps);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Whether any loader read an ignored param — its entry must not be served to a
|
|
327
|
+
* request that carried one. */
|
|
328
|
+
export function depsReadIgnoredParams(
|
|
329
|
+
layoutDeps: (LoaderDeps | null)[] | null,
|
|
330
|
+
pageDeps: LoaderDeps | null,
|
|
331
|
+
): boolean {
|
|
332
|
+
const check = (d: LoaderDeps | null) =>
|
|
333
|
+
!!d && (wholeQueryDeps.has(d) || d.searchParams.some(isIgnoredParam));
|
|
334
|
+
if (check(pageDeps)) return true;
|
|
335
|
+
return !!layoutDeps && layoutDeps.some(check);
|
|
336
|
+
}
|
|
337
|
+
|
|
158
338
|
/** Extract pathname portion of a cacheKey for path-based invalidation. */
|
|
159
339
|
function pathOfKey(key: string): string {
|
|
160
340
|
const qIdx = key.indexOf("?");
|
|
@@ -184,6 +364,26 @@ export function collectTags(
|
|
|
184
364
|
return [...tags];
|
|
185
365
|
}
|
|
186
366
|
|
|
367
|
+
/** A `Cache-Control` value that says not to reuse the response. */
|
|
368
|
+
export function isNoStoreCacheControl(cc: string | null | undefined): boolean {
|
|
369
|
+
return /no-store|no-cache|private/i.test(cc ?? "");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A `Vary` the cache key can't honor. The key covers the URL and CACHE_KEYS;
|
|
374
|
+
* Accept-Encoding is negotiated per hit. Anything else (`Cookie`, `Origin`
|
|
375
|
+
* from a handler doing its own CORS, `Accept-Language`, `*`) means one stored
|
|
376
|
+
* copy would be served to requests the response said differ. The framework's
|
|
377
|
+
* own `Vary: Origin` is added after this check, on the way out.
|
|
378
|
+
*/
|
|
379
|
+
export function varyBlocksCache(vary: string | null | undefined): boolean {
|
|
380
|
+
if (!vary) return false;
|
|
381
|
+
return vary
|
|
382
|
+
.split(",")
|
|
383
|
+
.map((t) => t.trim().toLowerCase())
|
|
384
|
+
.some((t) => t !== "" && t !== "accept-encoding");
|
|
385
|
+
}
|
|
386
|
+
|
|
187
387
|
// ─── Public-ish: read / write ────────────────────────────
|
|
188
388
|
|
|
189
389
|
export function cacheGet(key: string): CacheEntry | undefined {
|
|
@@ -226,8 +426,10 @@ export function cacheSet(key: string, entry: CacheEntry, cookies?: Cookies): voi
|
|
|
226
426
|
entry.etag ??= createHash("sha1").update(entry.raw).digest("base64url").slice(0, 22);
|
|
227
427
|
// Drop any existing entry's index pointers first
|
|
228
428
|
cacheDeleteKey(key);
|
|
229
|
-
const evicted = htmlCache.set(key, entry);
|
|
230
|
-
|
|
429
|
+
const { stored, evicted } = htmlCache.set(key, entry);
|
|
430
|
+
for (const e of evicted) cacheDeleteIndexOnly(e.key, e.value);
|
|
431
|
+
// Heavier than the whole budget: not stored, so nothing to index.
|
|
432
|
+
if (!stored) return;
|
|
231
433
|
for (const tag of entry.tags) {
|
|
232
434
|
let set = tagIndex.get(tag);
|
|
233
435
|
if (!set) {
|
|
@@ -330,6 +532,65 @@ export function deferCacheWrite(fn: () => void | Promise<void>): void {
|
|
|
330
532
|
else queueMicrotask(fn);
|
|
331
533
|
}
|
|
332
534
|
|
|
535
|
+
const textEncoder = new TextEncoder();
|
|
536
|
+
|
|
537
|
+
/** How long a cache write may spend reading a response body it copied. */
|
|
538
|
+
const CACHE_READ_TIMEOUT_MS = 10_000;
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Read a copied response body for the cache, giving up (and cancelling the
|
|
542
|
+
* copy) once it can't fit the cache (`bodyFitsCache`) or takes longer than
|
|
543
|
+
* `CACHE_READ_TIMEOUT_MS`. `null` = don't cache.
|
|
544
|
+
*
|
|
545
|
+
* `arrayBuffer()` on the copy held the whole body before any size check: a
|
|
546
|
+
* 1GB file served from a `+server.ts` GET took 1GB of RAM per download, and an
|
|
547
|
+
* endless non-SSE stream kept the miss gate (and every waiter) for good.
|
|
548
|
+
*/
|
|
549
|
+
export async function readBodyForCache(res: Response): Promise<Bytes | null> {
|
|
550
|
+
const stream = res.body;
|
|
551
|
+
if (!stream) return new Uint8Array(new ArrayBuffer(0));
|
|
552
|
+
const reader = stream.getReader();
|
|
553
|
+
const chunks: Uint8Array[] = [];
|
|
554
|
+
let total = 0;
|
|
555
|
+
// Cancelling ends the pending read() as `done`; the flag tells that apart
|
|
556
|
+
// from a body that really ended.
|
|
557
|
+
let timedOut = false;
|
|
558
|
+
const timer = setTimeout(() => {
|
|
559
|
+
timedOut = true;
|
|
560
|
+
reader.cancel().catch(() => {});
|
|
561
|
+
}, CACHE_READ_TIMEOUT_MS);
|
|
562
|
+
try {
|
|
563
|
+
for (;;) {
|
|
564
|
+
const step = await reader.read();
|
|
565
|
+
if (timedOut) return null;
|
|
566
|
+
if (step.done) return concatChunks(chunks);
|
|
567
|
+
// A handler's stream may enqueue strings; Bun passes them through a clone.
|
|
568
|
+
const value: unknown = step.value;
|
|
569
|
+
const chunk =
|
|
570
|
+
typeof value === "string"
|
|
571
|
+
? textEncoder.encode(value)
|
|
572
|
+
: value instanceof Uint8Array
|
|
573
|
+
? value
|
|
574
|
+
: ArrayBuffer.isView(value)
|
|
575
|
+
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
576
|
+
: value instanceof ArrayBuffer
|
|
577
|
+
? new Uint8Array(value)
|
|
578
|
+
: null;
|
|
579
|
+
if (chunk === null) return null;
|
|
580
|
+
total += chunk.byteLength;
|
|
581
|
+
if (!bodyFitsCache(total)) return null;
|
|
582
|
+
chunks.push(chunk);
|
|
583
|
+
}
|
|
584
|
+
} catch {
|
|
585
|
+
return null;
|
|
586
|
+
} finally {
|
|
587
|
+
clearTimeout(timer);
|
|
588
|
+
// Stops the copy so the runtime no longer buffers it; the response the
|
|
589
|
+
// client reads is a separate branch and keeps going.
|
|
590
|
+
reader.cancel().catch(() => {});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
333
594
|
/** Concatenate multiple Uint8Array chunks into one buffer. */
|
|
334
595
|
export function concatChunks(chunks: Uint8Array[]): Bytes {
|
|
335
596
|
let total = 0;
|
|
@@ -347,20 +608,59 @@ export function concatChunks(chunks: Uint8Array[]): Bytes {
|
|
|
347
608
|
// Stampede protection for cache misses. The first miss on a key becomes the
|
|
348
609
|
// leader (gets `release`); concurrent misses become waiters (get `wait`).
|
|
349
610
|
// Waiters re-check cacheGet after the wait resolves — hit: serve it; miss
|
|
350
|
-
// (leader skipped the write): build independently
|
|
351
|
-
//
|
|
611
|
+
// (leader skipped the write): build independently. Distinct from dedup(),
|
|
612
|
+
// which shares the *result*; this shares only the *wait*.
|
|
352
613
|
//
|
|
353
614
|
// INVARIANT: the leader must call release() exactly once, after its cacheSet
|
|
354
|
-
// attempt completed or was skipped
|
|
355
|
-
//
|
|
615
|
+
// attempt completed or was skipped — callers guard with try/finally.
|
|
616
|
+
//
|
|
617
|
+
// A leader that never finishes (an API handler that hangs, LOAD_TIMEOUT=0)
|
|
618
|
+
// held its key for the process lifetime: every later request waited on it
|
|
619
|
+
// and the handler never ran again. A gate expires CACHE_WAIT_TIMEOUT after
|
|
620
|
+
// its leader started: waiters resume with `false` and re-claim through
|
|
621
|
+
// claimMiss(); the first replaces the gate and leads with `stale: true` (and
|
|
622
|
+
// must not join the stuck leader's dedup() call), the rest wait on it.
|
|
623
|
+
|
|
624
|
+
type MissGate = {
|
|
625
|
+
promise: Promise<void>;
|
|
626
|
+
release: () => void;
|
|
627
|
+
start: number;
|
|
628
|
+
/** Set by an expiry timer, so a waiter it woke never sees the gate as
|
|
629
|
+
* live (the timer may fire a little before the clock reads expired). */
|
|
630
|
+
expired: boolean;
|
|
631
|
+
};
|
|
356
632
|
|
|
357
|
-
const missGates = new Map<string,
|
|
633
|
+
const missGates = new Map<string, MissGate>();
|
|
634
|
+
|
|
635
|
+
function gateExpired(gate: MissGate, now = performance.now()): boolean {
|
|
636
|
+
return gate.expired || (CACHE_WAIT_TIMEOUT > 0 && now - gate.start >= CACHE_WAIT_TIMEOUT);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// An expired gate stays until its key misses again (it tells that miss it is
|
|
640
|
+
// `stale`). Keys that never come back are swept once the map grows; by twice
|
|
641
|
+
// the timeout the stuck dedup() call is too old to be joined anyway.
|
|
642
|
+
const SWEEP_AT = 256;
|
|
643
|
+
let lastSweep = 0;
|
|
644
|
+
function sweepGates(now: number): void {
|
|
645
|
+
if (missGates.size < SWEEP_AT || now - lastSweep < CACHE_WAIT_TIMEOUT) return;
|
|
646
|
+
lastSweep = now;
|
|
647
|
+
for (const [key, gate] of missGates) {
|
|
648
|
+
if (now - gate.start >= 2 * CACHE_WAIT_TIMEOUT) missGates.delete(key);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** `staleStart`: when the expired gate this lead replaced started (else -Infinity). */
|
|
653
|
+
export type MissLead = { release: () => void; stale: boolean; staleStart: number };
|
|
358
654
|
|
|
359
655
|
export function coalesceMiss(
|
|
360
656
|
key: string,
|
|
361
|
-
):
|
|
657
|
+
): (MissLead & { wait?: undefined }) | { wait: Promise<boolean>; release?: undefined } {
|
|
658
|
+
const now = performance.now();
|
|
362
659
|
const existing = missGates.get(key);
|
|
363
|
-
if (existing
|
|
660
|
+
if (existing && !gateExpired(existing, now)) {
|
|
661
|
+
return { wait: waitWithTimeout(existing) };
|
|
662
|
+
}
|
|
663
|
+
if (CACHE_WAIT_TIMEOUT > 0) sweepGates(now);
|
|
364
664
|
let resolve!: () => void;
|
|
365
665
|
const promise = new Promise<void>((r) => (resolve = r));
|
|
366
666
|
const release = () => {
|
|
@@ -368,8 +668,56 @@ export function coalesceMiss(
|
|
|
368
668
|
if (missGates.get(key)?.promise === promise) missGates.delete(key);
|
|
369
669
|
resolve();
|
|
370
670
|
};
|
|
371
|
-
|
|
372
|
-
|
|
671
|
+
// Replaces an expired gate; the old leader's release() won't touch this one.
|
|
672
|
+
missGates.set(key, { promise, release, start: now, expired: false });
|
|
673
|
+
return { release, stale: existing !== undefined, staleStart: existing?.start ?? -Infinity };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Claim a cache miss: a hit some other request wrote while this one waited;
|
|
678
|
+
* the lead (`lead.release()` exactly once, after the write or its skip);
|
|
679
|
+
* `lead: null` — build without leading; or `aborted` — the client left while
|
|
680
|
+
* waiting, don't build. A waiter whose gate expired re-claims once: the first
|
|
681
|
+
* becomes the new (`stale`) leader and the rest wait on it, so a stuck leader
|
|
682
|
+
* doesn't set every waiter building at once. After a second expiry a waiter
|
|
683
|
+
* builds on its own: it has waited 2× CACHE_WAIT_TIMEOUT.
|
|
684
|
+
*/
|
|
685
|
+
export async function claimMiss(
|
|
686
|
+
key: string,
|
|
687
|
+
signal?: AbortSignal,
|
|
688
|
+
): Promise<
|
|
689
|
+
| { hit: CacheEntry; lead?: undefined; aborted?: undefined }
|
|
690
|
+
| { lead: MissLead | null; hit?: undefined; aborted?: undefined }
|
|
691
|
+
| { aborted: true; hit?: undefined; lead?: undefined }
|
|
692
|
+
> {
|
|
693
|
+
for (let round = 0; ; round++) {
|
|
694
|
+
const gate = coalesceMiss(key);
|
|
695
|
+
if (!gate.wait) return { lead: gate };
|
|
696
|
+
const finished = await gate.wait;
|
|
697
|
+
const hit = cacheGet(key);
|
|
698
|
+
if (hit) return { hit };
|
|
699
|
+
if (signal?.aborted) return { aborted: true };
|
|
700
|
+
if (finished || round >= 1) return { lead: null };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** True when the leader finished, false when the gate expired first. One
|
|
705
|
+
* timer per waiter, not per gate: on Workers a timer belongs to the request
|
|
706
|
+
* that set it and dies with it, which would strand the other waiters. */
|
|
707
|
+
function waitWithTimeout(gate: MissGate): Promise<boolean> {
|
|
708
|
+
if (CACHE_WAIT_TIMEOUT <= 0) return gate.promise.then(() => true);
|
|
709
|
+
const left = Math.max(0, CACHE_WAIT_TIMEOUT - (performance.now() - gate.start));
|
|
710
|
+
return new Promise<boolean>((resolve) => {
|
|
711
|
+
const timer = setTimeout(() => {
|
|
712
|
+
gate.expired = true;
|
|
713
|
+
resolve(false);
|
|
714
|
+
}, left);
|
|
715
|
+
(timer as { unref?: () => void }).unref?.();
|
|
716
|
+
gate.promise.then(() => {
|
|
717
|
+
clearTimeout(timer);
|
|
718
|
+
resolve(true);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
373
721
|
}
|
|
374
722
|
|
|
375
723
|
// ─── Serve a cache hit ───────────────────────────────────
|
|
@@ -380,9 +728,9 @@ export function serveCached(entry: CacheEntry, req: Request): Response {
|
|
|
380
728
|
// override them instead of getting comma-joined by Headers.
|
|
381
729
|
const headers: Record<string, string> = {
|
|
382
730
|
"content-type": entry.contentType,
|
|
383
|
-
vary: "Accept-Encoding",
|
|
384
731
|
"x-bosia-cache": "HIT",
|
|
385
732
|
...entry.extraHeaders,
|
|
733
|
+
vary: varyWithEncoding(entry.extraHeaders.vary),
|
|
386
734
|
};
|
|
387
735
|
let body: Bytes = entry.raw;
|
|
388
736
|
let encoding: "br" | "gzip" | null = null;
|