bosia 1.2.5 → 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -6
- package/package.json +2 -1
- package/src/core/backend.ts +56 -2
- package/src/core/cache.ts +378 -30
- package/src/core/client/App.svelte +17 -9
- package/src/core/client/prefetch.ts +57 -24
- package/src/core/cors.ts +17 -1
- package/src/core/dedup.ts +51 -7
- package/src/core/env.ts +3 -0
- package/src/core/hooks.ts +1 -1
- package/src/core/html.ts +13 -1
- package/src/core/plugins/access-log.ts +128 -0
- package/src/core/prerender.ts +143 -6
- package/src/core/renderer.ts +69 -28
- package/src/core/server.ts +76 -36
- package/src/core/server.workers.ts +1 -1
- package/src/core/types/plugin.ts +2 -1
- package/templates/default/.env.example +20 -5
- package/templates/default/tsconfig.json +1 -1
- package/templates/demo/.env.example +20 -5
- package/templates/demo/src/hooks.server.ts +3 -12
- package/templates/demo/tsconfig.json +1 -1
- package/templates/shop/src/app.d.ts +0 -1
- package/templates/shop/src/hooks.server.ts +3 -11
- package/templates/shop/src/routes/+layout.server.ts +0 -1
- package/templates/shop/tsconfig.json +1 -1
- package/templates/store/src/app.d.ts +0 -1
- package/templates/store/src/hooks.server.ts +3 -11
- package/templates/store/src/routes/+layout.server.ts +0 -1
- package/templates/store/tsconfig.json +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { router, scrollToHash } from "./router.svelte.ts";
|
|
4
4
|
import { findMatch } from "../matcher.ts";
|
|
5
5
|
import { clientRoutes, defaultTitle } from "bosia:routes";
|
|
6
|
-
import { consumePrefetch,
|
|
6
|
+
import { consumePrefetch, clearPrefetches, dataUrl, readDataResponse } from "./prefetch.ts";
|
|
7
7
|
import { appState, clearDirty } from "./appState.svelte.ts";
|
|
8
8
|
import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
|
|
9
9
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
@@ -350,18 +350,26 @@
|
|
|
350
350
|
|
|
351
351
|
// Load components + data in parallel, then update state atomically
|
|
352
352
|
// to avoid a flash of stale/empty data before the fetch completes.
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
// `fresh` covers `invalidated` plus dirty keys/URLs: a prefetch sent before
|
|
354
|
+
// an invalidate() skipped `_fresh`, so it may hold the server's stale copy.
|
|
355
|
+
const cached = match.route.hasServerData && !fresh ? consumePrefetch(path, maskBits) : null;
|
|
356
|
+
clearPrefetches(); // clear remaining entries on navigation — matches SvelteKit behavior
|
|
355
357
|
// A prerendered route's data is a fixed file, so there is nothing to omit.
|
|
356
358
|
const prerendered = match.route.prerender;
|
|
359
|
+
const fetchData = () =>
|
|
360
|
+
fetch(dataUrl(path, prerendered ? undefined : maskBits, fresh && !prerendered))
|
|
361
|
+
.then(readDataResponse)
|
|
362
|
+
// Only a failed request reaches here now — offline, DNS, aborted.
|
|
363
|
+
// A response that arrived is read for what it says, not discarded.
|
|
364
|
+
.catch(() => null);
|
|
357
365
|
const dataFetch = cached
|
|
358
|
-
?
|
|
366
|
+
? // A prefetch still in flight: wait for it; fetch again if it failed or
|
|
367
|
+
// errored (a hover-time 500 shouldn't stand in for the click's answer).
|
|
368
|
+
cached instanceof Promise
|
|
369
|
+
? cached.then((data) => (data && !data.error ? data : fetchData()))
|
|
370
|
+
: Promise.resolve(cached)
|
|
359
371
|
: match.route.hasServerData
|
|
360
|
-
?
|
|
361
|
-
.then(readDataResponse)
|
|
362
|
-
// Only a failed request reaches here now — offline, DNS, aborted.
|
|
363
|
-
// A response that arrived is read for what it says, not discarded.
|
|
364
|
-
.catch(() => null)
|
|
372
|
+
? fetchData()
|
|
365
373
|
: Promise.resolve(null);
|
|
366
374
|
|
|
367
375
|
// Scrolls first: waiting goto()/use:enhance calls resume after the scroll.
|
|
@@ -109,21 +109,46 @@ function errorMessage(res: Response): string {
|
|
|
109
109
|
return res.statusText || "Internal Server Error";
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
// `mask`: the `_invalidated` bits the prefetch sent (null = asked for every layer).
|
|
113
|
+
const prefetchCache = new Map<string, { data: any; ts: number; mask: string | null }>();
|
|
113
114
|
const MAX_PREFETCH_ENTRIES = 50;
|
|
114
115
|
|
|
115
|
-
// In-flight
|
|
116
|
-
|
|
116
|
+
// In-flight prefetches. A click that lands while its prefetch is still loading
|
|
117
|
+
// (a tap: touchstart prefetches ~100ms before the click) waits for that fetch
|
|
118
|
+
// instead of sending the same `/__bosia/data` request a second time. It gets
|
|
119
|
+
// whatever the response said (data, a redirect, an error), read like the
|
|
120
|
+
// router's own fetch; `null` only when the request itself failed.
|
|
121
|
+
// `clearPrefetches()` empties it on every navigation, so a prefetch that started
|
|
122
|
+
// before it neither fills the cache nor serves a later click.
|
|
123
|
+
const pending = new Map<string, { run: Promise<any | null>; mask: string | null }>();
|
|
117
124
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Prefetched data for a path, or the in-flight prefetch's promise (resolves to
|
|
127
|
+
* the data, or `null` when the prefetch can't be used — fetch it yourself).
|
|
128
|
+
* The entry is consumed: a second call gets `null`. `mask` is the navigation's
|
|
129
|
+
* own `_invalidated` bits: a prefetch built against the loader cache as it was
|
|
130
|
+
* at hover time may have left out a layer this navigation needs.
|
|
131
|
+
*/
|
|
132
|
+
export function consumePrefetch(path: string, mask: string): any | Promise<any | null> | null {
|
|
133
|
+
const inflight = pending.get(path);
|
|
134
|
+
if (inflight) {
|
|
135
|
+
pending.delete(path);
|
|
136
|
+
return inflight.mask === null || inflight.mask === mask ? inflight.run : null;
|
|
137
|
+
}
|
|
120
138
|
const entry = prefetchCache.get(path);
|
|
121
139
|
if (entry === undefined) return null;
|
|
122
140
|
prefetchCache.delete(path);
|
|
123
141
|
if (Date.now() - entry.ts > 30_000) return null;
|
|
142
|
+
if (entry.mask !== null && entry.mask !== mask) return null;
|
|
124
143
|
return entry.data;
|
|
125
144
|
}
|
|
126
145
|
|
|
146
|
+
/** Drops every prefetch, finished or in flight — call on navigation. */
|
|
147
|
+
export function clearPrefetches(): void {
|
|
148
|
+
prefetchCache.clear();
|
|
149
|
+
pending.clear();
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
/** Prefetches data for a path and stores in cache. No-op if already cached/in-flight. */
|
|
128
153
|
export async function prefetchPath(path: string): Promise<void> {
|
|
129
154
|
// Nothing to fetch for the page we're on. A click also focuses its link, so
|
|
@@ -152,27 +177,35 @@ export async function prefetchPath(path: string): Promise<void> {
|
|
|
152
177
|
// A route without server data never fetches on click either.
|
|
153
178
|
if (!warmMatch?.route.hasServerData) return;
|
|
154
179
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
180
|
+
// Only a plain data response is kept for a later click; `ok` alone would
|
|
181
|
+
// cache a guard's login page (200 after the redirect was followed).
|
|
182
|
+
let storable = false;
|
|
183
|
+
// Send the same mask as a real client nav would so the server leaves out
|
|
184
|
+
// layers the client already holds. Falls back to sending everything when
|
|
185
|
+
// the route can't be matched (e.g. external/unknown URL).
|
|
186
|
+
const mask = buildMaskBits(path);
|
|
187
|
+
const run = (async (): Promise<any | null> => {
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(dataUrl(path, mask ?? undefined));
|
|
190
|
+
storable = res.ok && !res.redirected && isJsonResponse(res);
|
|
191
|
+
return await readDataResponse(res);
|
|
192
|
+
} catch {
|
|
193
|
+
// Silently ignore — prefetch is best-effort
|
|
194
|
+
return null;
|
|
170
195
|
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
196
|
+
})();
|
|
197
|
+
pending.set(path, { run, mask });
|
|
198
|
+
const data = await run;
|
|
199
|
+
// Consumed by a click, or dropped by a navigation, while in flight.
|
|
200
|
+
if (pending.get(path)?.run !== run) return;
|
|
201
|
+
pending.delete(path);
|
|
202
|
+
// A guard's redirect is the session's answer now, not in 30s.
|
|
203
|
+
if (data === null || !storable || data.error || data.redirect) return;
|
|
204
|
+
if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
|
|
205
|
+
const oldest = prefetchCache.keys().next().value;
|
|
206
|
+
if (oldest !== undefined) prefetchCache.delete(oldest);
|
|
175
207
|
}
|
|
208
|
+
prefetchCache.set(path, { data, ts: Date.now(), mask });
|
|
176
209
|
}
|
|
177
210
|
|
|
178
211
|
function getLinkHref(anchor: HTMLAnchorElement): string | null {
|
package/src/core/cors.ts
CHANGED
|
@@ -29,7 +29,23 @@ function parseHeaderList(value: string): string[] {
|
|
|
29
29
|
* response with `Access-Control-Allow-Origin: X` to a different origin Y.
|
|
30
30
|
*/
|
|
31
31
|
export function applyCorsVary(headers: Headers): void {
|
|
32
|
-
headers
|
|
32
|
+
mergeVary(headers, "Origin");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add `value` to the Vary header, keeping what is there: `set("Vary", "Origin")`
|
|
37
|
+
* dropped `Accept-Encoding`, so a CDN could hand a brotli body to a client that
|
|
38
|
+
* never asked for one.
|
|
39
|
+
*/
|
|
40
|
+
export function mergeVary(headers: Headers, value: string): void {
|
|
41
|
+
const current = headers.get("Vary");
|
|
42
|
+
if (!current) {
|
|
43
|
+
headers.set("Vary", value);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const parts = current.split(",").map((p) => p.trim().toLowerCase());
|
|
47
|
+
if (parts.includes("*") || parts.includes(value.toLowerCase())) return;
|
|
48
|
+
headers.set("Vary", `${current}, ${value}`);
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
/**
|
package/src/core/dedup.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// different users never share a loader result. Custom session cookie names
|
|
9
9
|
// must be listed in CACHE_KEYS. See docs/guides/request-deduplication.md.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
type Inflight = { promise: Promise<unknown>; start: number };
|
|
12
|
+
const inflight = new Map<string, Inflight>();
|
|
12
13
|
|
|
13
14
|
/** Build a stable dedup key from a URL: normalized path + sorted query. */
|
|
14
15
|
export function dedupKey(url: URL): string {
|
|
@@ -19,11 +20,54 @@ export function dedupKey(url: URL): string {
|
|
|
19
20
|
return search ? `${path}?${search}` : path;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Coalesce concurrent calls under `key`. `fn` runs at most once per inflight
|
|
25
|
+
* window. With `maxAgeMs` > 0 a call is joined only until it is that old:
|
|
26
|
+
* one that never settles (a hung loader with LOAD_TIMEOUT=0) would otherwise
|
|
27
|
+
* take every later request for the key down with it. A call that started
|
|
28
|
+
* before `replaceStartedBefore` is replaced rather than joined (the caller
|
|
29
|
+
* knows it is stuck); later calls join the new one.
|
|
30
|
+
*/
|
|
31
|
+
export function dedup<T>(
|
|
32
|
+
key: string,
|
|
33
|
+
fn: () => Promise<T>,
|
|
34
|
+
maxAgeMs = 0,
|
|
35
|
+
replaceStartedBefore = -Infinity,
|
|
36
|
+
): Promise<T> {
|
|
24
37
|
const existing = inflight.get(key);
|
|
25
|
-
if (existing
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
if (existing && existing.start >= replaceStartedBefore) {
|
|
39
|
+
if (maxAgeMs <= 0) return existing.promise as Promise<T>;
|
|
40
|
+
const left = maxAgeMs - (performance.now() - existing.start);
|
|
41
|
+
if (left > 0) {
|
|
42
|
+
// Joined until the call reaches maxAgeMs; then the first joiner
|
|
43
|
+
// replaces it and the rest join the new call. One timer per joiner:
|
|
44
|
+
// on Workers a timer dies with the request that set it.
|
|
45
|
+
const joined = existing.promise as Promise<T>;
|
|
46
|
+
return new Promise<T>((resolve, reject) => {
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
const stuck = inflight.get(key) === existing;
|
|
49
|
+
dedup(key, fn, maxAgeMs, stuck ? Infinity : -Infinity).then(resolve, reject);
|
|
50
|
+
}, left);
|
|
51
|
+
(timer as { unref?: () => void }).unref?.();
|
|
52
|
+
joined.then(
|
|
53
|
+
(v) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
resolve(v);
|
|
56
|
+
},
|
|
57
|
+
(e) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
reject(e);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const entry: Inflight = { promise: null as unknown as Promise<T>, start: performance.now() };
|
|
66
|
+
const promise = fn().finally(() => {
|
|
67
|
+
// Never drops a newer call that replaced a stale one.
|
|
68
|
+
if (inflight.get(key) === entry) inflight.delete(key);
|
|
69
|
+
});
|
|
70
|
+
entry.promise = promise;
|
|
71
|
+
inflight.set(key, entry);
|
|
72
|
+
return promise;
|
|
29
73
|
}
|
package/src/core/env.ts
CHANGED
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, response, start }) => {
|
|
115
|
+
log({
|
|
116
|
+
method: request.method,
|
|
117
|
+
path: pathOf(request.url),
|
|
118
|
+
status: response.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.
|