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 +1 -1
- package/src/ambient.d.ts +3 -0
- package/src/cli/add.ts +15 -12
- package/src/cli/block.ts +18 -16
- package/src/cli/feat.ts +31 -11
- package/src/cli/page.ts +18 -16
- package/src/cli/registry.ts +117 -20
- package/src/cli/theme.ts +7 -2
- package/src/core/backend.ts +8 -1
- package/src/core/build.ts +7 -2
- package/src/core/cache.ts +28 -6
- package/src/core/client/App.svelte +15 -26
- package/src/core/client/page.svelte.ts +15 -0
- package/src/core/client/prefetch.ts +55 -60
- package/src/core/dev.ts +11 -1
- package/src/core/etag.ts +13 -0
- package/src/core/hooks.ts +17 -0
- package/src/core/html.ts +109 -14
- package/src/core/plugins/inspector/bun-plugin.ts +31 -18
- package/src/core/renderer.ts +99 -76
- package/src/core/routeFile.ts +36 -20
- package/src/core/scanner.ts +2 -0
- package/src/core/server.ts +294 -94
- package/src/core/staticManifest.ts +60 -15
- package/src/core/svelteCompiler.ts +96 -27
- package/src/core/types.ts +4 -0
- package/templates/shop/.env.example +3 -0
- package/templates/shop/src/hooks.server.ts +12 -2
- package/templates/shop/src/routes/(private)/+layout.server.ts +2 -6
- package/templates/store/.env.example +3 -0
- package/templates/store/src/hooks.server.ts +12 -2
- package/templates/store/src/routes/(private)/+layout.server.ts +2 -6
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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) {
|
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
|
package/src/core/backend.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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.
|
|
386
|
-
|
|
387
|
-
|
|
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(
|
|
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' =
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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.
|