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
@@ -1,7 +1,7 @@
1
1
  // ─── Error / Redirect Helpers ────────────────────────────
2
2
  // Throw these from load() functions; the server catches and handles them.
3
3
 
4
- import { withBase } from "./basePath.ts";
4
+ import { DANGEROUS_PROTOCOL, withBase } from "./basePath.ts";
5
5
  import { currentBase } from "./appBase.ts";
6
6
 
7
7
  // Identity across bundle boundaries. `dist/hooks.server.js` keeps "bosia"
@@ -59,17 +59,26 @@ export class Redirect {
59
59
  (HttpError.prototype as any)[HTTP_ERROR_BRAND] = true;
60
60
  (Redirect.prototype as any)[REDIRECT_BRAND] = true;
61
61
 
62
- const DANGEROUS_SCHEMES = /^(javascript|data|vbscript):/i;
62
+ // Any parseable origin works as the base: only "did it stay on it?" is asked.
63
+ const SELF = "http://bosia.invalid";
63
64
 
64
65
  function validateRedirectLocation(location: string, options?: RedirectOptions): void {
65
66
  const trimmed = location.trim();
67
+ // Parsed like the browser will parse the Location: `java\tscript:` is javascript:,
68
+ // `htt\tps://evil.com` and `/\\evil.com` are another origin.
69
+ let url: URL;
70
+ try {
71
+ url = new URL(trimmed, SELF);
72
+ } catch {
73
+ throw new Error(`redirect(): invalid URL "${location}".`);
74
+ }
66
75
 
67
76
  // Dangerous schemes are rejected even when `allowExternal: true` —
68
77
  // `javascript:` / `data:` / `vbscript:` are never legitimate redirect targets.
69
- if (DANGEROUS_SCHEMES.test(trimmed)) {
78
+ if (DANGEROUS_PROTOCOL.test(url.protocol)) {
70
79
  throw new Error(
71
80
  `redirect(): dangerous scheme in URL "${location}". ` +
72
- `Only relative paths and same-origin URLs are allowed.`,
81
+ `Use a relative path or an http(s) URL with { allowExternal: true }.`,
73
82
  );
74
83
  }
75
84
 
@@ -83,8 +92,16 @@ function validateRedirectLocation(location: string, options?: RedirectOptions):
83
92
  );
84
93
  }
85
94
 
86
- // Allow relative paths (no scheme)
87
- if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(trimmed)) return;
95
+ // Relative only when it isn't a URL on its own: `http:evil.com` resolves against
96
+ // SELF as a path, but on an https page the browser reads it as http://evil.com.
97
+ // try/new URL, not URL.canParse: this file ships to browsers without it (Safari < 17).
98
+ let absolute = true;
99
+ try {
100
+ new URL(trimmed);
101
+ } catch {
102
+ absolute = false;
103
+ }
104
+ if (!absolute && url.origin === SELF) return;
88
105
 
89
106
  // It's an absolute URL — reject external origins
