bosia 1.2.5 → 1.2.7
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 +87 -3
- package/src/core/basePath.ts +30 -0
- package/src/core/cache.ts +378 -30
- package/src/core/client/App.svelte +19 -11
- package/src/core/client/hydrate.ts +17 -9
- package/src/core/client/navigation.ts +1 -1
- package/src/core/client/prefetch.ts +66 -41
- package/src/core/client/router.svelte.ts +41 -22
- package/src/core/cors.ts +17 -1
- package/src/core/csrf.ts +32 -19
- package/src/core/dedup.ts +51 -7
- package/src/core/dev.ts +11 -1
- package/src/core/devWsProxy.ts +144 -0
- package/src/core/env.ts +7 -0
- package/src/core/errors.ts +23 -6
- 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 +143 -6
- package/src/core/proxy.ts +48 -0
- package/src/core/renderer.ts +70 -29
- package/src/core/routeTypes.ts +16 -1
- package/src/core/server.bun.ts +6 -1
- package/src/core/server.ts +198 -77
- package/src/core/server.workers.ts +3 -2
- package/src/core/types/plugin.ts +2 -1
- package/src/core/websocket.ts +232 -0
- package/src/core/workersGuard.ts +18 -3
- package/src/lib/index.ts +1 -0
- 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/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;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { router, scrollToHash } from "./router.svelte.ts";
|
|
4
4
|
import { findMatch } from "../matcher.ts";
|
|
5
5
|
import { clientRoutes, defaultTitle } from "bosia:routes";
|
|
6
|
-
import { consumePrefetch,
|
|
6
|
+
import { consumePrefetch, clearPrefetches, dataUrl, readDataResponse } from "./prefetch.ts";
|
|
7
7
|
import { appState, clearDirty } from "./appState.svelte.ts";
|
|
8
8
|
import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
|
|
9
9
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
const currentTick = appState.invalidationTick; // still tracks reactively
|
|
208
208
|
|
|
209
209
|
const path = router.currentRoute;
|
|
210
|
-
const url = new URL(path, window.location.
|
|
210
|
+
const url = new URL(path, window.location.href);
|
|
211
211
|
const pathname = url.pathname;
|
|
212
212
|
const navKey = pathname + url.search;
|
|
213
213
|
const match = findMatch(clientRoutes, pathname);
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
const isFirst = firstNav;
|
|
228
228
|
firstNav = false;
|
|
229
229
|
if (isFirst) {
|
|
230
|
-
const hUrl = new URL(router.hydratedRoute, window.location.
|
|
230
|
+
const hUrl = new URL(router.hydratedRoute, window.location.href);
|
|
231
231
|
const hMatch = router.hydratedRoute === path ? match : findMatch(clientRoutes, hUrl.pathname);
|
|
232
232
|
currentLayoutPaths = (hMatch?.route as any)?.layoutPaths ?? [];
|
|
233
233
|
// The probes ran during hydration, before this effect. A title other than
|
|
@@ -350,18 +350,26 @@
|
|
|
350
350
|
|
|
351
351
|
// Load components + data in parallel, then update state atomically
|
|
352
352
|
// to avoid a flash of stale/empty data before the fetch completes.
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
// `fresh` covers `invalidated` plus dirty keys/URLs: a prefetch sent before
|
|
354
|
+
// an invalidate() skipped `_fresh`, so it may hold the server's stale copy.
|
|
355
|
+
const cached = match.route.hasServerData && !fresh ? consumePrefetch(path, maskBits) : null;
|
|
356
|
+
clearPrefetches(); // clear remaining entries on navigation — matches SvelteKit behavior
|
|
355
357
|
// A prerendered route's data is a fixed file, so there is nothing to omit.
|
|
356
358
|
const prerendered = match.route.prerender;
|
|
359
|
+
const fetchData = () =>
|
|
360
|
+
fetch(dataUrl(path, prerendered ? undefined : maskBits, fresh && !prerendered))
|
|
361
|
+
.then(readDataResponse)
|
|
362
|
+
// Only a failed request reaches here now — offline, DNS, aborted.
|
|
363
|
+
// A response that arrived is read for what it says, not discarded.
|
|
364
|
+
.catch(() => null);
|
|
357
365
|
const dataFetch = cached
|
|
358
|
-
?
|
|
366
|
+
? // A prefetch still in flight: wait for it; fetch again if it failed or
|
|
367
|
+
// errored (a hover-time 500 shouldn't stand in for the click's answer).
|
|
368
|
+
cached instanceof Promise
|
|
369
|
+
? cached.then((data) => (data && !data.error ? data : fetchData()))
|
|
370
|
+
: Promise.resolve(cached)
|
|
359
371
|
: match.route.hasServerData
|
|
360
|
-
?
|
|
361
|
-
.then(readDataResponse)
|
|
362
|
-
// Only a failed request reaches here now — offline, DNS, aborted.
|
|
363
|
-
// A response that arrived is read for what it says, not discarded.
|
|
364
|
-
.catch(() => null)
|
|
372
|
+
? fetchData()
|
|
365
373
|
: Promise.resolve(null);
|
|
366
374
|
|
|
367
375
|
// Scrolls first: waiting goto()/use:enhance calls resume after the scroll.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hydrate, mount } from "svelte";
|
|
2
2
|
import App from "./App.svelte";
|
|
3
|
-
import { router } from "./router.svelte.ts";
|
|
3
|
+
import { locationPath, router } from "./router.svelte.ts";
|
|
4
4
|
import { initPrefetch } from "./prefetch.ts";
|
|
5
5
|
import { findMatch, compileRoutes, canonicalPathname } from "../matcher.ts";
|
|
6
6
|
import { clientRoutes } from "bosia:routes";
|
|
@@ -25,7 +25,8 @@ function readJsonScript<T>(id: string): T | null {
|
|
|
25
25
|
// ─── Hydration ────────────────────────────────────────────
|
|
26
26
|
|
|
27
27
|
async function main() {
|
|
28
|
-
|
|
28
|
+
// The pathname of the route the router will hold (`capacitor://localhost` → `/`).
|
|
29
|
+
const path = new URL(locationPath(), window.location.href).pathname;
|
|
29
30
|
|
|
30
31
|
router.init();
|
|
31
32
|
|
|
@@ -36,7 +37,8 @@ async function main() {
|
|
|
36
37
|
// Canonicalize trailing slash on initial mount — server already 308'd if
|
|
37
38
|
// SSR'd, but `ssr=false` shells and prerendered pages can land on the
|
|
38
39
|
// non-canonical URL. replaceState (no extra history entry).
|
|
39
|
-
|
|
40
|
+
// A `//x` path is skipped: replaceState would read it as host x and throw.
|
|
41
|
+
if (match && !path.startsWith("//")) {
|
|
40
42
|
const canonical = canonicalPathname(path, (match.route as any).trailingSlash ?? "never");
|
|
41
43
|
if (canonical !== null && typeof history !== "undefined") {
|
|
42
44
|
history.replaceState(
|
|
@@ -46,7 +48,7 @@ async function main() {
|
|
|
46
48
|
);
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
|
-
router.currentRoute =
|
|
51
|
+
router.currentRoute = locationPath();
|
|
50
52
|
// After canonicalizing, before any await: a click during the chunk loads below
|
|
51
53
|
// moves currentRoute, and App must still know which route the server rendered.
|
|
52
54
|
router.hydratedRoute = router.currentRoute;
|
|
@@ -84,7 +86,7 @@ async function main() {
|
|
|
84
86
|
if (match) {
|
|
85
87
|
// The server's route, not window.location: a click during the chunk loads
|
|
86
88
|
// above already moved the address bar on.
|
|
87
|
-
const url = new URL(router.hydratedRoute, window.location.
|
|
89
|
+
const url = new URL(router.hydratedRoute, window.location.href);
|
|
88
90
|
const ctx = liveContext(url.pathname, match.params, url);
|
|
89
91
|
const ssrPageDeps: LoaderDeps | null = (window as any).__BOSIA_PAGE_DEPS__ ?? null;
|
|
90
92
|
const ssrLayoutDeps: (LoaderDeps | null)[] = (window as any).__BOSIA_LAYOUT_DEPS__ ?? [];
|
|
@@ -169,10 +171,16 @@ if (typeof window !== "undefined" && process.env.NODE_ENV === "production") {
|
|
|
169
171
|
} catch {
|
|
170
172
|
// Storage may be unavailable (private mode) — best effort.
|
|
171
173
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
);
|
|
174
|
+
// Split on `&` and kept as text: searchParams would re-encode the query (breaks
|
|
175
|
+
// signed URLs), and a pattern could hit a `?_v=` inside a value (`?next=/a?_v=1`).
|
|
176
|
+
// The last deploy's `_v` goes, so they don't pile up.
|
|
177
|
+
const url = new URL(window.location.href);
|
|
178
|
+
const parts = url.search
|
|
179
|
+
.slice(1)
|
|
180
|
+
.split("&")
|
|
181
|
+
.filter((p) => p && !/^_v=\d+$/.test(p));
|
|
182
|
+
url.search = [...parts, `_v=${Date.now()}`].join("&");
|
|
183
|
+
window.location.replace(url.href);
|
|
176
184
|
});
|
|
177
185
|
}
|
|
178
186
|
} catch {
|
|
@@ -73,7 +73,7 @@ export function invalidate(target: InvalidateTarget): Promise<void> {
|
|
|
73
73
|
try {
|
|
74
74
|
// Normalize to an absolute URL — fetches the loader recorded are
|
|
75
75
|
// always absolute, so a relative `/api/foo` must be promoted here.
|
|
76
|
-
const abs = new URL(str, window.location.
|
|
76
|
+
const abs = new URL(str, window.location.href).href;
|
|
77
77
|
appState.dirty.urls.add(abs);
|
|
78
78
|
} catch {
|
|
79
79
|
appState.dirty.urls.add(str);
|