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.
Files changed (44) hide show
  1. package/README.md +5 -6
  2. package/package.json +2 -1
  3. package/src/core/backend.ts +87 -3
  4. package/src/core/basePath.ts +30 -0
  5. package/src/core/cache.ts +378 -30
  6. package/src/core/client/App.svelte +19 -11
  7. package/src/core/client/hydrate.ts +17 -9
  8. package/src/core/client/navigation.ts +1 -1
  9. package/src/core/client/prefetch.ts +66 -41
  10. package/src/core/client/router.svelte.ts +41 -22
  11. package/src/core/cors.ts +17 -1
  12. package/src/core/csrf.ts +32 -19
  13. package/src/core/dedup.ts +51 -7
  14. package/src/core/dev.ts +11 -1
  15. package/src/core/devWsProxy.ts +144 -0
  16. package/src/core/env.ts +7 -0
  17. package/src/core/errors.ts +23 -6
  18. package/src/core/hooks.ts +1 -1
  19. package/src/core/html.ts +13 -1
  20. package/src/core/plugins/access-log.ts +128 -0
  21. package/src/core/prerender.ts +143 -6
  22. package/src/core/proxy.ts +48 -0
  23. package/src/core/renderer.ts +70 -29
  24. package/src/core/routeTypes.ts +16 -1
  25. package/src/core/server.bun.ts +6 -1
  26. package/src/core/server.ts +198 -77
  27. package/src/core/server.workers.ts +3 -2
  28. package/src/core/types/plugin.ts +2 -1
  29. package/src/core/websocket.ts +232 -0
  30. package/src/core/workersGuard.ts +18 -3
  31. package/src/lib/index.ts +1 -0
  32. package/templates/default/.env.example +20 -5
  33. package/templates/default/tsconfig.json +1 -1
  34. package/templates/demo/.env.example +20 -5
  35. package/templates/demo/src/hooks.server.ts +3 -12
  36. package/templates/demo/tsconfig.json +1 -1
  37. package/templates/shop/src/app.d.ts +0 -1
  38. package/templates/shop/src/hooks.server.ts +3 -11
  39. package/templates/shop/src/routes/+layout.server.ts +0 -1
  40. package/templates/shop/tsconfig.json +1 -1
  41. package/templates/store/src/app.d.ts +0 -1
  42. package/templates/store/src/hooks.server.ts +3 -11
  43. package/templates/store/src/routes/+layout.server.ts +0 -1
  44. 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 parseMaxBodyBytes(raw: string | undefined): number {
38
- const DEFAULT = 2_097_152; // 2MB; 0 = unlimited
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 DEFAULT;
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 ${CACHE_MAX_BODY_BYTES === 0 ? "unlimited" : `${CACHE_MAX_BODY_BYTES} bytes`}, identity keys [${CACHE_KEYS.join(", ")}]`,
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
- constructor(private cap: number) {}
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
- set(key: K, value: V): { key: K; value: V } | undefined {
99
- if (this.map.has(key)) this.map.delete(key);
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
- if (this.map.size > this.cap) {
102
- const oldest = this.map.keys().next().value as K | undefined;
103
- if (oldest !== undefined) {
104
- const evicted = this.map.get(oldest) as V;
105
- this.map.delete(oldest);
106
- return { key: oldest, value: evicted };
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 undefined;
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>(CACHE_MAX_ENTRIES || 1);
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
- if (evicted) cacheDeleteIndexOnly(evicted.key, evicted.value);
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, no re-leadering. Distinct
351
- // from dedup(), which shares the *result*; this shares only the *wait*.
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. A missed release() hangs waiters for the
355
- // process lifetime — callers guard with try/finally.
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, { promise: Promise<void>; release: () => void }>();
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
- ): { release: () => void; wait?: undefined } | { wait: Promise<void>; release?: undefined } {
657
+ ): (MissLead & { wait?: undefined }) | { wait: Promise<boolean>; release?: undefined } {
658
+ const now = performance.now();
362
659
  const existing = missGates.get(key);
363
- if (existing) return { wait: existing.promise };
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
- missGates.set(key, { promise, release });
372
- return { release };
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, prefetchCache, dataUrl, readDataResponse } from "./prefetch.ts";
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.origin);
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.origin);
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
- const cached = match.route.hasServerData && !invalidated ? consumePrefetch(path) : null;
354
- prefetchCache.clear(); // clear remaining entries on navigation — matches SvelteKit behavior
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
- ? Promise.resolve(cached)
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
- ? 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)
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
- const path = window.location.pathname;
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
- if (match) {
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 = window.location.pathname + window.location.search + window.location.hash;
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.origin);
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
- const sep = window.location.search ? "&" : "?";
173
- window.location.replace(
174
- `${window.location.pathname}${window.location.search}${sep}_v=${Date.now()}${window.location.hash}`,
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.origin).href;
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);