90
107
  throw new Error(
package/src/core/hooks.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Usage in src/hooks.server.ts:
4
4
  //
5
5
  // import { sequence } from "bosia";
6
- // export const handle = sequence(authHandle, loggingHandle);
6
+ // export const handle = sequence(authHandle, guardHandle);
7
7
 
8
8
  // ─── Cookie Types ─────────────────────────────────────────
9
9
 
package/src/core/html.ts CHANGED
@@ -674,6 +674,18 @@ export function compress(
674
674
  return compressBytes(textEncoder.encode(body), contentType, req, status, extraHeaders);
675
675
  }
676
676
 
677
+ /**
678
+ * The Vary value for a body that may be compressed: a loader's or handler's
679
+ * own `vary` (e.g. `Cookie`) used to replace `Accept-Encoding`, so a CDN could
680
+ * hand brotli to a client that never asked for it.
681
+ */
682
+ export function varyWithEncoding(vary: string | undefined): string {
683
+ if (!vary) return "Accept-Encoding";
684
+ const parts = vary.split(",").map((p) => p.trim().toLowerCase());
685
+ if (parts.includes("*") || parts.includes("accept-encoding")) return vary;
686
+ return `${vary}, Accept-Encoding`;
687
+ }
688
+
677
689
  /** `encoded`: the result of `encodeForRequest` when the caller already has it
678
690
  * (a cache write reuses the same bytes); omitted, it is built here. */
679
691
  export function compressBytes(
@@ -688,8 +700,8 @@ export function compressBytes(
688
700
  // override them instead of getting comma-joined by Headers.
689
701
  const headers: Record<string, string> = {
690
702
  "content-type": contentType,
691
- vary: "Accept-Encoding",
692
703
  ...extraHeaders,
704
+ vary: varyWithEncoding(extraHeaders?.vary),
693
705
  };
694
706
  if (encoded) {
695
707
  return new Response(encoded.encoded, {
@@ -0,0 +1,128 @@
1
+ import type { BosiaPlugin } from "../types/plugin.ts";
2
+
3
+ export interface AccessLogEntry {
4
+ method: string;
5
+ /** Path without the query string. */
6
+ path: string;
7
+ /** The status the client got. */
8
+ status: number;
9
+ /** Time to build the response, in milliseconds (not to deliver its body). */
10
+ ms: number;
11
+ request: Request;
12
+ }
13
+
14
+ export interface AccessLogOptions {
15
+ /** Return false to skip a line, e.g. `(e) => e.status >= 400 || e.ms > 200`. */
16
+ filter?: (entry: AccessLogEntry) => boolean;
17
+ /** One line per request, without the trailing newline. */
18
+ format?: (entry: AccessLogEntry) => string;
19
+ /** Where lines go. Defaults to console.log, one call per batch. May be async;
20
+ * a failed write loses that batch, never the request or the process. */
21
+ write?: (chunk: string) => void | Promise<unknown>;
22
+ /**
23
+ * Lines are collected and written together at most this often (ms). One
24
+ * write per request costs a syscall each: on a pipe (`docker logs`, systemd)
25
+ * that was ~11–14µs of CPU per request, batched ~3µs. 0 writes every line at
26
+ * once. Defaults to 100 on Bun, 0 on Workers (no timers after a response).
27
+ */
28
+ flushMs?: number;
29
+ }
30
+
31
+ /** Flush early once this much is waiting, so a burst never piles up here. */
32
+ const MAX_BUFFER_CHARS = 64 * 1024;
33
+
34
+ function defaultFormat(e: AccessLogEntry): string {
35
+ return `[${e.method}] ${e.path} ${e.status} (${e.ms.toFixed(1)}ms)`;
36
+ }
37
+
38
+ /**
39
+ * One console.log per batch. On Bun it blocks until the pipe takes every byte;
40
+ * `process.stdout.write` queued on a slow pipe and lost the queue at exit, and
41
+ * `fs.writeSync(1)` wrote part of a batch (non-blocking stdout) and dropped the
42
+ * rest — ~870 of 3000 lines in a test, both ways. Like any console.log, a log
43
+ * reader that stops reading slows the server.
44
+ */
45
+ function defaultWrite(chunk: string): void {
46
+ console.log(chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk);
47
+ }
48
+
49
+ /** Path of a request URL without allocating a URL object. */
50
+ function pathOf(url: string): string {
51
+ const start = url.indexOf("/", url.indexOf("//") + 2);
52
+ if (start === -1) return "/";
53
+ const q = url.indexOf("?", start);
54
+ return q === -1 ? url.slice(start) : url.slice(start, q);
55
+ }
56
+
57
+ /**
58
+ * Logs one line per request: method, path, status, duration. Opt-in — the
59
+ * app templates no longer log every request from `hooks.server.ts`; a reverse
60
+ * proxy (nginx, Caddy) can keep the access log instead.
61
+ *
62
+ * Reads the final response (`onResponse`), so the status is what the client
63
+ * got whatever other plugins did, and every request is logged exactly once.
64
+ */
65
+ export function accessLog(options: AccessLogOptions = {}): BosiaPlugin {
66
+ const format = options.format ?? defaultFormat;
67
+ const write = options.write ?? defaultWrite;
68
+ // Workers drops timers once the response is sent: a batch would never flush.
69
+ const flushMs = typeof Bun === "undefined" ? 0 : (options.flushMs ?? 100);
70
+ const filter = options.filter;
71
+
72
+ let buffer = "";
73
+ let timer: ReturnType<typeof setTimeout> | null = null;
74
+ const flush = () => {
75
+ if (timer) {
76
+ clearTimeout(timer);
77
+ timer = null;
78
+ }
79
+ if (buffer === "") return;
80
+ const out = buffer;
81
+ buffer = "";
82
+ try {
83
+ const done = write(out);
84
+ if (done instanceof Promise) done.catch(() => {});
85
+ } catch {
86
+ // A broken sink loses this batch, not the request or the process.
87
+ }
88
+ };
89
+ let exitHooked = false;
90
+
91
+ const log = (entry: AccessLogEntry) => {
92
+ if (filter && !filter(entry)) return;
93
+ buffer += format(entry) + "\n";
94
+ if (flushMs <= 0 || buffer.length >= MAX_BUFFER_CHARS) flush();
95
+ else if (!timer) {
96
+ // Lines still waiting when the server drains and exits. Added on the
97
+ // first buffered line, so `bosia build` (which loads the config too)
98
+ // never registers one.
99
+ if (!exitHooked && typeof process !== "undefined" && typeof process.once === "function") {
100
+ exitHooked = true;
101
+ process.once("exit", flush);
102
+ }
103
+ timer = setTimeout(flush, flushMs);
104
+ (timer as { unref?: () => void }).unref?.();
105
+ }
106
+ };
107
+
108
+ return {
109
+ name: "access-log",
110
+ backend: {
111
+ before(app) {
112
+ // onResponse swallows a throw here (a broken filter/format/sink),
113
+ // so logging can never change or fail a response.
114
+ return app.onResponse(({ request, status, start }) => {
115
+ log({
116
+ method: request.method,
117
+ path: pathOf(request.url),
118
+ status,
119
+ ms: performance.now() - start,
120
+ request,
121
+ });
122
+ });
123
+ },
124
+ },
125
+ };
126
+ }
127
+
128
+ export default accessLog;
@@ -380,6 +380,7 @@ function targetLabel(t: PrerenderTarget): string {
380
380
  * Targets whose URL another route serves: `/blog/[slug]` giving `new` while
381
381
  * `src/routes/blog/new` exists, or a page slug at a `+server.ts` URL (an API
382
382
  * wins, as in server.ts matchRoutes). Saving it would freeze the other route.
383
+ * Checked ignoring case too: on a macOS/Windows host `blog/New.html` answers `/blog/new`.
383
384
  */
384
385
  export function findMisownedTargets(
385
386
  manifest: Pick<RouteManifest, "pages" | "apis">,
@@ -388,19 +389,155 @@ export function findMisownedTargets(
388
389
  // Copies: findMatch caches a compiled regex on each route object it sees.
389
390
  const pages = sortRoutes(manifest.pages.map((r) => ({ pattern: r.pattern })));
390
391
  const apis = sortRoutes(manifest.apis.map((r) => ({ pattern: r.pattern })));
391
- const out = new Map<PrerenderTarget, string>();
392
- for (const t of targets) {
392
+ // The server's own matcher decides who serves the URL exactly.
393
+ const ownerOf = (t: PrerenderTarget) => {
393
394
  const api = findMatch(apis, t.path)?.route.pattern;
394
- let owner = api;
395
+ if (t.kind === "api") return api;
395
396
  // A page loses to any API at its URL, even a +server.ts in its own folder.
396
- if (t.kind === "page")
397
- owner = api ? `${api} (+server.ts)` : findMatch(pages, t.path)?.route.pattern;
398
- if (owner && owner !== t.route)
397
+ return api ? `${api} (+server.ts)` : findMatch(pages, t.path)?.route.pattern;
398
+ };
399
+ // Split once, in match order, for the case pass: every API first, then pages. An API
400
+ // target meets pages too: a live `/API/ALL` page is hidden by a saved `api/all` file.
401
+ const shape = (pattern: string, api: boolean): RouteShape => {
402
+ const parts = pattern.split("/").filter(Boolean);
403
+ const ranks = parts.map(segmentRank);
404
+ const lower = parts.map((p) => p.toLowerCase());
405
+ return { pattern, api, parts, lower, ranks, rest: ranks.at(-1) === 2 };
406
+ };
407
+ const order = [
408
+ ...apis.map((r) => shape(r.pattern, true)),
409
+ ...pages.map((r) => shape(r.pattern, false)),
410
+ ];
411
+ const out = new Map<PrerenderTarget, string>();
412
+ for (const t of targets) {
413
+ const owner = ownerOf(t);
414
+ if (owner && owner !== t.route) {
399
415
  out.set(t, `❌ Prerender: ${targetLabel(t)} is served by ${owner}, not this route`);
416
+ continue;
417
+ }
418
+ // No route answers the URL (a hand-built table): nothing to measure a case clash by.
419
+ if (!owner) continue;
420
+ const problem = caseProblem(t, order);
421
+ if (problem) out.set(t, problem);
400
422
  }
401
423
  return out;
402
424
  }
403
425
 
426
+ interface RouteShape {
427
+ pattern: string;
428
+ api: boolean;
429
+ parts: string[];
430
+ lower: string[];
431
+ ranks: (0 | 1 | 2)[];
432
+ /** Ends in `[...rest]`, so it takes paths longer than its parts. */
433
+ rest: boolean;
434
+ }
435
+
436
+ /**
437
+ * This route serves the URL exactly (findMatch said so). A route matching only ignoring
438
+ * case (a static part spelled `NEW` for `new`) then clashes if it wins some spelling: it
439
+ * ranks above this route, or the spelling differs at one of this route's static parts so
440
+ * this route can't answer it. On a host that ignores case the saved file answers that
441
+ * spelling. Overlaps through params alone (`/[...rest]` catching `/BLOG/new`) are allowed,
442
+ * or every catch-all next to a prerendered page would fail. A route ranked higher still
443
+ * that takes the very same spellings isn't looked for; the check may then name a route
444
+ * that never answers.
445
+ */
446
+ function caseProblem(t: PrerenderTarget, order: RouteShape[]): string | null {
447
+ const path = t.path.split("/").filter(Boolean);
448
+ const pathLower = path.map((p) => p.toLowerCase());
449
+ let own: PartMatch[] | null = null;
450
+ let hit: { r: RouteShape; kinds: PartMatch[]; above: boolean } | null = null;
451
+ for (const r of order) {
452
+ // Cheap filter first: most routes can't match on part count alone.
453
+ if (r.rest ? path.length < r.parts.length : path.length !== r.parts.length) continue;
454
+ const kinds = matchPartsFolded(r.parts, r.lower, r.ranks, path, pathLower);
455
+ if (!kinds) continue;
456
+ if (r.pattern === t.route && r.api === (t.kind === "api")) {
457
+ own = kinds;
458
+ if (hit) break; // a route above already wins a spelling
459
+ } else if (!kinds.includes("case")) continue;
460
+ else if (!own) hit ??= { r, kinds, above: true };
461
+ else if (escapesOwn(kinds, own, path)) {
462
+ hit = { r, kinds, above: false };
463
+ break;
464
+ }
465
+ }
466
+ // findMatch said this route serves the URL, so the walk must find it too; if not, the two
467
+ // matchers have drifted apart and every case clash would pass unseen.
468
+ if (!own)
469
+ throw new Error(
470
+ `Bosia internal: the prerender case check can't match ${t.path} to ${t.route}; please report it`,
471
+ );
472
+ if (!hit) return null;
473
+ const { r, kinds, above } = hit;
474
+ // The spelling it wins: its own static parts. Ranked below, every param under this
475
+ // route's static part changes case too, which also steers clear of routes in between
476
+ // (`/BLOG/NEW`, not `/blog/NEW` that `/blog/[slug]` takes).
477
+ const spelling = kinds.map((k, j) => {
478
+ if (k !== "param") return r.parts[j]!;
479
+ return (!above && own[j] === "same" && otherCase(path[j]!)) || path[j]!;
480
+ });
481
+ const name = r.api && t.kind === "page" ? `${r.pattern} (+server.ts)` : r.pattern;
482
+ return `❌ Prerender: ${targetLabel(t)} would also answer /${spelling.join("/")} on hosts that ignore case (macOS/Windows), which ${name} serves`;
483
+ }
484
+
485
+ /** How one part of a URL meets a route part: `same` static, `case` static spelled in
486
+ * another case, `param`. A match with no `case` is an exact match. */
487
+ type PartMatch = "same" | "case" | "param";
488
+
489
+ /**
490
+ * Match a route's parts against a URL's parts ignoring case, by compilePattern's rules: only
491
+ * a last `[...rest]` takes several parts, any other bracket part is one param. `null` when
492
+ * they don't match. `lower`/`pathLower` are the same parts in `toLowerCase`, made once by the
493
+ * caller. ASCII case only in effect: Unicode forms and special folds (`ς`/`σ`) aren't merged.
494
+ * Keep in step with matcher.ts compilePattern.
495
+ */
496
+ function matchPartsFolded(
497
+ parts: string[],
498
+ lower: string[],
499
+ ranks: (0 | 1 | 2)[],
500
+ path: string[],
501
+ pathLower: string[],
502
+ ): PartMatch[] | null {
503
+ const kinds: PartMatch[] = [];
504
+ for (const [i, seg] of parts.entries()) {
505
+ if (ranks[i] === 2 && i === parts.length - 1) {
506
+ if (path.length <= i) return null;
507
+ for (let j = i; j < path.length; j++) kinds.push("param");
508
+ return kinds;
509
+ }
510
+ const part = path[i];
511
+ if (part === undefined) return null;
512
+ if (ranks[i]) kinds.push("param");
513
+ else if (part === seg) kinds.push("same");
514
+ else if (pathLower[i] === lower[i]) kinds.push("case");
515
+ else return null;
516
+ }
517
+ return kinds.length === path.length ? kinds : null;
518
+ }
519
+
520
+ /** Some part where this route is static has a spelling the other route takes and this one doesn't. */
521
+ function escapesOwn(kinds: PartMatch[], own: PartMatch[], path: string[]): boolean {
522
+ return kinds.some(
523
+ (k, j) => own[j] === "same" && (k === "case" || (k === "param" && otherCase(path[j]!))),
524
+ );
525
+ }
526
+
527
+ /**
528
+ * `s` with every letter in the other case, keeping only swaps that lowercase back to the same
529
+ * letter (`ß` → `SS` and `İ` → `i̇` change length, so they stay). `null` if nothing swaps.
530
+ */
531
+ function otherCase(s: string): string | null {
532
+ const swapped = [...s]
533
+ .map((c) => {
534
+ const o = c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase();
535
+ return o.length === c.length && o.toLowerCase() === c.toLowerCase() ? o : c;
536
+ })
537
+ .join("");
538
+ return swapped === s ? null : swapped;
539
+ }
540
+
404
541
  /**
405
542
  * Files two targets would both write. macOS/Windows ignore case, so `About` and `about`
406
543
  * (or folders `A/` and `a/`) merge; a file and a folder can't share a path either.
@@ -0,0 +1,48 @@
1
+ // Behind a reverse proxy the inbound `Host`/scheme is the proxy's inner hop
2
+ // (e.g. `localhost:PORT` over plain HTTP). X-Forwarded-Host/-Proto carry the
3
+ // public origin, but any client can send them, so they count only when the
4
+ // operator sets TRUST_PROXY=true.
5
+
6
+ /**
7
+ * First value of an `X-Forwarded-*` header: each proxy in a chain appends its
8
+ * own ("https, http"), and the first is the client-facing one.
9
+ */
10
+ function firstForwarded(request: Request, name: string): string | undefined {
11
+ return request.headers.get(name)?.split(",")[0]?.trim() || undefined;
12
+ }
13
+
14
+ const SCHEMES: Record<string, string> = {
15
+ http: "http:",
16
+ https: "https:",
17
+ // Traefik sends ws/wss on upgrades; the origin a browser sends is http(s).
18
+ ws: "http:",
19
+ wss: "https:",
20
+ };
21
+
22
+ /**
23
+ * Point `url` at the public origin from X-Forwarded-Host/-Proto, in place, so
24
+ * `event.url`, redirects and the CSRF/WebSocket origin checks all see it.
25
+ * Does nothing unless `trustProxy`. A malformed host leaves `url` as it was,
26
+ * scheme included; an unknown scheme is ignored.
27
+ */
28
+ export function applyForwarded(request: Request, url: URL, trustProxy: boolean): void {
29
+ if (!trustProxy) return;
30
+ const proto = SCHEMES[firstForwarded(request, "x-forwarded-proto")?.toLowerCase() ?? ""];
31
+ const host = firstForwarded(request, "x-forwarded-host");
32
+ let parsed: URL | undefined;
33
+ if (host) {
34
+ // The parser would quietly keep part of `evil.com@app.com` or `app.com/x`.
35
+ if (/[\s/?#@\\]/.test(host)) return;
36
+ try {
37
+ parsed = new URL(`${proto ?? url.protocol}//${host}`);
38
+ } catch {
39
+ return;
40
+ }
41
+ }
42
+ if (proto) url.protocol = proto;
43
+ if (parsed) {
44
+ // hostname + port, not `host`: setting `host` without a port keeps the inner one.
45
+ url.hostname = parsed.hostname;
46
+ url.port = parsed.port;
47
+ }
48
+ }
@@ -8,15 +8,22 @@ import type { Cookies, LoaderDeps } from "./hooks.ts";
8
8
  import { CSP_ENABLED } from "./csp.ts";
9
9
  import {
10
10
  CACHE_ENABLED,
11
- CACHE_MAX_BODY_BYTES,
11
+ bodyFitsCache,
12
12
  buildCompressedVariants,
13
13
  cacheGet,
14
14
  cacheSet,
15
- coalesceMiss,
15
+ claimMiss,
16
16
  collectTags,
17
+ depsReadIgnoredParams,
18
+ markWholeQueryRead,
19
+ LOAD_TIMEOUT,
20
+ METADATA_TIMEOUT,
21
+ stripIgnoredParams,
17
22
  computeCacheKey,
18
23
  deferCacheWrite,
24
+ isNoStoreCacheControl,
19
25
  serveCached,
26
+ varyBlocksCache,
20
27
  } from "./cache.ts";
21
28
  import type { CookieJar } from "./cookies.ts";
22
29
  import { HttpError, Redirect, isHttpError, isRedirect } from "./errors.ts";
@@ -69,7 +76,7 @@ const enc = new TextEncoder();
69
76
  //
70
77
  // `url` is app-space (server.ts strips BASE_PATH exactly once, at the top of
71
78
  // handleRequest), but every value the client half writes to these fields is
72
- // browser-space: hydrate.ts assigns raw window.location.pathname, and
79
+ // browser-space: hydrate.ts assigns locationPath() (the address bar), and
73
80
  // clientRoutes are generated with the prefix already in them. So the base goes
74
81
  // back on before seeding, or a mounted app server-renders `/admin/audit` and
75
82
  // hydration flips it to `/sso/admin/audit`.
@@ -137,15 +144,6 @@ class LoadTimeoutError extends Error {
137
144
  }
138
145
  }
139
146
 
140
- function parseTimeout(raw: string | undefined, fallback: number): number {
141
- if (!raw || raw === "Infinity") return 0;
142
- const n = parseInt(raw, 10);
143
- return Number.isFinite(n) && n > 0 ? n : fallback;
144
- }
145
-
146
- const LOAD_TIMEOUT = parseTimeout(process.env.LOAD_TIMEOUT, 5000);
147
- const METADATA_TIMEOUT = parseTimeout(process.env.METADATA_TIMEOUT, 3000);
148
-
149
147
  // A plain-function load() hands back a value, not a promise; wrap it first.
150
148
  function withTimeout<T>(value: T | Promise<T>, ms: number, label: string): Promise<T> {
151
149
  const promise = Promise.resolve(value);
@@ -254,7 +252,7 @@ function stampErrorContext(
254
252
  // cache uses these records to skip re-runs on subsequent navigations
255
253
  // when none of the tracked inputs changed.
256
254
 
257
- function emptyDeps(): LoaderDeps {
255
+ export function emptyDeps(): LoaderDeps {
258
256
  return {
259
257
  keys: [],
260
258
  urls: [],
@@ -282,13 +280,16 @@ function trackedUrl(url: URL, deps: LoaderDeps): URL {
282
280
  const trackedSearch = new Proxy(url.searchParams, {
283
281
  get(target, prop) {
284
282
  if (prop === "get" || prop === "has" || prop === "getAll") {
285
- return (key: string) => {
283
+ // All arguments: `has(name, value)` checks the value too.
284
+ return (key: string, ...rest: unknown[]) => {
286
285
  if (typeof key === "string" && !deps.searchParams.includes(key)) {
287
286
  deps.searchParams.push(key);
288
287
  }
289
- return (target as any)[prop](key);
288
+ return (target as any)[prop](key, ...rest);
290
289
  };
291
290
  }
291
+ // entries(), size, toString(), iteration…: may depend on any param.
292
+ markWholeQueryRead(deps);
292
293
  const value = Reflect.get(target, prop);
293
294
  return typeof value === "function" ? value.bind(target) : value;
294
295
  },
@@ -299,8 +300,12 @@ function trackedUrl(url: URL, deps: LoaderDeps): URL {
299
300
  if (prop === "search") {
300
301
  // Whole search string read → treat as broad searchParams dep
301
302
  deps.uses_url = true;
303
+ markWholeQueryRead(deps);
302
304
  return target.search;
303
305
  }
306
+ if (prop === "href" || prop === "toString" || prop === "toJSON") {
307
+ markWholeQueryRead(deps);
308
+ }
304
309
  if (typeof prop === "string" && URL_TRACKED_PROPS.has(prop)) {
305
310
  deps.uses_url = true;
306
311
  }
@@ -532,6 +537,7 @@ export async function loadRouteData(
532
537
 
533
538
  // Settle root → leaf. The first failure wins, carrying the data of the
534
539
  // layers above it — the same error a serial run would have produced.
540
+ let omittedReadIgnored = false;
535
541
  for (let i = 0; i < route.layoutServers.length; i++) {
536
542
  const ls = route.layoutServers[i]!;
537
543
  // Runs regardless of the mask — see the trust note above loadRouteData.
@@ -540,6 +546,10 @@ export async function loadRouteData(
540
546
  const { data, deps } = await layers[i]!;
541
547
  layoutData[ls.depth] = omit ? null : data;
542
548
  layoutDeps[ls.depth] = omit ? null : deps;
549
+ // A left-out layout still feeds the layers below it via parent(): its
550
+ // ignored-param reads count for every one that is sent.
551
+ if (omit && depsReadIgnoredParams(null, deps)) omittedReadIgnored = true;
552
+ else if (!omit && omittedReadIgnored) markWholeQueryRead(deps);
543
553
  } catch (err) {
544
554
  // Loaders below this one already started; dev warns about the guard.
545
555
  const thrown = isDev ? guardLabel(err) : null;
@@ -606,6 +616,8 @@ export async function loadRouteData(
606
616
  }
607
617
  }
608
618
 
619
+ if (omittedReadIgnored && pageDeps) markWholeQueryRead(pageDeps);
620
+
609
621
  // `params` are always attached to pageData for client-side router consumption.
610
622
  // When pageData is skipped, the client merges its cached pageData with current
611
623
  // route params separately, so we keep the `null` sentinel here.
@@ -615,6 +627,14 @@ export async function loadRouteData(
615
627
 
616
628
  // ─── Metadata Loader ─────────────────────────────────────
617
629
 
630
+ /** metadata() calls that failed or timed out (keyed by their deps): the page
631
+ * still renders, without title/meta/canonical, but isn't cached that way. */
632
+ const failedMetadata = new WeakSet<LoaderDeps>();
633
+
634
+ export function metadataFailed(deps: LoaderDeps): boolean {
635
+ return failedMetadata.has(deps);
636
+ }
637
+
618
638
  export async function loadMetadata(
619
639
  route: any,
620
640
  params: Record<string, string>,
@@ -622,6 +642,8 @@ export async function loadMetadata(
622
642
  locals: Record<string, any>,
623
643
  cookies: Cookies,
624
644
  req: Request,
645
+ /** Records which query params metadata() read, for the response cache. */
646
+ deps?: LoaderDeps,
625
647
  ): Promise<Metadata | null> {
626
648
  if (!route.pageServer) return null;
627
649
  try {
@@ -633,7 +655,7 @@ export async function loadMetadata(
633
655
  mod.metadata({
634
656
  params,
635
657
  route: { id: route.id },
636
- url,
658
+ url: deps ? trackedUrl(url, deps) : url,
637
659
  locals,
638
660
  cookies,
639
661
  fetch,
@@ -647,6 +669,9 @@ export async function loadMetadata(
647
669
  } catch (err) {
648
670
  // Control flow thrown from metadata() is intent, not failure — swallowing it
649
671
  // here made every caller's Redirect/HttpError branch dead code.
672
+ // Also marked for control flow: the data endpoint swallows it and must
673
+ // not cache the 200 it sends instead.
674
+ if (deps) failedMetadata.add(deps);
650
675
  if (isRedirect(err) || isHttpError(err)) throw err;
651
676
  if (isDev) console.error("Metadata load error:", err);
652
677
  else console.error("Metadata load error:", (err as Error).message ?? err);
@@ -706,21 +731,25 @@ export async function renderSSRStream(
706
731
  if (cacheRead) {
707
732
  cacheKey = computeCacheKey(url, req, cookies as CookieJar);
708
733
  if (!cacheBypass) {
734
+ // Tracking params (CACHE_IGNORE_PARAMS): the plain URL's entry first,
735
+ // then this URL's own, as before — see cache.ts.
736
+ const plainUrl = stripIgnoredParams(url);
737
+ if (plainUrl !== url) {
738
+ const plain = cacheGet(computeCacheKey(plainUrl, req, cookies as CookieJar));
739
+ if (plain && !plain.queryAware) return serveCached(plain, req);
740
+ }
709
741
  const hit = cacheGet(cacheKey);
710
742
  if (hit) return serveCached(hit, req);
711
743
  }
712
744
  if (cacheable && !cacheBypass) {
713
745
  // Miss coalescing: the first miss leads the build; concurrent misses
714
- // wait, then re-check the cache. A waiter that still misses (leader
715
- // skipped the write) builds independently — no re-leadering.
716
- const gate = coalesceMiss(cacheKey);
717
- if (gate.wait) {
718
- await gate.wait;
719
- const rehit = cacheGet(cacheKey);
720
- if (rehit) return serveCached(rehit, req);
721
- } else {
722
- releaseMiss = gate.release;
723
- }
746
+ // wait, then re-check the cache — see claimMiss().
747
+ const claim = await claimMiss(cacheKey, req.signal);
748
+ if (claim.hit) return serveCached(claim.hit, req);
749
+ // The client left while waiting: nobody reads this. 499 (nginx's
750
+ // "client closed request"), so logs don't count it as a server error.
751
+ if (claim.aborted) return new Response(null, { status: 499 });
752
+ if (claim.lead) releaseMiss = claim.lead.release;
724
753
  }
725
754
  }
726
755
 
@@ -733,7 +762,8 @@ export async function renderSSRStream(
733
762
  // fragments all start together, and all settle before committing to a 200,
734
763
  // so a Redirect/HttpError from any of them still gets a proper response.
735
764
  // The page load() alone waits for metadata.data (see loadRouteData).
736
- const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
765
+ const metaDeps = emptyDeps();
766
+ const metadataP = loadMetadata(route, params, url, locals, cookies, req, metaDeps).then(
737
767
  (value) => ({ ok: true as const, value }),
738
768
  (err: unknown) => ({ ok: false as const, err }),
739
769
  );
@@ -968,11 +998,21 @@ export async function renderSSRStream(
968
998
  // Skip if the handler set cookies — cached response can't reproduce
969
999
  // per-request Set-Cookie headers. deferCacheWrite runs the variant
970
1000
  // compression after this response is flushed, not ahead of it.
971
- if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
1001
+ // A loader that said not to store its response is taken at its word, as
1002
+ // on the data endpoint: a `private` page must not be replayed from cache.
1003
+ const loaderOptOut =
1004
+ isNoStoreCacheControl(data.loaderHeaders?.["cache-control"]) ||
1005
+ varyBlocksCache(data.loaderHeaders?.["vary"]) ||
1006
+ metadataFailed(metaDeps);
1007
+ if (cacheable && cacheKey && !loaderOptOut && (cookies as any).outgoing?.length === 0) {
972
1008
  // Oversized bodies skip early so they never pay compression; cacheSet
973
1009
  // re-checks as the authoritative guard.
974
- if (CACHE_MAX_BODY_BYTES === 0 || fullBody.length <= CACHE_MAX_BODY_BYTES) {
1010
+ if (bodyFitsCache(fullBody.length)) {
975
1011
  const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
1012
+ const queryAware = depsReadIgnoredParams(
1013
+ [...(data.layoutDeps ?? []), metaDeps],
1014
+ data.pageDeps ?? null,
1015
+ );
976
1016
  const keyForWrite = cacheKey;
977
1017
  // Hand the gate release to the deferred write: waiters resume only
978
1018
  // after cacheSet ran, so their re-check hits.
@@ -1000,6 +1040,7 @@ export async function renderSSRStream(
1000
1040
  status: 200,
1001
1041
  extraHeaders: headers,
1002
1042
  tags,
1043
+ queryAware,
1003
1044
  },
1004
1045
  cookies,
1005
1046
  );