bosia 1.1.3 → 1.1.5

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/src/core/cache.ts CHANGED
@@ -12,6 +12,7 @@ import type { Cookies, LoaderDeps } from "./hooks.ts";
12
12
  import type { CookieJar } from "./cookies.ts";
13
13
  import { dedupKey } from "./dedup.ts";
14
14
  import { compressionOn, encodeBytes, PRECOMPRESSED } from "./html.ts";
15
+ import { matchesEtag } from "./etag.ts";
15
16
 
16
17
  // ─── Config ──────────────────────────────────────────────
17
18
 
@@ -76,6 +77,9 @@ export type CacheEntry = {
76
77
  status: number;
77
78
  extraHeaders: Record<string, string>;
78
79
  tags: string[];
80
+ /** Digest of `raw`, filled in by `cacheSet`; the ETag is built from it per
81
+ * encoding so a gzip and a brotli copy are never mistaken for each other. */
82
+ etag?: string;
79
83
  };
80
84
 
81
85
  // ─── Tiny LRU ────────────────────────────────────────────
@@ -218,6 +222,8 @@ export function cacheSet(key: string, entry: CacheEntry, cookies?: Cookies): voi
218
222
  if (!CACHE_ENABLED) return;
219
223
  if (CACHE_MAX_BODY_BYTES > 0 && entry.raw.length > CACHE_MAX_BODY_BYTES) return;
220
224
  if (cookies) warnUncoveredCookies(cookies);
225
+ // Runs in the deferred write, after the response went out — never on a hit.
226
+ entry.etag ??= createHash("sha1").update(entry.raw).digest("base64url").slice(0, 22);
221
227
  // Drop any existing entry's index pointers first
222
228
  cacheDeleteKey(key);
223
229
  const evicted = htmlCache.set(key, entry);
