bosia 1.2.4 → 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.
@@ -3,7 +3,7 @@
3
3
  import { router, scrollToHash } from "./router.svelte.ts";
4
4
  import { findMatch } from "../matcher.ts";
5
5
  import { clientRoutes, defaultTitle } from "bosia:routes";
6
- import { consumePrefetch, prefetchCache, dataUrl, readDataResponse } from "./prefetch.ts";
6
+ import { consumePrefetch, clearPrefetches, dataUrl, readDataResponse } from "./prefetch.ts";
7
7
  import { appState, clearDirty } from "./appState.svelte.ts";
8
8
  import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
9
9
  import { pickErrorPage } from "../errorMatch.ts";
@@ -350,18 +350,26 @@
350
350
 
351
351
  // Load components + data in parallel, then update state atomically
352
352
  // to avoid a flash of stale/empty data before the fetch completes.
353
- const cached = match.route.hasServerData && !invalidated ? consumePrefetch(path) : null;
354
- prefetchCache.clear(); // clear remaining entries on navigation — matches SvelteKit behavior
353
+ // `fresh` covers `invalidated` plus dirty keys/URLs: a prefetch sent before
354
+ // an invalidate() skipped `_fresh`, so it may hold the server's stale copy.
355
+ const cached = match.route.hasServerData && !fresh ? consumePrefetch(path, maskBits) : null;
356
+ clearPrefetches(); // clear remaining entries on navigation — matches SvelteKit behavior
355
357
  // A prerendered route's data is a fixed file, so there is nothing to omit.
356
358
  const prerendered = match.route.prerender;
359
+ const fetchData = () =>
360
+ fetch(dataUrl(path, prerendered ? undefined : maskBits, fresh && !prerendered))
361
+ .then(readDataResponse)
362
+ // Only a failed request reaches here now — offline, DNS, aborted.
363
+ // A response that arrived is read for what it says, not discarded.
364
+ .catch(() => null);
357
365
  const dataFetch = cached
358
- ? Promise.resolve(cached)
366
+ ? // A prefetch still in flight: wait for it; fetch again if it failed or
367
+ // errored (a hover-time 500 shouldn't stand in for the click's answer).
368
+ cached instanceof Promise
369
+ ? cached.then((data) => (data && !data.error ? data : fetchData()))
370
+ : Promise.resolve(cached)
359
371
  : match.route.hasServerData
360
- ? fetch(dataUrl(path, prerendered ? undefined : maskBits, fresh && !prerendered))
361
- .then(readDataResponse)
362
- // Only a failed request reaches here now — offline, DNS, aborted.
363
- // A response that arrived is read for what it says, not discarded.
364
- .catch(() => null)
372
+ ? fetchData()
365
373
  : Promise.resolve(null);
366
374
 
367
375
  // Scrolls first: waiting goto()/use:enhance calls resume after the scroll.
@@ -109,21 +109,46 @@ function errorMessage(res: Response): string {
109
109
  return res.statusText || "Internal Server Error";
110
110
  }
111
111
 
112
- export const prefetchCache = new Map<string, { data: any; ts: number }>();
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 fetch deduplication
116
- const pending = new Set<string>();
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
- /** Returns cached prefetch data for a path and removes it from cache. */
119
- export function consumePrefetch(path: string): any | null {
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
- pending.add(path);
156
- try {
157
- // Send the same mask as a real client nav would so the server leaves out
158
- // layers the client already holds. Falls back to sending everything when
159
- // the route can't be matched (e.g. external/unknown URL).
160
- const maskBits = buildMaskBits(path) ?? undefined;
161
- const res = await fetch(dataUrl(path, maskBits));
162
- // `ok` alone would cache a guard's login page (200 after the redirect was
163
- // followed) as if it were this route's data.
164
- if (res.ok && !res.redirected && isJsonResponse(res)) {
165
- if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
166
- const oldest = prefetchCache.keys().next().value;
167
- if (oldest !== undefined) prefetchCache.delete(oldest);
168
- }
169
- prefetchCache.set(path, { data: await res.json(), ts: Date.now() });
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
- } catch {
172
- // Silently ignore — prefetch is best-effort
173
- } finally {
174
- pending.delete(path);
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.set("Vary", "Origin");
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
- const inflight = new Map<string, Promise<unknown>>();
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
- /** Coalesce concurrent calls under `key`. `fn` runs at most once per inflight window. */
23
- export function dedup<T>(key: string, fn: () => Promise<T>): Promise<T> {
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) return existing as Promise<T>;
26
- const promise = fn().finally(() => inflight.delete(key));
27
- inflight.set(key, promise);
28
- return promise as Promise<T>;
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
@@ -30,6 +30,9 @@ const FRAMEWORK_VARS = new Set([
30
30
  "CACHE_KEYS",
31
31
  "CACHE_MAX_ENTRIES",
32
32
  "CACHE_MAX_BODY_BYTES",
33
+ "CACHE_MAX_BYTES",
34
+ "CACHE_WAIT_TIMEOUT",
35
+ "CACHE_IGNORE_PARAMS",
33
36
  "ASSET_CACHE_MAX_BYTES",
34
37
  "ASSET_CACHE_MAX_FILE_BYTES",
35
38
  ]);
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, 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;