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/errors.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
78
|
+
if (DANGEROUS_PROTOCOL.test(url.protocol)) {
|
|
70
79
|
throw new Error(
|
|
71
80
|
`redirect(): dangerous scheme in URL "${location}". ` +
|
|
72
|
-
`
|
|
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
|
-
//
|
|
87
|
-
|
|
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,
|
|
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;
|
package/src/core/prerender.ts
CHANGED
|
@@ -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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
+
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -8,15 +8,22 @@ import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
|
8
8
|
import { CSP_ENABLED } from "./csp.ts";
|
|
9
9
|
import {
|
|
10
10
|
CACHE_ENABLED,
|
|
11
|
-
|
|
11
|
+
bodyFitsCache,
|
|
12
12
|
buildCompressedVariants,
|
|
13
13
|
cacheGet,
|
|
14
14
|
cacheSet,
|
|
15
|
-
|
|
15
|
+
claimMiss,
|
|
16
16
|
collectTags,
|
|
17
|
+
depsReadIgnoredParams,
|
|
18
|
+
markWholeQueryRead,
|
|
19
|
+
LOAD_TIMEOUT,
|
|
20
|
+
METADATA_TIMEOUT,
|
|
21
|
+
stripIgnoredParams,
|
|
17
22
|
computeCacheKey,
|
|
18
23
|
deferCacheWrite,
|
|
24
|
+
isNoStoreCacheControl,
|
|
19
25
|
serveCached,
|
|
26
|
+
varyBlocksCache,
|
|
20
27
|
} from "./cache.ts";
|
|
21
28
|
import type { CookieJar } from "./cookies.ts";
|
|
22
29
|
import { HttpError, Redirect, isHttpError, isRedirect } from "./errors.ts";
|
|
@@ -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
|
|
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
|
-
|
|
283
|
+
// All arguments: `has(name, value)` checks the value too.
|
|
284
|
+
return (key: string, ...rest: unknown[]) => {
|
|
286
285
|
if (typeof key === "string" && !deps.searchParams.includes(key)) {
|
|
287
286
|
deps.searchParams.push(key);
|
|
288
287
|
}
|
|
289
|
-
return (target as any)[prop](key);
|
|
288
|
+
return (target as any)[prop](key, ...rest);
|
|
290
289
|
};
|
|
291
290
|
}
|
|
291
|
+
// entries(), size, toString(), iteration…: may depend on any param.
|
|
292
|
+
markWholeQueryRead(deps);
|
|
292
293
|
const value = Reflect.get(target, prop);
|
|
293
294
|
return typeof value === "function" ? value.bind(target) : value;
|
|
294
295
|
},
|
|
@@ -299,8 +300,12 @@ function trackedUrl(url: URL, deps: LoaderDeps): URL {
|
|
|
299
300
|
if (prop === "search") {
|
|
300
301
|
// Whole search string read → treat as broad searchParams dep
|
|
301
302
|
deps.uses_url = true;
|
|
303
|
+
markWholeQueryRead(deps);
|
|
302
304
|
return target.search;
|
|
303
305
|
}
|
|
306
|
+
if (prop === "href" || prop === "toString" || prop === "toJSON") {
|
|
307
|
+
markWholeQueryRead(deps);
|
|
308
|
+
}
|
|
304
309
|
if (typeof prop === "string" && URL_TRACKED_PROPS.has(prop)) {
|
|
305
310
|
deps.uses_url = true;
|
|
306
311
|
}
|
|
@@ -532,6 +537,7 @@ export async function loadRouteData(
|
|
|
532
537
|
|
|
533
538
|
// Settle root → leaf. The first failure wins, carrying the data of the
|
|
534
539
|
// layers above it — the same error a serial run would have produced.
|
|
540
|
+
let omittedReadIgnored = false;
|
|
535
541
|
for (let i = 0; i < route.layoutServers.length; i++) {
|
|
536
542
|
const ls = route.layoutServers[i]!;
|
|
537
543
|
// Runs regardless of the mask — see the trust note above loadRouteData.
|
|
@@ -540,6 +546,10 @@ export async function loadRouteData(
|
|
|
540
546
|
const { data, deps } = await layers[i]!;
|
|
541
547
|
layoutData[ls.depth] = omit ? null : data;
|
|
542
548
|
layoutDeps[ls.depth] = omit ? null : deps;
|
|
549
|
+
// A left-out layout still feeds the layers below it via parent(): its
|
|
550
|
+
// ignored-param reads count for every one that is sent.
|
|
551
|
+
if (omit && depsReadIgnoredParams(null, deps)) omittedReadIgnored = true;
|
|
552
|
+
else if (!omit && omittedReadIgnored) markWholeQueryRead(deps);
|
|
543
553
|
} catch (err) {
|
|
544
554
|
// Loaders below this one already started; dev warns about the guard.
|
|
545
555
|
const thrown = isDev ? guardLabel(err) : null;
|
|
@@ -606,6 +616,8 @@ export async function loadRouteData(
|
|
|
606
616
|
}
|
|
607
617
|
}
|
|
608
618
|
|
|
619
|
+
if (omittedReadIgnored && pageDeps) markWholeQueryRead(pageDeps);
|
|
620
|
+
|
|
609
621
|
// `params` are always attached to pageData for client-side router consumption.
|
|
610
622
|
// When pageData is skipped, the client merges its cached pageData with current
|
|
611
623
|
// route params separately, so we keep the `null` sentinel here.
|
|
@@ -615,6 +627,14 @@ export async function loadRouteData(
|
|
|
615
627
|
|
|
616
628
|
// ─── Metadata Loader ─────────────────────────────────────
|
|
617
629
|
|
|
630
|
+
/** metadata() calls that failed or timed out (keyed by their deps): the page
|
|
631
|
+
* still renders, without title/meta/canonical, but isn't cached that way. */
|
|
632
|
+
const failedMetadata = new WeakSet<LoaderDeps>();
|
|
633
|
+
|
|
634
|
+
export function metadataFailed(deps: LoaderDeps): boolean {
|
|
635
|
+
return failedMetadata.has(deps);
|
|
636
|
+
}
|
|
637
|
+
|
|
618
638
|
export async function loadMetadata(
|
|
619
639
|
route: any,
|
|
620
640
|
params: Record<string, string>,
|
|
@@ -622,6 +642,8 @@ export async function loadMetadata(
|
|
|
622
642
|
locals: Record<string, any>,
|
|
623
643
|
cookies: Cookies,
|
|
624
644
|
req: Request,
|
|
645
|
+
/** Records which query params metadata() read, for the response cache. */
|
|
646
|
+
deps?: LoaderDeps,
|
|
625
647
|
): Promise<Metadata | null> {
|
|
626
648
|
if (!route.pageServer) return null;
|
|
627
649
|
try {
|
|
@@ -633,7 +655,7 @@ export async function loadMetadata(
|
|
|
633
655
|
mod.metadata({
|
|
634
656
|
params,
|
|
635
657
|
route: { id: route.id },
|
|
636
|
-
url,
|
|
658
|
+
url: deps ? trackedUrl(url, deps) : url,
|
|
637
659
|
locals,
|
|
638
660
|
cookies,
|
|
639
661
|
fetch,
|
|
@@ -647,6 +669,9 @@ export async function loadMetadata(
|
|
|
647
669
|
} catch (err) {
|
|
648
670
|
// Control flow thrown from metadata() is intent, not failure — swallowing it
|
|
649
671
|
// here made every caller's Redirect/HttpError branch dead code.
|
|
672
|
+
// Also marked for control flow: the data endpoint swallows it and must
|
|
673
|
+
// not cache the 200 it sends instead.
|
|
674
|
+
if (deps) failedMetadata.add(deps);
|
|
650
675
|
if (isRedirect(err) || isHttpError(err)) throw err;
|
|
651
676
|
if (isDev) console.error("Metadata load error:", err);
|
|
652
677
|
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
@@ -706,21 +731,25 @@ export async function renderSSRStream(
|
|
|
706
731
|
if (cacheRead) {
|
|
707
732
|
cacheKey = computeCacheKey(url, req, cookies as CookieJar);
|
|
708
733
|
if (!cacheBypass) {
|
|
734
|
+
// Tracking params (CACHE_IGNORE_PARAMS): the plain URL's entry first,
|
|
735
|
+
// then this URL's own, as before — see cache.ts.
|
|
736
|
+
const plainUrl = stripIgnoredParams(url);
|
|
737
|
+
if (plainUrl !== url) {
|
|
738
|
+
const plain = cacheGet(computeCacheKey(plainUrl, req, cookies as CookieJar));
|
|
739
|
+
if (plain && !plain.queryAware) return serveCached(plain, req);
|
|
740
|
+
}
|
|
709
741
|
const hit = cacheGet(cacheKey);
|
|
710
742
|
if (hit) return serveCached(hit, req);
|
|
711
743
|
}
|
|
712
744
|
if (cacheable && !cacheBypass) {
|
|
713
745
|
// Miss coalescing: the first miss leads the build; concurrent misses
|
|
714
|
-
// wait, then re-check the cache
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
} else {
|
|
722
|
-
releaseMiss = gate.release;
|
|
723
|
-
}
|
|
746
|
+
// wait, then re-check the cache — see claimMiss().
|
|
747
|
+
const claim = await claimMiss(cacheKey, req.signal);
|
|
748
|
+
if (claim.hit) return serveCached(claim.hit, req);
|
|
749
|
+
// The client left while waiting: nobody reads this. 499 (nginx's
|
|
750
|
+
// "client closed request"), so logs don't count it as a server error.
|
|
751
|
+
if (claim.aborted) return new Response(null, { status: 499 });
|
|
752
|
+
if (claim.lead) releaseMiss = claim.lead.release;
|
|
724
753
|
}
|
|
725
754
|
}
|
|
726
755
|
|
|
@@ -733,7 +762,8 @@ export async function renderSSRStream(
|
|
|
733
762
|
// fragments all start together, and all settle before committing to a 200,
|
|
734
763
|
// so a Redirect/HttpError from any of them still gets a proper response.
|
|
735
764
|
// The page load() alone waits for metadata.data (see loadRouteData).
|
|
736
|
-
const
|
|
765
|
+
const metaDeps = emptyDeps();
|
|
766
|
+
const metadataP = loadMetadata(route, params, url, locals, cookies, req, metaDeps).then(
|
|
737
767
|
(value) => ({ ok: true as const, value }),
|
|
738
768
|
(err: unknown) => ({ ok: false as const, err }),
|
|
739
769
|
);
|
|
@@ -968,11 +998,21 @@ export async function renderSSRStream(
|
|
|
968
998
|
// Skip if the handler set cookies — cached response can't reproduce
|
|
969
999
|
// per-request Set-Cookie headers. deferCacheWrite runs the variant
|
|
970
1000
|
// compression after this response is flushed, not ahead of it.
|
|
971
|
-
|
|
1001
|
+
// A loader that said not to store its response is taken at its word, as
|
|
1002
|
+
// on the data endpoint: a `private` page must not be replayed from cache.
|
|
1003
|
+
const loaderOptOut =
|
|
1004
|
+
isNoStoreCacheControl(data.loaderHeaders?.["cache-control"]) ||
|
|
1005
|
+
varyBlocksCache(data.loaderHeaders?.["vary"]) ||
|
|
1006
|
+
metadataFailed(metaDeps);
|
|
1007
|
+
if (cacheable && cacheKey && !loaderOptOut && (cookies as any).outgoing?.length === 0) {
|
|
972
1008
|
// Oversized bodies skip early so they never pay compression; cacheSet
|
|
973
1009
|
// re-checks as the authoritative guard.
|
|
974
|
-
if (
|
|
1010
|
+
if (bodyFitsCache(fullBody.length)) {
|
|
975
1011
|
const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
|
|
1012
|
+
const queryAware = depsReadIgnoredParams(
|
|
1013
|
+
[...(data.layoutDeps ?? []), metaDeps],
|
|
1014
|
+
data.pageDeps ?? null,
|
|
1015
|
+
);
|
|
976
1016
|
const keyForWrite = cacheKey;
|
|
977
1017
|
// Hand the gate release to the deferred write: waiters resume only
|
|
978
1018
|
// after cacheSet ran, so their re-check hits.
|
|
@@ -1000,6 +1040,7 @@ export async function renderSSRStream(
|
|
|
1000
1040
|
status: 200,
|
|
1001
1041
|
extraHeaders: headers,
|
|
1002
1042
|
tags,
|
|
1043
|
+
queryAware,
|
|
1003
1044
|
},
|
|
1004
1045
|
cookies,
|
|
1005
1046
|
);
|