@@ -279,26 +285,35 @@ function cacheDeleteIndexOnly(key: string, entry: CacheEntry): void {
279
285
 
280
286
  // ─── Compression helpers ─────────────────────────────────
281
287
 
282
- /** Build gzip + brotli copies of body. Sync — call it from `deferCacheWrite`. */
283
- export function buildCompressedVariants(body: Bytes): {
288
+ /**
289
+ * Build gzip + brotli copies of body. Sync — call it from `deferCacheWrite`.
290
+ * `prebuilt`: a variant already encoded for the response (at cache quality),
291
+ * reused as-is so a miss compresses each encoding once.
292
+ */
293
+ export function buildCompressedVariants(
294
+ body: Bytes,
295
+ prebuilt: { gzip?: Bytes; brotli?: Bytes } = {},
296
+ ): {
284
297
  gzip: Bytes | null;
285
298
  brotli: Bytes | null;
286
299
  } {
287
300
  const COMPRESS_MIN_BYTES = 2048;
288
301
  if (!compressionOn || body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
289
- let gzip: Bytes | null = null;
290
- let brotli: Bytes | null = null;
291
- try {
292
- gzip = encodeBytes(body, "gzip");
293
- } catch {
294
- gzip = null;
302
+ let gzip: Bytes | null = prebuilt.gzip ?? null;
303
+ let brotli: Bytes | null = prebuilt.brotli ?? null;
304
+ if (!gzip) {
305
+ try {
306
+ gzip = encodeBytes(body, "gzip", "cache");
307
+ } catch {
308
+ gzip = null;
309
+ }
295
310
  }
296
- try {
297
- // Quality 5 (set in encodeBytes): ~20x faster than the default 11, still
298
- // beats gzip on size. 11 would block the event loop ~17ms/500KB.
299
- brotli = encodeBytes(body, "br");
300
- } catch {
301
- brotli = null;
311
+ if (!brotli) {
312
+ try {
313
+ brotli = encodeBytes(body, "br", "cache");
314
+ } catch {
315
+ brotli = null;
316
+ }
302
317
  }
303
318
  return { gzip, brotli };
304
319
  }
@@ -309,9 +324,6 @@ export function buildCompressedVariants(body: Bytes): {
309
324
  * the body ran ahead of the first byte. `setImmediate` yields to I/O first.
310
325
  * Workers keeps the microtask — it compresses nothing (the edge does), and a
311
326
  * timer after the response returns is not guaranteed to run there.
312
- *
313
- * On a miss the variant sent to the client is compressed twice (response +
314
- * cache). Off the critical path; pass the built variant in if it ever shows up.
315
327
  */
316
328
  export function deferCacheWrite(fn: () => void | Promise<void>): void {
317
329
  if (compressionOn) setImmediate(fn);
@@ -372,15 +384,31 @@ export function serveCached(entry: CacheEntry, req: Request): Response {
372
384
  "x-bosia-cache": "HIT",
373
385
  ...entry.extraHeaders,
374
386
  };
387
+ let body: Bytes = entry.raw;
388
+ let encoding: "br" | "gzip" | null = null;
375
389
  if (entry.brotli && accept.includes("br")) {
376
- headers["content-encoding"] = "br";
377
- return new Response(entry.brotli, { ...PRECOMPRESSED, status: entry.status, headers });
390
+ body = entry.brotli;
391
+ encoding = "br";
392
+ } else if (entry.gzip && accept.includes("gzip")) {
393
+ body = entry.gzip;
394
+ encoding = "gzip";
395
+ }
396
+ if (entry.etag) {
397
+ const suffix = encoding === "br" ? "-br" : encoding === "gzip" ? "-gz" : "";
398
+ const etag = `"${entry.etag}${suffix}"`;
399
+ headers["etag"] = etag;
400
+ // A browser or CDN re-checking a copy it already holds gets a 304 with
401
+ // no body instead of the whole page again.
402
+ if (entry.status === 200 && matchesEtag(req.headers.get("if-none-match"), etag)) {
403
+ delete headers["content-type"];
404
+ return new Response(null, { status: 304, headers });
405
+ }
378
406
  }
379
- if (entry.gzip && accept.includes("gzip")) {
380
- headers["content-encoding"] = "gzip";
381
- return new Response(entry.gzip, { ...PRECOMPRESSED, status: entry.status, headers });
407
+ if (encoding) {
408
+ headers["content-encoding"] = encoding;
409
+ return new Response(body, { ...PRECOMPRESSED, status: entry.status, headers });
382
410
  }
383
- return new Response(entry.raw, { status: entry.status, headers });
411
+ return new Response(body, { status: entry.status, headers });
384
412
  }
385
413
 
386
414
  // ─── Invalidation API ────────────────────────────────────
@@ -3,13 +3,7 @@
3
3
  import { router, scrollToHash } from "./router.svelte.ts";
4
4
  import { findMatch } from "../matcher.ts";
5
5
  import { clientRoutes } from "bosia:routes";
6
- import {
7
- consumePrefetch,
8
- prefetchCache,
9
- dataUrl,
10
- buildParentSnapshots,
11
- readDataResponse,
12
- } from "./prefetch.ts";
6
+ import { consumePrefetch, prefetchCache, dataUrl, readDataResponse } from "./prefetch.ts";
13
7
  import { appState, clearDirty } from "./appState.svelte.ts";
14
8
  import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
15
9
  import { pickErrorPage } from "../errorMatch.ts";
@@ -209,12 +203,20 @@
209
203
  }
210
204
 
211
205
  // Build `_invalidated=<bits>` — char 0 = page, char i+1 = layouts[i].
212
- // '1' = run, '0' = skip. Always sent so the server honors cached layers.
213
- // We always issue the fetch (even when all loaders skip) so page-level
214
- // metadata stays fresh on every navigation; only the loaders flagged in
215
- // the mask actually run server-side.
206
+ // '1' = send, '0' = omit (the client renders that layer from its cache;
207
+ // the server still runs layout loaders, which may be guards).
208
+ // We always issue the fetch (even when every layer is cached) so page-level
209
+ // metadata stays fresh on every navigation.
216
210
  const maskBits = (pageRun ? "1" : "0") + layoutRunFlags.map((b) => (b ? "1" : "0")).join("");
217
211
 
212
+ // An invalidate() since the last nav — tick bump or any dirty entry —
213
+ // must read past the server's response cache, not just re-run loaders.
214
+ const fresh =
215
+ invalidated ||
216
+ appState.dirty.keys.size > 0 ||
217
+ appState.dirty.urls.size > 0 ||
218
+ appState.dirty.urlMatchers.length > 0;
219
+
218
220
  // Clear dirty set now — we've baked it into the mask.
219
221
  clearDirty();
220
222
 
@@ -222,25 +224,12 @@
222
224
  // to avoid a flash of stale/empty data before the fetch completes.
223
225
  const cached = match.route.hasServerData && !invalidated ? consumePrefetch(path) : null;
224
226
  prefetchCache.clear(); // clear remaining entries on navigation — matches SvelteKit behavior
225
- // Forward cached parent data for skipped layers so downstream loaders see
226
- // real parent() data, not {}. POST only when there's something to carry —
227
- // keeps the no-skip case a cacheable/dedupable GET.
228
- // A prerendered route's data is a fixed file, so there is nothing to skip —
229
- // and on Workers the asset server answers anything but GET with 405.
227
+ // A prerendered route's data is a fixed file, so there is nothing to omit.
230
228
  const prerendered = match.route.prerender;
231
- const snapshots = prerendered ? {} : buildParentSnapshots(path, maskBits);
232
- const dataInit: RequestInit =
233
- Object.keys(snapshots).length > 0
234
- ? {
235
- method: "POST",
236
- headers: { "Content-Type": "application/json" },
237
- body: JSON.stringify({ parentSnapshots: snapshots }),
238
- }
239
- : {};
240
229
  const dataFetch = cached
241
230
  ? Promise.resolve(cached)
242
231
  : match.route.hasServerData
243
- ? fetch(dataUrl(path, prerendered ? undefined : maskBits), dataInit)
232
+ ? fetch(dataUrl(path, prerendered ? undefined : maskBits, fresh && !prerendered))
244
233
  .then(readDataResponse)
245
234
  // Only a failed request reaches here now — offline, DNS, aborted.
246
235
  // A response that arrived is read for what it says, not discarded.
@@ -8,8 +8,14 @@
8
8
  // App.svelte), mirroring the modern SvelteKit `$app/state` direction. Route
9
9
  // components should destructure `params` from `$props()`. `page.params` was
10
10
  // removed in 1.1.0.
11
+ //
12
+ // `page.route.id` is the matched route's folder path with groups kept
13
+ // (`/(private)/dashboard/[id]`), the same id hooks and loaders get. `null` when
14
+ // no route matches, e.g. on the 404 page.
11
15
 
12
16
  import { router } from "./router.svelte.ts";
17
+ import { findMatch } from "../matcher.ts";
18
+ import { clientRoutes } from "bosia:routes";
13
19
 
14
20
  class Page {
15
21
  // Real on the server too: the renderer seeds `router.currentRoute`/`.origin`
@@ -18,12 +24,21 @@ class Page {
18
24
  // render that seeded nothing at all — a bare unit test, never a served request.
19
25
  #url = $derived.by(() => new URL(router.currentRoute, router.origin || "http://localhost/"));
20
26
 
27
+ // clientRoutes carry the BASE_PATH prefix, and so does `router.currentRoute`
28
+ // (the renderer puts it back before SSR), so the pathname matches as-is.
29
+ #route = $derived.by(() => ({
30
+ id: findMatch(clientRoutes, this.#url.pathname)?.route.id ?? null,
31
+ }));
32
+
21
33
  get url() {
22
34
  return this.#url;
23
35
  }
24
36
  get pathname() {
25
37
  return this.#url.pathname;
26
38
  }
39
+ get route() {
40
+ return this.#route;
41
+ }
27
42
  }
28
43
 
29
44
  export const page = new Page();
@@ -10,8 +10,9 @@ import { base } from "./base.ts";
10
10
 
11
11
  /**
12
12
  * Build the `_invalidated` mask bits for a target path using the current
13
- * client loader cache. Char 0 = page, char i+1 = layout depth i; '1' = run,
14
- * '0' = skip. Returns `null` when the route cannot be matched.
13
+ * client loader cache. Char 0 = page, char i+1 = layout depth i; '1' = send,
14
+ * '0' = the client already holds it (the server still runs layout loaders,
15
+ * since they may be guards). Returns `null` when the route cannot be matched.
15
16
  */
16
17
  export function buildMaskBits(path: string): string | null {
17
18
  const url = new URL(path, window.location.origin);
@@ -42,37 +43,10 @@ export function buildMaskBits(path: string): string | null {
42
43
  }
43
44
 
44
45
  /**
45
- * Build `parentSnapshots` (layout depth → cached data) for a target path from
46
- * the current loader cache, given the mask bits from `buildMaskBits`. For each
47
- * layout depth whose mask bit is '0' (skipped) and whose cached entry exists,
48
- * forward that layer's data so server-side downstream loaders see real
49
- * `parent()` data instead of `{}`. Returns `{}` when nothing to carry.
50
- *
51
- * Client-supplied perf hint only — the server never trusts it for authz.
46
+ * Builds the `/__bosia/data/…` URL for a given client path. `fresh` marks a
47
+ * fetch that follows an `invalidate()`, so the server skips its cached copy.
52
48
  */
53
- export function buildParentSnapshots(
54
- path: string,
55
- maskBits: string,
56
- ): Record<number, Record<string, any>> {
57
- const snapshots: Record<number, Record<string, any>> = {};
58
- const url = new URL(path, window.location.origin);
59
- const match = findMatch(clientRoutes, url.pathname);
60
- if (!match) return snapshots;
61
- const layoutIds = (match.route as any).layoutIds as (string | null)[];
62
-
63
- layoutIds.forEach((id, depth) => {
64
- // maskBits char 0 = page, char depth+1 = layout depth. '0' = skipped.
65
- if (maskBits[depth + 1] !== "0") return;
66
- if (id === null) return;
67
- const entry = appState.loaderCache.layouts[id];
68
- if (entry) snapshots[depth] = entry.data;
69
- });
70
-
71
- return snapshots;
72
- }
73
-
74
- /** Builds the `/__bosia/data/…` URL for a given client path. */
75
- export function dataUrl(path: string, invalidatedBits?: string): string {
49
+ export function dataUrl(path: string, invalidatedBits?: string, fresh = false): string {
76
50
  const url = new URL(path, window.location.origin);
77
51
  let p = url.pathname.replace(/\/$/, "");
78
52
  let qs = url.search;
@@ -80,6 +54,7 @@ export function dataUrl(path: string, invalidatedBits?: string): string {
80
54
  const sep = qs ? "&" : "?";
81
55
  qs = `${qs}${sep}_invalidated=${invalidatedBits}`;
82
56
  }
57
+ if (fresh) qs = `${qs}${qs ? "&" : "?"}_fresh=1`;
83
58
  // The one place a path is still taken apart, and it is URL construction rather
84
59
  // than navigation: the data endpoint is `<base>/__bosia/data` + the *app* path,
85
60
  // so the mount prefix moves from the front of the route to the front of the
@@ -151,35 +126,39 @@ export function consumePrefetch(path: string): any | null {
151
126
 
152
127
  /** Prefetches data for a path and stores in cache. No-op if already cached/in-flight. */
153
128
  export async function prefetchPath(path: string): Promise<void> {
129
+ // Nothing to fetch for the page we're on. A click also focuses its link, so
130
+ // the focusin trigger fires ~100ms after the navigation it belongs to.
131
+ if (path === window.location.pathname + window.location.search) return;
154
132
  // Warm the route's +loading.svelte chunk alongside its data, so the skeleton
155
133
  // paints instantly on click instead of cold-importing after the old page lingers.
156
134
  const warmMatch = findMatch(clientRoutes, new URL(path, window.location.origin).pathname);
157
- (warmMatch?.route as { loading?: (() => Promise<unknown>) | null } | undefined)?.loading?.();
135
+ // Also warm the page and layout chunks: a route not visited yet otherwise
136
+ // downloads its code only after the click. Errors are swallowed — a stale
137
+ // chunk is the click's to report, not the hover's (hydrate.ts reloads on it).
138
+ if (warmMatch) {
139
+ const route = warmMatch.route as typeof warmMatch.route & {
140
+ loading?: (() => Promise<unknown>) | null;
141
+ };
142
+ for (const load of [route.loading, route.page, ...route.layouts]) {
143
+ load?.().catch(() => {});
144
+ }
145
+ }
158
146
 
159
147
  const existing = prefetchCache.get(path);
160
148
  if (existing && Date.now() - existing.ts <= 30_000) return;
161
149
  if (existing) prefetchCache.delete(path);
162
150
  if (pending.has(path)) return;
163
151
 
152
+ // A route without server data never fetches on click either.
153
+ if (!warmMatch?.route.hasServerData) return;
154
+
164
155
  pending.add(path);
165
156
  try {
166
- // Send the same mask as a real client nav would so the server can skip
167
- // loaders whose tracked inputs haven't changed. Falls back to running
168
- // everything when the route can't be matched (e.g. external/unknown URL).
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).
169
160
  const maskBits = buildMaskBits(path) ?? undefined;
170
- // Forward cached parent data for skipped layers so a prefetched response
171
- // is computed with real parent() data, not {}. POST only when there's
172
- // something to carry — keeps the no-skip case a cacheable/dedupable GET.
173
- const snapshots = maskBits ? buildParentSnapshots(path, maskBits) : {};
174
- const init: RequestInit =
175
- Object.keys(snapshots).length > 0
176
- ? {
177
- method: "POST",
178
- headers: { "Content-Type": "application/json" },
179
- body: JSON.stringify({ parentSnapshots: snapshots }),
180
- }
181
- : {};
182
- const res = await fetch(dataUrl(path, maskBits), init);
161
+ const res = await fetch(dataUrl(path, maskBits));
183
162
  // `ok` alone would cache a guard's login page (200 after the redirect was
184
163
  // followed) as if it were this route's data.
185
164
  if (res.ok && !res.redirected && isJsonResponse(res)) {
@@ -228,23 +207,39 @@ function observeViewportLinks(container: Element | Document = document) {
228
207
  return observer;
229
208
  }
230
209
 
210
+ /** The link a hover/touch/focus event is on, when it sits under `data-bosia-preload="hover"`. */
211
+ function hoverLinkHref(target: EventTarget | null): string | null {
212
+ if (!(target instanceof Element)) return null;
213
+ // Early exit: skip if no [data-bosia-preload="hover"] ancestor exists
214
+ const preloadEl = target.closest("[data-bosia-preload]");
215
+ if (!preloadEl || preloadEl.getAttribute("data-bosia-preload") !== "hover") return null;
216
+ const anchor = target.closest("a") as HTMLAnchorElement | null;
217
+ return anchor ? getLinkHref(anchor) : null;
218
+ }
219
+
231
220
  export function initPrefetch(): void {
232
- // ── Hover strategy (event delegation, 20ms debounce) ─────
221
+ // ── Hover strategy (event delegation, 100ms debounce) ────
233
222
  let hoverTimer: ReturnType<typeof setTimeout> | null = null;
234
223
 
235
- document.addEventListener("mouseover", (e) => {
236
- if (!(e.target instanceof Element)) return;
237
- // Early exit: skip if no [data-bosia-preload="hover"] ancestor exists
238
- const preloadEl = e.target.closest("[data-bosia-preload]");
239
- if (!preloadEl || preloadEl.getAttribute("data-bosia-preload") !== "hover") return;
240
- const anchor = e.target.closest("a") as HTMLAnchorElement | null;
241
- if (!anchor) return;
242
- const href = getLinkHref(anchor);
224
+ const onIntent = (e: Event) => {
225
+ const href = hoverLinkHref(e.target);
243
226
  if (!href) return;
244
-
245
227
  if (hoverTimer) clearTimeout(hoverTimer);
246
228
  hoverTimer = setTimeout(() => prefetchPath(href), 100);
247
- });
229
+ };
230
+ document.addEventListener("mouseover", onIntent);
231
+ // Keyboard users tab onto a link before pressing Enter — same signal.
232
+ document.addEventListener("focusin", onIntent);
233
+ // Touch screens have no hover; a touch is followed by the click within
234
+ // ~100ms or not at all, so start right away instead of debouncing.
235
+ document.addEventListener(
236
+ "touchstart",
237
+ (e) => {
238
+ const href = hoverLinkHref(e.target);
239
+ if (href) prefetchPath(href);
240
+ },
241
+ { passive: true },
242
+ );
248
243
 
249
244
  document.addEventListener("mouseout", () => {
250
245
  if (hoverTimer) {
package/src/core/dev.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
- import { readdirSync, statSync, watch, type Dirent } from "fs";
2
+ import { readdirSync, rmSync, statSync, watch, type Dirent } from "fs";
3
3
  import { join } from "path";
4
4
  import { loadEnv } from "./env.ts";
5
5
  import { BOSIA_NODE_PATH } from "./paths.ts";
@@ -12,6 +12,12 @@ import { affectsRouteManifest, shouldIgnoreForRebuild } from "./devWatch.ts";
12
12
  // but dev.ts itself never reads it.
13
13
  const DEV_OUT_DIR = ".bosia/dev";
14
14
 
15
+ // Compiled-component cache shared by every rebuild in this session (see
16
+ // svelteCompiler.ts). Outside DEV_OUT_DIR, which each build clears. Emptied
17
+ // here, once per `bosia dev`, so it only ever holds this session's entries.
18
+ const SVELTE_CACHE_DIR = ".bosia/cache/svelte";
19
+ rmSync(SVELTE_CACHE_DIR, { recursive: true, force: true });
20
+
15
21
  // Snapshot pure shell env BEFORE any loadEnv call pollutes process.env.
16
22
  // On `.env*` change we restore from this snapshot, then re-run loadEnv,
17
23
  // so removed/renamed keys no longer linger in the dev process.
@@ -142,6 +148,10 @@ async function runBuild(): Promise<boolean> {
142
148
  ...process.env,
143
149
  BOSIA_OUT_DIR: DEV_OUT_DIR,
144
150
  BOSIA_SKIP_ROUTE_SCAN: skipScan ? "1" : "0",
151
+ // The dev server never serves prerendered pages or dist/static, so the
152
+ // build skips producing them (~25% of a rebuild on the demo).
153
+ BOSIA_DEV_BUILD: "1",
154
+ BOSIA_SVELTE_CACHE_DIR: SVELTE_CACHE_DIR,
145
155
  },
146
156
  });
147
157
  return (await proc.exited) === 0;
package/src/core/env.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
 
4
4
  // ─── Framework-reserved vars ─────────────────────────────
5
5
  // These are controlled by Bosia itself — users access them via process.env directly.
6
+ // `test/env.test.ts` scans `src/` and fails when Bosia reads a name missing here.
6
7
  const FRAMEWORK_VARS = new Set([
7
8
  "PORT",
8
9
  "NODE_ENV",
@@ -20,8 +21,24 @@ const FRAMEWORK_VARS = new Set([
20
21
  "LOAD_TIMEOUT",
21
22
  "METADATA_TIMEOUT",
22
23
  "PRERENDER_TIMEOUT",
24
+ "PRERENDER_CONCURRENCY",
25
+ "IDLE_TIMEOUT",
26
+ "MAX_INFLIGHT",
27
+ "TRUST_PROXY",
28
+ "CSP_DIRECTIVES",
29
+ "DISABLE_X_FRAME_OPTIONS",
30
+ "CACHE_KEYS",
31
+ "CACHE_MAX_ENTRIES",
32
+ "CACHE_MAX_BODY_BYTES",
33
+ "ASSET_CACHE_MAX_BYTES",
34
+ "ASSET_CACHE_MAX_FILE_BYTES",
23
35
  ]);
24
36
 
37
+ /** Framework-owned: a listed name, or anything under the reserved `BOSIA_` prefix. */
38
+ export function isFrameworkVar(name: string): boolean {
39
+ return FRAMEWORK_VARS.has(name) || name.startsWith("BOSIA_");
40
+ }
41
+
25
42
  // ─── .env File Parser ────────────────────────────────────
26
43
 
27
44
  /** Valid JS/TS identifier: starts with letter/underscore, then alphanumeric/underscore. */
@@ -173,7 +190,7 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
173
190
  // Return only non-framework vars
174
191
  const result: Record<string, string> = {};
175
192
  for (const key of declaredNames) {
176
- if (!FRAMEWORK_VARS.has(key)) {
193
+ if (!isFrameworkVar(key)) {
177
194
  result[key] = process.env[key] ?? merged[key] ?? "";
178
195
  }
179
196
  }
@@ -0,0 +1,13 @@
1
+ // ─── ETag matching ───────────────────────────────────────
2
+ // Shared by static files (staticManifest.ts) and cached responses (cache.ts).
3
+ // No runtime imports: cache.ts also runs on Cloudflare Workers.
4
+
5
+ /** True when an `If-None-Match` header names `etag` (or `*`). Weak tags match too. */
6
+ export function matchesEtag(header: string | null, etag: string): boolean {
7
+ if (!header) return false;
8
+ for (const raw of header.split(",")) {
9
+ const t = raw.trim();
10
+ if (t === "*" || t === etag || (t.startsWith("W/") && t.slice(2) === etag)) return true;
11
+ }
12
+ return false;
13
+ }
package/src/core/hooks.ts CHANGED
@@ -58,6 +58,19 @@ export type RequestEvent = {
58
58
  */
59
59
  locals: Record<string, any> & { nonce?: string };
60
60
  params: Record<string, string>;
61
+ /**
62
+ * The matched route. `id` is its folder under `src/routes` with groups kept,
63
+ * e.g. `"/(private)/dashboard/[id]"`; `null` when nothing matched. Set before
64
+ * `handle` runs, for page, client-navigation data, form and API requests
65
+ * alike, so one check guards a whole group:
66
+ *
67
+ * ```ts
68
+ * if (event.route.id?.startsWith("/(private)") && !event.locals.user) {
69
+ * throw redirect(303, "/login");
70
+ * }
71
+ * ```
72
+ */
73
+ route: { id: string | null };
61
74
  cookies: Cookies;
62
75
  /**
63
76
  * True when the client router is fetching this page's loader data for a
@@ -79,6 +92,8 @@ export type RequestEvent = {
79
92
  export type LoadEvent = {
80
93
  url: URL;
81
94
  params: Record<string, string>;
95
+ /** The route being rendered; see `RequestEvent.route`. */
96
+ route: { id: string };
82
97
  locals: Record<string, any>;
83
98
  cookies: Cookies;
84
99
  fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -169,6 +184,8 @@ export type Handle = (input: {
169
184
 
170
185
  export type MetadataEvent = {
171
186
  params: Record<string, string>;
187
+ /** The route being rendered; see `RequestEvent.route`. */
188
+ route: { id: string };
172
189
  url: URL;
173
190
  locals: Record<string, any>;
174
191
  cookies: Cookies;