bosia 1.1.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
package/src/ambient.d.ts CHANGED
@@ -7,6 +7,7 @@ declare module "bosia:routes" {
7
7
 
8
8
  export const clientRoutes: Array<{
9
9
  pattern: string;
10
+ id: string;
10
11
  page: Loader;
11
12
  layouts: Loader[];
12
13
  hasServerData: boolean;
@@ -16,6 +17,7 @@ declare module "bosia:routes" {
16
17
 
17
18
  export const serverRoutes: Array<{
18
19
  pattern: string;
20
+ id: string;
19
21
  pageModule: Loader;
20
22
  layoutModules: Loader[];
21
23
  pageServer: Loader | null;
@@ -25,6 +27,7 @@ declare module "bosia:routes" {
25
27
 
26
28
  export const apiRoutes: Array<{
27
29
  pattern: string;
30
+ id: string;
28
31
  module: Loader;
29
32
  }>;
30
33
 
package/src/cli/add.ts CHANGED
@@ -2,12 +2,13 @@ import { join, dirname } from "path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
3
  import * as p from "@clack/prompts";
4
4
  import {
5
+ checkRegistryFiles,
6
+ containedPath,
5
7
  type InstallOptions,
6
8
  REGISTRY_URL,
7
9
  resolveLocalRegistryOrExit,
8
10
  readRegistryJSON,
9
- readRegistryFile,
10
- writeRegistryFile,
11
+ installRegistryFiles,
11
12
  mergePkgJson,
12
13
  bunAdd,
13
14
  } from "./registry.ts";
@@ -128,13 +129,16 @@ export async function addComponent(name: string, root = false, options?: Install
128
129
  "meta.json",
129
130
  );
130
131
 
132
+ // Check this component's own paths before installing anything for it.
133
+ const destDir = containedPath(join(cwd, "src", "lib", "components"), fullPath);
134
+ checkRegistryFiles(registryRoot, "components", fullPath, meta.files, destDir);
135
+
131
136
  // Install component dependencies first (recursive)
132
137
  for (const dep of meta.dependencies) {
133
138
  await addComponent(dep, false, options);
134
139
  }
135
140
 
136
141
  // Check if component already exists (skip check entirely in non-interactive mode)
137
- const destDir = join(cwd, "src", "lib", "components", fullPath);
138
142
  if (!options?.skipPrompts && existsSync(destDir)) {
139
143
  const replace = await p.confirm({
140
144
  message: `Component "${name}" already exists at src/lib/components/${fullPath}/. Replace it?`,
@@ -146,15 +150,14 @@ export async function addComponent(name: string, root = false, options?: Install
146
150
  }
147
151
 
148
152
  // Download/copy component files into src/lib/components/<fullPath>/
149
- mkdirSync(destDir, { recursive: true });
150
-
151
- for (const file of meta.files) {
152
- const content = await readRegistryFile(registryRoot, "components", fullPath, file);
153
- const dest = join(destDir, file);
154
- if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
155
- writeRegistryFile(dest, content);
156
- console.log(` ✍️ src/lib/components/${fullPath}/${file}`);
157
- }
153
+ await installRegistryFiles(
154
+ registryRoot,
155
+ "components",
156
+ fullPath,
157
+ meta.files,
158
+ destDir,
159
+ `src/lib/components/${fullPath}`,
160
+ );
158
161
 
159
162
  // Install npm dependencies
160
163
  if (Object.keys(meta.npmDeps).length > 0) {
package/src/cli/block.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { join, dirname } from "path";
2
- import { mkdirSync, existsSync } from "fs";
1
+ import { join } from "path";
2
+ import { existsSync } from "fs";
3
3
  import * as p from "@clack/prompts";
4
4
  import {
5
+ checkRegistryFiles,
6
+ containedPath,
5
7
  type InstallOptions,
6
8
  resolveLocalRegistryOrExit,
7
9
  readRegistryJSON,
8
- readRegistryFile,
9
- writeRegistryFile,
10
+ installRegistryFiles,
10
11
  mergePkgJson,
11
12
  bunAdd,
12
13
  } from "./registry.ts";
@@ -69,6 +70,11 @@ export async function runAddBlock(
69
70
 
70
71
  const meta = await readRegistryJSON<BlockMeta>(registryRoot, "blocks", name, "meta.json");
71
72
 
73
+ // Check this item's own paths before installing anything for it.
74
+ const cwd = resolvedOptions.cwd ?? process.cwd();
75
+ const destDir = containedPath(join(cwd, "src", "lib", "blocks"), name);
76
+ checkRegistryFiles(registryRoot, "blocks", name, meta.files, destDir);
77
+
72
78
  // 1. Install primitive dependencies first.
73
79
  // Component deps (e.g. "ui/button") go through addComponent.
74
80
  // Block deps (e.g. "blocks/files/upload-area") recurse into runAddBlock.
@@ -81,9 +87,6 @@ export async function runAddBlock(
81
87
  }
82
88
 
83
89
  // 2. Copy block files to src/lib/blocks/<path>/
84
- const cwd = resolvedOptions.cwd ?? process.cwd();
85
- const destDir = join(cwd, "src", "lib", "blocks", name);
86
-
87
90
  if (!resolvedOptions.skipPrompts && existsSync(destDir)) {
88
91
  const replace = await p.confirm({
89
92
  message: `Block "${name}" already exists at src/lib/blocks/${name}/. Replace it?`,
@@ -94,15 +97,14 @@ export async function runAddBlock(
94
97
  }
95
98
  }
96
99
 
97
- mkdirSync(destDir, { recursive: true });
98
-
99
- for (const file of meta.files) {
100
- const content = await readRegistryFile(registryRoot, "blocks", name, file);
101
- const dest = join(destDir, file);
102
- if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
103
- writeRegistryFile(dest, content);
104
- console.log(` ✍️ src/lib/blocks/${name}/${file}`);
105
- }
100
+ await installRegistryFiles(
101
+ registryRoot,
102
+ "blocks",
103
+ name,
104
+ meta.files,
105
+ destDir,
106
+ `src/lib/blocks/${name}`,
107
+ );
106
108
 
107
109
  // 3. Merge font @imports into app.css (idempotent)
108
110
  if (meta.fonts && Object.keys(meta.fonts).length > 0) {
package/src/cli/feat.ts CHANGED
@@ -4,6 +4,8 @@ import * as p from "@clack/prompts";
4
4
  import { addComponent, initAddRegistry } from "./add.ts";
5
5
  import { runAddBlock } from "./block.ts";
6
6
  import {
7
+ containedPath,
8
+ registrySource,
7
9
  type InstallOptions,
8
10
  resolveLocalRegistryOrExit,
9
11
  readRegistryJSON,
@@ -246,6 +248,13 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
246
248
  featureArgs: undefined, // already consumed by the root feature
247
249
  };
248
250
 
251
+ // Check every target and source path before installing any dependency, so a
252
+ // bad path in this feature stops the install before anything is written.
253
+ for (const entry of meta.files) {
254
+ containedPath(cwd, entry.target);
255
+ registrySource(registryRoot, "features", name, entry.src);
256
+ }
257
+
249
258
  // Install required feature dependencies first (recursive)
250
259
  if (meta.features && meta.features.length > 0) {
251
260
  for (const feat of meta.features) {
@@ -271,19 +280,27 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
271
280
  console.log("");
272
281
  }
273
282
 
274
- // Apply each file entry per its strategy. Skip entries whose `when` clause doesn't match.
283
+ // Skip entries whose `when` clause doesn't match. Read every source (in
284
+ // parallel) before writing anything, so a failed download leaves this
285
+ // feature's files untouched.
286
+ const entries = meta.files.filter((e) => !e.when || whenMatches(e.when, myOptions));
287
+ const contents = await Promise.all(
288
+ entries.map((e) => readRegistryFile(registryRoot, "features", name, e.src)),
289
+ );
290
+ const planned: { entry: FileEntry; dest: string; content: string }[] = entries.map(
291
+ (entry, i) => ({ entry, dest: containedPath(cwd, entry.target), content: contents[i]! }),
292
+ );
293
+
294
+ // Apply each file entry per its strategy.
275
295
  const createdDirs = new Set<string>();
276
296
  const recordedFiles: { target: string; strategy: string; marker?: string }[] = [];
277
- for (const entry of meta.files) {
278
- if (entry.when && !whenMatches(entry.when, myOptions)) continue;
279
- const dest = join(cwd, entry.target);
297
+ for (const { entry, dest, content } of planned) {
280
298
  const strategy: FileStrategy = entry.strategy ?? "write";
281
299
  const dir = dirname(dest);
282
300
  if (!createdDirs.has(dir)) {
283
301
  mkdirSync(dir, { recursive: true });
284
302
  createdDirs.add(dir);
285
303
  }
286
- const content = await readRegistryFile(registryRoot, "features", name, entry.src);
287
304
  await applyStrategy({
288
305
  dest,
289
306
  target: entry.target,
@@ -331,12 +348,7 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
331
348
  if (envEntries.length > 0) {
332
349
  const envPath = join(cwd, ".env");
333
350
  const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
334
- const toAdd: string[] = [];
335
- for (const [key, val] of envEntries) {
336
- if (!existing.includes(`${key}=`)) {
337
- toAdd.push(`${key}=${val}`);
338
- }
339
- }
351
+ const toAdd = missingEnvLines(existing, envEntries);
340
352
  if (toAdd.length > 0) {
341
353
  const nl = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
342
354
  writeFileSync(envPath, existing + nl + toAdd.join("\n") + "\n", "utf-8");
@@ -496,6 +508,14 @@ function blockDelim(ext: string): { start: string; end: string } {
496
508
  return { start: "//", end: "" };
497
509
  }
498
510
 
511
+ /** `KEY=value` lines for the entries `.env` doesn't set yet. A commented-out
512
+ * `# KEY=` (the default template documents CACHE_KEYS that way) doesn't count. */
513
+ export function missingEnvLines(existing: string, entries: [string, string][]): string[] {
514
+ return entries
515
+ .filter(([key]) => !new RegExp(`^\\s*${escapeRegex(key)}=`, "m").test(existing))
516
+ .map(([key, val]) => `${key}=${val}`);
517
+ }
518
+
499
519
  function escapeRegex(s: string): string {
500
520
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
501
521
  }
package/src/cli/page.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { join, dirname } from "path";
2
- import { mkdirSync, existsSync } from "fs";
1
+ import { join } from "path";
2
+ import { existsSync } from "fs";
3
3
  import * as p from "@clack/prompts";
4
4
  import {
5
+ checkRegistryFiles,
6
+ containedPath,
5
7
  type InstallOptions,
6
8
  resolveLocalRegistryOrExit,
7
9
  readRegistryJSON,
8
- readRegistryFile,
9
- writeRegistryFile,
10
+ installRegistryFiles,
10
11
  mergePkgJson,
11
12
  bunAdd,
12
13
  } from "./registry.ts";
@@ -69,6 +70,11 @@ export async function runAddPage(
69
70
 
70
71
  const meta = await readRegistryJSON<PageMeta>(registryRoot, "pages", name, "meta.json");
71
72
 
73
+ // Check this item's own paths before installing anything for it.
74
+ const cwd = resolvedOptions.cwd ?? process.cwd();
75
+ const destDir = containedPath(join(cwd, "src", "lib", "pages"), name);
76
+ checkRegistryFiles(registryRoot, "pages", name, meta.files, destDir);
77
+
72
78
  // 1. Install dependencies first.
73
79
  // Block deps (e.g. "blocks/storefront/header") recurse into runAddBlock.
74
80
  // Component deps (e.g. "ui/button") go through addComponent.
@@ -81,9 +87,6 @@ export async function runAddPage(
81
87
  }
82
88
 
83
89
  // 2. Copy page files to src/lib/pages/<path>/
84
- const cwd = resolvedOptions.cwd ?? process.cwd();
85
- const destDir = join(cwd, "src", "lib", "pages", name);
86
-
87
90
  if (!resolvedOptions.skipPrompts && existsSync(destDir)) {
88
91
  const replace = await p.confirm({
89
92
  message: `Page "${name}" already exists at src/lib/pages/${name}/. Replace it?`,
@@ -94,15 +97,14 @@ export async function runAddPage(
94
97
  }
95
98
  }
96
99
 
97
- mkdirSync(destDir, { recursive: true });
98
-
99
- for (const file of meta.files) {
100
- const content = await readRegistryFile(registryRoot, "pages", name, file);
101
- const dest = join(destDir, file);
102
- if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
103
- writeRegistryFile(dest, content);
104
- console.log(` ✍️ src/lib/pages/${name}/${file}`);
105
- }
100
+ await installRegistryFiles(
101
+ registryRoot,
102
+ "pages",
103
+ name,
104
+ meta.files,
105
+ destDir,
106
+ `src/lib/pages/${name}`,
107
+ );
106
108
 
107
109
  // 3. Merge font @imports into app.css (idempotent)
108
110
  if (meta.fonts && Object.keys(meta.fonts).length > 0) {
@@ -1,7 +1,9 @@
1
- import { join, dirname } from "path";
2
- import { writeFileSync, readFileSync, existsSync, unlinkSync } from "fs";
1
+ import { join, dirname, resolve as resolvePath } from "path";
2
+ import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from "fs";
3
3
  import { spawn } from "bun";
4
4
 
5
+ import { safePath } from "../core/safePath.ts";
6
+
5
7
  // ─── Shared registry utilities for feat.ts and add.ts ─────
6
8
 
7
9
  export const REGISTRY_URL = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
@@ -42,8 +44,61 @@ export function resolveLocalRegistryOrExit(): string {
42
44
  }
43
45
  }
44
46
 
47
+ // ─── Path containment ─────────────────────────────────────
48
+
49
+ /**
50
+ * Resolve `rel` (a name or path taken from registry metadata or the command
51
+ * line) under `base`, and throw if it lands outside it. Registry `meta.json`
52
+ * decides where files go, so a `../` in it must not write over `~/.bashrc`.
53
+ *
54
+ * Backslashes are refused outright: on Linux and macOS `..\x` is one plain
55
+ * filename and would pass the check, but `fetch()` reads `\` as `/` when the
56
+ * same string goes into a registry URL. No registry entry uses one.
57
+ */
58
+ export function containedPath(base: string, rel: string): string {
59
+ if (rel.includes("\\")) {
60
+ throw new Error(`Refusing to use "${rel}": registry paths use "/", not "\\"`);
61
+ }
62
+ const full = safePath(base, rel);
63
+ if (!full || full === resolvePath(base)) {
64
+ throw new Error(`Refusing to use "${rel}": it resolves outside ${base}`);
65
+ }
66
+ return full;
67
+ }
68
+
45
69
  // ─── Registry file readers ────────────────────────────────
46
70
 
71
+ /**
72
+ * Where a registry file comes from: a path under the local registry's category
73
+ * folder, or a URL under the remote one. Throws when `name`/`file` would leave
74
+ * that folder.
75
+ *
76
+ * The remote case is checked as a URL, because a URL is what gets fetched. The
77
+ * URL parser reads `%2e%2e` as `..` (a path check sees a plain folder name), and
78
+ * `?` or `#` would cut the path short, so all three are refused, and the parsed
79
+ * pathname must still sit under the category.
80
+ */
81
+ export function registrySource(
82
+ registryRoot: string | null,
83
+ category: string,
84
+ name: string,
85
+ file: string,
86
+ ): string {
87
+ const rel = `${name}/${file}`;
88
+ if (registryRoot) return containedPath(join(registryRoot, category), rel);
89
+
90
+ if (/[%?#]/.test(rel)) {
91
+ throw new Error(`Refusing to use "${rel}": registry paths can't contain "%", "?" or "#"`);
92
+ }
93
+ containedPath(category, rel);
94
+ const base = new URL(`${REGISTRY_URL}/${category}/`);
95
+ const url = new URL(rel, base);
96
+ if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
97
+ throw new Error(`Refusing to use "${rel}": it resolves outside ${base.href}`);
98
+ }
99
+ return url.href;
100
+ }
101
+
47
102
  /** Read and parse a JSON file from the registry (local or remote). */
48
103
  export async function readRegistryJSON<T>(
49
104
  registryRoot: string | null,
@@ -51,16 +106,12 @@ export async function readRegistryJSON<T>(
51
106
  name: string,
52
107
  file: string,
53
108
  ): Promise<T> {
54
- if (registryRoot) {
55
- const path = join(registryRoot, category, name, file);
56
- if (!existsSync(path)) {
57
- throw new Error(
58
- `"${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`,
59
- );
60
- }
61
- return JSON.parse(readFileSync(path, "utf-8"));
109
+ const source = registrySource(registryRoot, category, name, file);
110
+ if (!registryRoot) return fetchJSON<T>(source);
111
+ if (!existsSync(source)) {
112
+ throw new Error(`"${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`);
62
113
  }
63
- return fetchJSON<T>(`${REGISTRY_URL}/${category}/${name}/${file}`);
114
+ return JSON.parse(readFileSync(source, "utf-8"));
64
115
  }
65
116
 
66
117
  /** Read a text file from the registry (local or remote). */
@@ -70,16 +121,62 @@ export async function readRegistryFile(
70
121
  name: string,
71
122
  file: string,
72
123
  ): Promise<string> {
73
- if (registryRoot) {
74
- const path = join(registryRoot, category, name, file);
75
- if (!existsSync(path)) {
76
- throw new Error(
77
- `File "${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`,
78
- );
79
- }
80
- return readFileSync(path, "utf-8");
124
+ const source = registrySource(registryRoot, category, name, file);
125
+ if (!registryRoot) return fetchText(source);
126
+ if (!existsSync(source)) {
127
+ throw new Error(
128
+ `File "${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`,
129
+ );
81
130
  }
82
- return fetchText(`${REGISTRY_URL}/${category}/${name}/${file}`);
131
+ return readFileSync(source, "utf-8");
132
+ }
133
+
134
+ // ─── Registry file installer ──────────────────────────────
135
+
136
+ /**
137
+ * Check every source and destination path of a registry item without reading
138
+ * or writing anything. Installers call this right after reading `meta.json`,
139
+ * before installing any dependency, so a bad path stops the install before
140
+ * anything is written. Returns the checked destinations, in `files` order.
141
+ */
142
+ export function checkRegistryFiles(
143
+ registryRoot: string | null,
144
+ category: string,
145
+ name: string,
146
+ files: string[],
147
+ destDir: string,
148
+ ): string[] {
149
+ return files.map((file) => {
150
+ registrySource(registryRoot, category, name, file);
151
+ return containedPath(destDir, file);
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Copy a registry item's `files` into `destDir`. Every path is checked and
157
+ * every file read (in parallel) before the first write, so a rejected path or a
158
+ * failed download leaves nothing half-installed. `label` is the project-relative
159
+ * `destDir` used in the log lines.
160
+ */
161
+ export async function installRegistryFiles(
162
+ registryRoot: string | null,
163
+ category: string,
164
+ name: string,
165
+ files: string[],
166
+ destDir: string,
167
+ label: string,
168
+ ): Promise<void> {
169
+ const dests = checkRegistryFiles(registryRoot, category, name, files, destDir);
170
+ const contents = await Promise.all(
171
+ files.map((file) => readRegistryFile(registryRoot, category, name, file)),
172
+ );
173
+
174
+ mkdirSync(destDir, { recursive: true });
175
+ files.forEach((file, i) => {
176
+ if (file.includes("/")) mkdirSync(dirname(dests[i]!), { recursive: true });
177
+ writeRegistryFile(dests[i]!, contents[i]!);
178
+ console.log(` ✍️ ${label}/${file}`);
179
+ });
83
180
  }
84
181
 
85
182
  // ─── Registry file writer ─────────────────────────────────
package/src/cli/theme.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { join } from "path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
- import { resolveLocalRegistryOrExit, readRegistryJSON, readRegistryFile } from "./registry.ts";
3
+ import {
4
+ containedPath,
5
+ resolveLocalRegistryOrExit,
6
+ readRegistryJSON,
7
+ readRegistryFile,
8
+ } from "./registry.ts";
4
9
  import { mergeFontImports } from "./fonts.ts";
5
10
 
6
11
  // ─── bun x bosia@latest add theme <name> ─────────────────
@@ -46,7 +51,7 @@ export async function runAddTheme(name: string | undefined, flags: string[] = []
46
51
  // Convention: first file is the tokens file, written as <name>.css.
47
52
  const tokensFile = meta.files[0] ?? "tokens.css";
48
53
  const content = await readRegistryFile(registryRoot, "themes", name, tokensFile);
49
- const tokensDest = join(themesDir, `${name}.css`);
54
+ const tokensDest = containedPath(themesDir, `${name}.css`);
50
55
  writeFileSync(tokensDest, content, "utf-8");
51
56
  console.log(` ✍️ src/lib/themes/${name}.css`);
52
57
 
@@ -17,6 +17,8 @@ export interface HandlerContext {
17
17
  */
18
18
  body: unknown;
19
19
  query: Record<string, string>;
20
+ /** `request.url`, parsed once for the whole dispatch. */
21
+ url: URL;
20
22
  set: ResponseSet;
21
23
  }
22
24
 
@@ -153,10 +155,15 @@ export class BosiaApp {
153
155
  const route = this.match(method, url.pathname);
154
156
  let response: unknown;
155
157
  if (route) {
158
+ let query: Record<string, string> | undefined;
156
159
  response = await route.handler({
157
160
  request,
158
161
  body: BODYLESS.has(method) || route.path === "*" ? undefined : await parseBody(request),
159
- query: Object.fromEntries(url.searchParams),
162
+ // Built on first read: the framework's "*" route never reads it.
163
+ get query() {
164
+ return (query ??= Object.fromEntries(url.searchParams));
165
+ },
166
+ url,
160
167
  set,
161
168
  });
162
169
  } else {
package/src/core/build.ts CHANGED
@@ -393,11 +393,16 @@ writeAppHtmlSegments(appHtml);
393
393
  // so they resolve against the app's node_modules at runtime.
394
394
  await bundleRuntimeUserFiles(process.cwd());
395
395
 
396
+ // `bosia dev` builds for its own server only, which serves neither prerendered
397
+ // pages nor dist/static (server.ts reads both from disk in prod only). Skipping
398
+ // them saves booting a second server to crawl routes on every rebuild.
399
+ const devBuild = process.env.BOSIA_DEV_BUILD === "1";
400
+
396
401
  // 9. Prerender static routes
397
- await prerenderStaticRoutes(manifest);
402
+ if (!devBuild) await prerenderStaticRoutes(manifest);
398
403
 
399
404
  // 10. Generate static site output (HTML + client assets + public → dist/static/)
400
- generateStaticSite();
405
+ if (!devBuild) generateStaticSite();
401
406
 
402
407
  // 10b. Precompress client assets + prerendered HTML (.br/.gz siblings) for the
403
408
  // Bun server. Runs after the static mirror so dist/static — the Workers upload
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);
@@ -378,15 +384,31 @@ export function serveCached(entry: CacheEntry, req: Request): Response {
378
384
  "x-bosia-cache": "HIT",
379
385
  ...entry.extraHeaders,
380
386
  };
387
+ let body: Bytes = entry.raw;
388
+ let encoding: "br" | "gzip" | null = null;
381
389
  if (entry.brotli && accept.includes("br")) {
382
- headers["content-encoding"] = "br";
383
- 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";
384
395
  }
385
- if (entry.gzip && accept.includes("gzip")) {
386
- headers["content-encoding"] = "gzip";
387
- return new Response(entry.gzip, { ...PRECOMPRESSED, status: entry.status, headers });
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
+ }
406
+ }
407
+ if (encoding) {
408
+ headers["content-encoding"] = encoding;
409
+ return new Response(body, { ...PRECOMPRESSED, status: entry.status, headers });
388
410
  }
389
- return new Response(entry.raw, { status: entry.status, headers });
411
+ return new Response(body, { status: entry.status, headers });
390
412
  }
391
413
 
392
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.