bosia 0.4.3 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/add.ts +4 -2
- package/src/cli/block.ts +94 -0
- package/src/cli/fonts.ts +61 -0
- package/src/cli/index.ts +19 -6
- package/src/cli/theme.ts +88 -0
- package/src/core/cors.ts +57 -11
- package/src/core/csp.ts +47 -0
- package/src/core/csrf.ts +8 -5
- package/src/core/dev.ts +14 -2
- package/src/core/errors.ts +4 -3
- package/src/core/hooks.ts +10 -1
- package/src/core/html.ts +21 -20
- package/src/core/plugin.ts +13 -0
- package/src/core/prerender.ts +11 -0
- package/src/core/renderer.ts +115 -11
- package/src/core/safePath.ts +14 -0
- package/src/core/server.ts +53 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
|
|
6
6
|
"keywords": [
|
package/src/cli/add.ts
CHANGED
|
@@ -27,9 +27,11 @@ interface ComponentMeta {
|
|
|
27
27
|
npmDeps: Record<string, string>;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
interface RegistryIndex {
|
|
30
|
+
export interface RegistryIndex {
|
|
31
31
|
components: string[];
|
|
32
32
|
features: string[];
|
|
33
|
+
blocks?: string[];
|
|
34
|
+
themes?: string[];
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// Track already-installed components within a session to avoid re-running deps
|
|
@@ -193,7 +195,7 @@ export function cn(...inputs: ClassValue[]) {
|
|
|
193
195
|
}
|
|
194
196
|
`;
|
|
195
197
|
|
|
196
|
-
function ensureUtils() {
|
|
198
|
+
export function ensureUtils() {
|
|
197
199
|
const utilsPath = join(process.cwd(), "src", "lib", "utils.ts");
|
|
198
200
|
if (!existsSync(utilsPath)) {
|
|
199
201
|
mkdirSync(dirname(utilsPath), { recursive: true });
|
package/src/cli/block.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { join, dirname } from "path";
|
|
2
|
+
import { mkdirSync, writeFileSync, existsSync } from "fs";
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
4
|
+
import {
|
|
5
|
+
resolveLocalRegistryOrExit,
|
|
6
|
+
readRegistryJSON,
|
|
7
|
+
readRegistryFile,
|
|
8
|
+
bunAdd,
|
|
9
|
+
} from "./registry.ts";
|
|
10
|
+
import { addComponent, initAddRegistry, ensureUtils } from "./add.ts";
|
|
11
|
+
import { mergeFontImports } from "./fonts.ts";
|
|
12
|
+
|
|
13
|
+
// ─── bun x bosia@latest add block <category>/<name> ──────
|
|
14
|
+
// Installs a composed block into src/lib/blocks/<path>/.
|
|
15
|
+
// Recursively installs primitive component dependencies and
|
|
16
|
+
// optional Google Fonts @imports into app.css.
|
|
17
|
+
|
|
18
|
+
interface BlockMeta {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
category: string;
|
|
22
|
+
themes?: string[];
|
|
23
|
+
dependencies: string[]; // primitive component names
|
|
24
|
+
files: string[];
|
|
25
|
+
fonts?: Record<string, string>; // family → @import URL
|
|
26
|
+
npmDeps: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runAddBlock(name: string | undefined, flags: string[] = []) {
|
|
30
|
+
if (!name || !name.includes("/")) {
|
|
31
|
+
console.error(
|
|
32
|
+
"❌ Please provide a block path.\n Usage: bun x bosia@latest add block <category>/<name> [--local]",
|
|
33
|
+
);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const local = flags.includes("--local");
|
|
38
|
+
const registryRoot = local ? resolveLocalRegistryOrExit() : null;
|
|
39
|
+
if (local) console.log(`⬡ Using local registry: ${registryRoot}\n`);
|
|
40
|
+
|
|
41
|
+
await initAddRegistry(registryRoot);
|
|
42
|
+
ensureUtils();
|
|
43
|
+
|
|
44
|
+
console.log(`⬡ Installing block: ${name}\n`);
|
|
45
|
+
|
|
46
|
+
const meta = await readRegistryJSON<BlockMeta>(registryRoot, "blocks", name, "meta.json");
|
|
47
|
+
|
|
48
|
+
// 1. Install primitive dependencies first
|
|
49
|
+
for (const dep of meta.dependencies ?? []) {
|
|
50
|
+
await addComponent(dep, false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. Copy block files to src/lib/blocks/<path>/
|
|
54
|
+
const cwd = process.cwd();
|
|
55
|
+
const destDir = join(cwd, "src", "lib", "blocks", name);
|
|
56
|
+
|
|
57
|
+
if (existsSync(destDir)) {
|
|
58
|
+
const replace = await p.confirm({
|
|
59
|
+
message: `Block "${name}" already exists at src/lib/blocks/${name}/. Replace it?`,
|
|
60
|
+
});
|
|
61
|
+
if (p.isCancel(replace) || !replace) {
|
|
62
|
+
console.log(` ⏭️ Skipped ${name}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
mkdirSync(destDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
for (const file of meta.files) {
|
|
70
|
+
const content = await readRegistryFile(registryRoot, "blocks", name, file);
|
|
71
|
+
const dest = join(destDir, file);
|
|
72
|
+
if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
|
|
73
|
+
writeFileSync(dest, content, "utf-8");
|
|
74
|
+
console.log(` ✍️ src/lib/blocks/${name}/${file}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Merge font @imports into app.css (idempotent)
|
|
78
|
+
if (meta.fonts && Object.keys(meta.fonts).length > 0) {
|
|
79
|
+
const cssPath = join(cwd, "src", "app.css");
|
|
80
|
+
if (existsSync(cssPath)) {
|
|
81
|
+
const added = mergeFontImports(cssPath, meta.fonts);
|
|
82
|
+
if (added.length > 0) {
|
|
83
|
+
console.log(` 🔤 Added fonts to app.css: ${added.join(", ")}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 4. npm deps
|
|
89
|
+
if (meta.npmDeps && Object.keys(meta.npmDeps).length > 0) {
|
|
90
|
+
await bunAdd(cwd, meta.npmDeps);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`\n✅ ${name} installed at src/lib/blocks/${name}/`);
|
|
94
|
+
}
|
package/src/cli/fonts.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
// ─── Font @import management for app.css ──────────────────
|
|
4
|
+
// Merges Google-Fonts (or any) @import lines into app.css idempotently.
|
|
5
|
+
// Each font URL is bracketed with a marker comment so we can detect and skip
|
|
6
|
+
// duplicates without parsing real CSS.
|
|
7
|
+
|
|
8
|
+
const MARK_PREFIX = "/* bosia-font:";
|
|
9
|
+
|
|
10
|
+
export interface FontEntry {
|
|
11
|
+
[family: string]: string; // family → @import URL
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Inserts `@import url("…");` lines for each font that is not already present.
|
|
16
|
+
* Returns the list of family names that were newly added (empty if no-op).
|
|
17
|
+
*/
|
|
18
|
+
export function mergeFontImports(cssPath: string, fonts: FontEntry): string[] {
|
|
19
|
+
const existing = readFileSync(cssPath, "utf-8");
|
|
20
|
+
const added: string[] = [];
|
|
21
|
+
const lines: string[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [family, url] of Object.entries(fonts)) {
|
|
24
|
+
const marker = `${MARK_PREFIX} ${family} */`;
|
|
25
|
+
if (existing.includes(marker)) continue;
|
|
26
|
+
lines.push(`${marker}\n@import url("${url}");`);
|
|
27
|
+
added.push(family);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (lines.length === 0) return [];
|
|
31
|
+
|
|
32
|
+
// Prepend after any opening comment block; simplest: prepend to top.
|
|
33
|
+
const next = lines.join("\n") + "\n" + existing;
|
|
34
|
+
writeFileSync(cssPath, next, "utf-8");
|
|
35
|
+
return added;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remove font @imports inserted by Bosia by family name. Used when switching themes.
|
|
40
|
+
*/
|
|
41
|
+
export function removeFontImports(cssPath: string, families: string[]): string[] {
|
|
42
|
+
const existing = readFileSync(cssPath, "utf-8");
|
|
43
|
+
let next = existing;
|
|
44
|
+
const removed: string[] = [];
|
|
45
|
+
|
|
46
|
+
for (const family of families) {
|
|
47
|
+
const marker = `${MARK_PREFIX} ${family} */`;
|
|
48
|
+
const re = new RegExp(`${escapeRegExp(marker)}\\n@import url\\("[^"]+"\\);\\n?`, "g");
|
|
49
|
+
if (re.test(next)) {
|
|
50
|
+
next = next.replace(re, "");
|
|
51
|
+
removed.push(family);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (removed.length > 0) writeFileSync(cssPath, next, "utf-8");
|
|
56
|
+
return removed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function escapeRegExp(s: string): string {
|
|
60
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -37,10 +37,19 @@ async function main() {
|
|
|
37
37
|
break;
|
|
38
38
|
}
|
|
39
39
|
case "add": {
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
40
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
41
|
+
const flags = args.filter((a) => a.startsWith("--"));
|
|
42
|
+
const sub = positional[0];
|
|
43
|
+
if (sub === "block") {
|
|
44
|
+
const { runAddBlock } = await import("./block.ts");
|
|
45
|
+
await runAddBlock(positional[1], flags);
|
|
46
|
+
} else if (sub === "theme") {
|
|
47
|
+
const { runAddTheme } = await import("./theme.ts");
|
|
48
|
+
await runAddTheme(positional[1], flags);
|
|
49
|
+
} else {
|
|
50
|
+
const { runAdd } = await import("./add.ts");
|
|
51
|
+
await runAdd(sub, flags);
|
|
52
|
+
}
|
|
44
53
|
break;
|
|
45
54
|
}
|
|
46
55
|
case "feat": {
|
|
@@ -63,8 +72,10 @@ Commands:
|
|
|
63
72
|
build Build for production
|
|
64
73
|
start Run the production server
|
|
65
74
|
test [args] Run tests with bun test (auto-loads .env.test, sets BOSIA_ENV=test)
|
|
66
|
-
add <component>
|
|
67
|
-
|
|
75
|
+
add <component> Add a UI component from the registry
|
|
76
|
+
add block <cat>/<name> Add a composed block from the registry
|
|
77
|
+
add theme <name> Add a theme (tokens.css) from the registry
|
|
78
|
+
feat <feature> Add a feature scaffold from the registry [--local]
|
|
68
79
|
|
|
69
80
|
Examples:
|
|
70
81
|
bun x bosia@latest create my-app
|
|
@@ -77,6 +88,8 @@ Examples:
|
|
|
77
88
|
bun x bosia test --coverage
|
|
78
89
|
bun x bosia@latest add button → src/lib/components/ui/button/
|
|
79
90
|
bun x bosia@latest add shop/cart → src/lib/components/shop/cart/
|
|
91
|
+
bun x bosia@latest add block cards/feature-editorial
|
|
92
|
+
bun x bosia@latest add theme editorial
|
|
80
93
|
bun x bosia@latest feat login
|
|
81
94
|
`);
|
|
82
95
|
break;
|
package/src/cli/theme.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
|
|
3
|
+
import { resolveLocalRegistryOrExit, readRegistryJSON, readRegistryFile } from "./registry.ts";
|
|
4
|
+
import { mergeFontImports } from "./fonts.ts";
|
|
5
|
+
|
|
6
|
+
// ─── bun x bosia@latest add theme <name> ─────────────────
|
|
7
|
+
// Installs a theme tokens.css to src/lib/themes/<name>.css and
|
|
8
|
+
// rewrites the active theme @import in src/app.css. One theme
|
|
9
|
+
// active at a time (v1 assumption).
|
|
10
|
+
|
|
11
|
+
interface ThemeMeta {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
files: string[];
|
|
15
|
+
fonts?: Record<string, string>;
|
|
16
|
+
npmDeps?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const THEME_IMPORT_RE = /^@import\s+["']\.\/lib\/themes\/[^"']+["'];?\s*$/m;
|
|
20
|
+
const THEME_MARKER = "/* bosia-theme */";
|
|
21
|
+
|
|
22
|
+
export async function runAddTheme(name: string | undefined, flags: string[] = []) {
|
|
23
|
+
if (!name) {
|
|
24
|
+
console.error(
|
|
25
|
+
"❌ Please provide a theme name.\n Usage: bun x bosia@latest add theme <name> [--local]",
|
|
26
|
+
);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const local = flags.includes("--local");
|
|
31
|
+
const registryRoot = local ? resolveLocalRegistryOrExit() : null;
|
|
32
|
+
if (local) console.log(`⬡ Using local registry: ${registryRoot}\n`);
|
|
33
|
+
|
|
34
|
+
console.log(`⬡ Installing theme: ${name}\n`);
|
|
35
|
+
|
|
36
|
+
const meta = await readRegistryJSON<ThemeMeta>(registryRoot, "themes", name, "meta.json");
|
|
37
|
+
|
|
38
|
+
const cwd = process.cwd();
|
|
39
|
+
const themesDir = join(cwd, "src", "lib", "themes");
|
|
40
|
+
mkdirSync(themesDir, { recursive: true });
|
|
41
|
+
|
|
42
|
+
// Copy tokens.css (and any other files) to src/lib/themes/<name>.css
|
|
43
|
+
// Convention: first file is the tokens file, written as <name>.css.
|
|
44
|
+
const tokensFile = meta.files[0] ?? "tokens.css";
|
|
45
|
+
const content = await readRegistryFile(registryRoot, "themes", name, tokensFile);
|
|
46
|
+
const tokensDest = join(themesDir, `${name}.css`);
|
|
47
|
+
writeFileSync(tokensDest, content, "utf-8");
|
|
48
|
+
console.log(` ✍️ src/lib/themes/${name}.css`);
|
|
49
|
+
|
|
50
|
+
// Patch app.css: swap any existing ./lib/themes/*.css import for this one
|
|
51
|
+
const appCssPath = join(cwd, "src", "app.css");
|
|
52
|
+
if (existsSync(appCssPath)) {
|
|
53
|
+
patchAppCssThemeImport(appCssPath, name);
|
|
54
|
+
console.log(` 🎨 app.css → @import "./lib/themes/${name}.css"`);
|
|
55
|
+
} else {
|
|
56
|
+
console.warn(` ⚠️ src/app.css not found — theme import not wired automatically.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Font @imports
|
|
60
|
+
if (meta.fonts && Object.keys(meta.fonts).length > 0 && existsSync(appCssPath)) {
|
|
61
|
+
const added = mergeFontImports(appCssPath, meta.fonts);
|
|
62
|
+
if (added.length > 0) console.log(` 🔤 Added fonts: ${added.join(", ")}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`\n✅ ${name} theme installed.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function patchAppCssThemeImport(appCssPath: string, themeName: string) {
|
|
69
|
+
const src = readFileSync(appCssPath, "utf-8");
|
|
70
|
+
const newImport = `${THEME_MARKER}\n@import "./lib/themes/${themeName}.css";`;
|
|
71
|
+
|
|
72
|
+
let next: string;
|
|
73
|
+
if (THEME_IMPORT_RE.test(src)) {
|
|
74
|
+
next = src.replace(THEME_IMPORT_RE, `@import "./lib/themes/${themeName}.css";`);
|
|
75
|
+
if (!next.includes(THEME_MARKER)) {
|
|
76
|
+
next = next.replace(`@import "./lib/themes/${themeName}.css";`, newImport);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
// Insert after the tailwindcss @import line so @theme {} is processed correctly.
|
|
80
|
+
const tw = /^(@import\s+["']tailwindcss["'];\s*\n)/m;
|
|
81
|
+
if (tw.test(src)) {
|
|
82
|
+
next = src.replace(tw, `$1${newImport}\n`);
|
|
83
|
+
} else {
|
|
84
|
+
next = `${newImport}\n${src}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
writeFileSync(appCssPath, next, "utf-8");
|
|
88
|
+
}
|
package/src/core/cors.ts
CHANGED
|
@@ -13,8 +13,24 @@ export interface CorsConfig {
|
|
|
13
13
|
maxAge?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const DEFAULT_METHODS = "GET, HEAD, PUT, PATCH, POST, DELETE";
|
|
17
|
-
const DEFAULT_HEADERS = "Content-Type, Authorization";
|
|
16
|
+
const DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"];
|
|
17
|
+
const DEFAULT_HEADERS = ["Content-Type", "Authorization"];
|
|
18
|
+
|
|
19
|
+
function parseHeaderList(value: string): string[] {
|
|
20
|
+
return value
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((s) => s.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Headers applied to *every* response when CORS is configured, regardless of
|
|
28
|
+
* whether the request Origin is allowed. Keeps caches/CDNs from serving a
|
|
29
|
+
* response with `Access-Control-Allow-Origin: X` to a different origin Y.
|
|
30
|
+
*/
|
|
31
|
+
export function applyCorsVary(headers: Headers): void {
|
|
32
|
+
headers.set("Vary", "Origin");
|
|
33
|
+
}
|
|
18
34
|
|
|
19
35
|
/**
|
|
20
36
|
* Returns CORS response headers if the request Origin is in the allowed list.
|
|
@@ -48,22 +64,52 @@ export function getCorsHeaders(
|
|
|
48
64
|
|
|
49
65
|
/**
|
|
50
66
|
* Handles OPTIONS preflight requests.
|
|
51
|
-
*
|
|
67
|
+
*
|
|
68
|
+
* - Returns `null` if the request's Origin is missing or not allowed — the
|
|
69
|
+
* caller treats this as "not a CORS preflight we serve", avoiding leaking
|
|
70
|
+
* policy details to unknown origins.
|
|
71
|
+
* - Returns a 403 (carrying `Access-Control-Allow-Origin` + `Vary: Origin`)
|
|
72
|
+
* when the requested method or any requested header falls outside the
|
|
73
|
+
* configured allow-list. A 403 surfaces a clearer "not allowed by CORS
|
|
74
|
+
* policy" message in the browser than letting the OPTIONS request fall
|
|
75
|
+
* through to the default handler.
|
|
76
|
+
* - Otherwise returns a 204 with the standard preflight headers.
|
|
52
77
|
*/
|
|
53
78
|
export function handlePreflight(request: Request, config: CorsConfig): Response | null {
|
|
54
79
|
const base = getCorsHeaders(request, config);
|
|
55
80
|
if (!base) return null;
|
|
56
81
|
|
|
82
|
+
const allowedMethods = config.allowedMethods ?? DEFAULT_METHODS;
|
|
83
|
+
const allowedHeaders = config.allowedHeaders ?? DEFAULT_HEADERS;
|
|
84
|
+
|
|
85
|
+
const requestedMethod = request.headers.get("access-control-request-method");
|
|
86
|
+
if (requestedMethod) {
|
|
87
|
+
const upper = requestedMethod.toUpperCase();
|
|
88
|
+
const allowedUpper = allowedMethods.map((m) => m.toUpperCase());
|
|
89
|
+
if (!allowedUpper.includes(upper)) {
|
|
90
|
+
return rejectPreflight(base, `Method ${requestedMethod} not allowed by CORS policy`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const requestedHeadersRaw = request.headers.get("access-control-request-headers");
|
|
95
|
+
if (requestedHeadersRaw) {
|
|
96
|
+
const requested = parseHeaderList(requestedHeadersRaw).map((h) => h.toLowerCase());
|
|
97
|
+
const allowedLower = allowedHeaders.map((h) => h.toLowerCase());
|
|
98
|
+
const disallowed = requested.find((h) => !allowedLower.includes(h));
|
|
99
|
+
if (disallowed) {
|
|
100
|
+
return rejectPreflight(base, `Header ${disallowed} not allowed by CORS policy`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
57
104
|
const headers = new Headers(base as HeadersInit);
|
|
58
|
-
headers.set(
|
|
59
|
-
|
|
60
|
-
config.allowedMethods?.join(", ") ?? DEFAULT_METHODS,
|
|
61
|
-
);
|
|
62
|
-
headers.set(
|
|
63
|
-
"Access-Control-Allow-Headers",
|
|
64
|
-
config.allowedHeaders?.join(", ") ?? DEFAULT_HEADERS,
|
|
65
|
-
);
|
|
105
|
+
headers.set("Access-Control-Allow-Methods", allowedMethods.join(", "));
|
|
106
|
+
headers.set("Access-Control-Allow-Headers", allowedHeaders.join(", "));
|
|
66
107
|
headers.set("Access-Control-Max-Age", String(config.maxAge ?? 86400));
|
|
67
108
|
|
|
68
109
|
return new Response(null, { status: 204, headers });
|
|
69
110
|
}
|
|
111
|
+
|
|
112
|
+
function rejectPreflight(base: Record<string, string>, reason: string): Response {
|
|
113
|
+
const headers = new Headers(base as HeadersInit);
|
|
114
|
+
return new Response(reason, { status: 403, headers });
|
|
115
|
+
}
|
package/src/core/csp.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ─── CSP Nonce ───────────────────────────────────────────
|
|
2
|
+
// Per-request cryptographic nonce. Embedded as `nonce="..."` on every
|
|
3
|
+
// framework-emitted <script> tag so that user code (or operators) can
|
|
4
|
+
// configure a `Content-Security-Policy` header with `script-src 'nonce-…'`
|
|
5
|
+
// and lock down inline-script execution without breaking framework
|
|
6
|
+
// hydration.
|
|
7
|
+
//
|
|
8
|
+
// 16 random bytes → base64 (22 chars after stripping `=` padding) gives
|
|
9
|
+
// the 128 bits of entropy recommended by the CSP spec.
|
|
10
|
+
|
|
11
|
+
const NONCE_BYTES = 16;
|
|
12
|
+
|
|
13
|
+
export function generateNonce(): string {
|
|
14
|
+
const bytes = new Uint8Array(NONCE_BYTES);
|
|
15
|
+
crypto.getRandomValues(bytes);
|
|
16
|
+
let binary = "";
|
|
17
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
18
|
+
return btoa(binary).replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Returns ` nonce="…"` (with leading space) when `nonce` is non-empty, otherwise `""`. */
|
|
22
|
+
export function nonceAttr(nonce: string | undefined): string {
|
|
23
|
+
return nonce ? ` nonce="${nonce}"` : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Optional CSP Header ─────────────────────────────────
|
|
27
|
+
// Opt-in via `CSP_DIRECTIVES` env. The literal `{nonce}` placeholder in
|
|
28
|
+
// the configured value is replaced with the per-request nonce on each
|
|
29
|
+
// response. Empty/unset env → no `Content-Security-Policy` header.
|
|
30
|
+
//
|
|
31
|
+
// Example:
|
|
32
|
+
// CSP_DIRECTIVES="default-src 'self'; script-src 'self' 'nonce-{nonce}'"
|
|
33
|
+
|
|
34
|
+
export const CSP_DIRECTIVES_TEMPLATE: string | null = process.env.CSP_DIRECTIVES?.trim() || null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `true` when an operator has opted into CSP via `CSP_DIRECTIVES`. The
|
|
38
|
+
* framework gates *all* nonce-related wire output on this flag — without a
|
|
39
|
+
* matching `Content-Security-Policy` header the nonce attribute is just dead
|
|
40
|
+
* bytes, so we omit it.
|
|
41
|
+
*/
|
|
42
|
+
export const CSP_ENABLED: boolean = CSP_DIRECTIVES_TEMPLATE !== null;
|
|
43
|
+
|
|
44
|
+
export function buildCspHeader(nonce: string): string | null {
|
|
45
|
+
if (!CSP_DIRECTIVES_TEMPLATE) return null;
|
|
46
|
+
return CSP_DIRECTIVES_TEMPLATE.replace(/\{nonce\}/g, nonce);
|
|
47
|
+
}
|
package/src/core/csrf.ts
CHANGED
|
@@ -31,12 +31,15 @@ export function checkCsrf(
|
|
|
31
31
|
if (SAFE_METHODS.has(request.method.toUpperCase())) return null;
|
|
32
32
|
|
|
33
33
|
// Derive the expected origin.
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
34
|
+
// `X-Forwarded-*` headers are only trusted when `TRUST_PROXY=true`, since a
|
|
35
|
+
// directly-exposed server would otherwise let a client spoof its own origin
|
|
36
|
+
// via attacker-controlled forwarded headers. Behind a real reverse proxy
|
|
37
|
+
// (nginx, Caddy, Cloudflare) the operator opts in by setting the env.
|
|
38
|
+
const trustProxy = process.env.TRUST_PROXY === "true";
|
|
39
|
+
const forwardedHost = trustProxy ? request.headers.get("x-forwarded-host") : null;
|
|
38
40
|
const host = forwardedHost ?? request.headers.get("host");
|
|
39
|
-
const
|
|
41
|
+
const forwardedProto = trustProxy ? request.headers.get("x-forwarded-proto") : null;
|
|
42
|
+
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
40
43
|
const expectedOrigin = host ? `${protocol}://${host}` : url.origin;
|
|
41
44
|
|
|
42
45
|
const allowedOrigins = new Set([expectedOrigin, ...(config.allowedOrigins ?? [])]);
|
package/src/core/dev.ts
CHANGED
|
@@ -94,6 +94,10 @@ async function startAppServer() {
|
|
|
94
94
|
PORT: String(APP_PORT),
|
|
95
95
|
// Allow externalized deps (elysia, etc.) to resolve from bosia's node_modules
|
|
96
96
|
NODE_PATH: BOSIA_NODE_PATH,
|
|
97
|
+
// Dev proxy injects X-Forwarded-Host/Proto reflecting the public DEV_PORT, so CSRF
|
|
98
|
+
// origin checks must honour them. Safe in dev because the proxy controls these
|
|
99
|
+
// headers — no untrusted client can spoof them.
|
|
100
|
+
TRUST_PROXY: "true",
|
|
97
101
|
},
|
|
98
102
|
});
|
|
99
103
|
|
|
@@ -204,16 +208,24 @@ const devServer = Bun.serve({
|
|
|
204
208
|
);
|
|
205
209
|
}
|
|
206
210
|
|
|
207
|
-
// Proxy everything else to the app server
|
|
211
|
+
// Proxy everything else to the app server. Inject X-Forwarded-Host/Proto so
|
|
212
|
+
// the app's CSRF origin check (gated behind TRUST_PROXY=true, also set in the
|
|
213
|
+
// app env above) reconstructs the public-facing origin from the dev proxy
|
|
214
|
+
// rather than the inner-app's host (localhost:APP_PORT).
|
|
208
215
|
try {
|
|
216
|
+
const reqUrl = new URL(req.url);
|
|
209
217
|
const target = new URL(req.url);
|
|
210
218
|
target.hostname = "localhost";
|
|
211
219
|
target.port = String(APP_PORT);
|
|
212
220
|
|
|
221
|
+
const forwardedHeaders = new Headers(req.headers);
|
|
222
|
+
forwardedHeaders.set("x-forwarded-host", reqUrl.host);
|
|
223
|
+
forwardedHeaders.set("x-forwarded-proto", reqUrl.protocol.replace(":", ""));
|
|
224
|
+
|
|
213
225
|
return await fetch(
|
|
214
226
|
new Request(target.toString(), {
|
|
215
227
|
method: req.method,
|
|
216
|
-
headers:
|
|
228
|
+
headers: forwardedHeaders,
|
|
217
229
|
body: req.body,
|
|
218
230
|
redirect: "manual",
|
|
219
231
|
}),
|
package/src/core/errors.ts
CHANGED
|
@@ -29,11 +29,10 @@ export class Redirect {
|
|
|
29
29
|
const DANGEROUS_SCHEMES = /^(javascript|data|vbscript):/i;
|
|
30
30
|
|
|
31
31
|
function validateRedirectLocation(location: string, options?: RedirectOptions): void {
|
|
32
|
-
if (options?.allowExternal) return;
|
|
33
|
-
|
|
34
32
|
const trimmed = location.trim();
|
|
35
33
|
|
|
36
|
-
//
|
|
34
|
+
// Dangerous schemes are rejected even when `allowExternal: true` —
|
|
35
|
+
// `javascript:` / `data:` / `vbscript:` are never legitimate redirect targets.
|
|
37
36
|
if (DANGEROUS_SCHEMES.test(trimmed)) {
|
|
38
37
|
throw new Error(
|
|
39
38
|
`redirect(): dangerous scheme in URL "${location}". ` +
|
|
@@ -41,6 +40,8 @@ function validateRedirectLocation(location: string, options?: RedirectOptions):
|
|
|
41
40
|
);
|
|
42
41
|
}
|
|
43
42
|
|
|
43
|
+
if (options?.allowExternal) return;
|
|
44
|
+
|
|
44
45
|
// Reject protocol-relative URLs (//evil.com)
|
|
45
46
|
if (trimmed.startsWith("//")) {
|
|
46
47
|
throw new Error(
|
package/src/core/hooks.ts
CHANGED
|
@@ -34,7 +34,16 @@ export interface Cookies {
|
|
|
34
34
|
export type RequestEvent = {
|
|
35
35
|
request: Request;
|
|
36
36
|
url: URL;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Per-request scratch object for user hooks/load functions.
|
|
39
|
+
*
|
|
40
|
+
* `locals.nonce` is populated by the framework with a fresh per-request
|
|
41
|
+
* cryptographic nonce (base64, 128 bits of entropy) and is safe to embed
|
|
42
|
+
* as `nonce="${event.locals.nonce}"` on user-authored inline scripts when
|
|
43
|
+
* the operator enables a `Content-Security-Policy` via the
|
|
44
|
+
* `CSP_DIRECTIVES` env var.
|
|
45
|
+
*/
|
|
46
|
+
locals: Record<string, any> & { nonce?: string };
|
|
38
47
|
params: Record<string, string>;
|
|
39
48
|
cookies: Cookies;
|
|
40
49
|
};
|
package/src/core/html.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
2
|
import { getDeclaredEnvKeys } from "./env.ts";
|
|
3
|
+
import { nonceAttr } from "./csp.ts";
|
|
3
4
|
|
|
4
5
|
// ─── Dist Manifest ───────────────────────────────────────
|
|
5
6
|
// Maps hashed filenames → script/link tags.
|
|
@@ -76,6 +77,7 @@ export function buildHtml(
|
|
|
76
77
|
formData: any = null,
|
|
77
78
|
lang?: string,
|
|
78
79
|
ssr = true,
|
|
80
|
+
nonce?: string,
|
|
79
81
|
): string {
|
|
80
82
|
const cssLinks = (distManifest.css ?? [])
|
|
81
83
|
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
@@ -83,10 +85,11 @@ export function buildHtml(
|
|
|
83
85
|
|
|
84
86
|
const fallbackTitle = head.includes("<title>") ? "" : "<title>Bosia App</title>";
|
|
85
87
|
|
|
88
|
+
const n = nonceAttr(nonce);
|
|
86
89
|
const publicEnv = getPublicDynamicEnv();
|
|
87
90
|
const envScript =
|
|
88
91
|
Object.keys(publicEnv).length > 0
|
|
89
|
-
? `\n <script>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
|
|
92
|
+
? `\n <script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
|
|
90
93
|
: "";
|
|
91
94
|
|
|
92
95
|
const formScript =
|
|
@@ -94,9 +97,9 @@ export function buildHtml(
|
|
|
94
97
|
const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
|
|
95
98
|
|
|
96
99
|
const scripts = csr
|
|
97
|
-
? `${envScript}\n <script>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
|
|
100
|
+
? `${envScript}\n <script${n}>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
|
|
98
101
|
: isDev
|
|
99
|
-
? `\n <script>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
|
|
102
|
+
? `\n <script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
|
|
100
103
|
: "";
|
|
101
104
|
|
|
102
105
|
return `<!DOCTYPE html>
|
|
@@ -109,7 +112,7 @@ export function buildHtml(
|
|
|
109
112
|
${head}
|
|
110
113
|
${cssLinks}
|
|
111
114
|
<link rel="stylesheet" href="/bosia-tw.css${cacheBust}">
|
|
112
|
-
<script>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>
|
|
115
|
+
<script${n}>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>
|
|
113
116
|
</head>
|
|
114
117
|
<body>
|
|
115
118
|
<div id="app">${body}</div>${scripts}
|
|
@@ -121,27 +124,23 @@ export function buildHtml(
|
|
|
121
124
|
|
|
122
125
|
import type { Metadata } from "./hooks.ts";
|
|
123
126
|
|
|
124
|
-
const _shellOpenCache = new Map<string, string>();
|
|
125
|
-
|
|
126
127
|
/** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
|
|
127
|
-
export function buildHtmlShellOpen(lang?: string): string {
|
|
128
|
+
export function buildHtmlShellOpen(lang?: string, nonce?: string): string {
|
|
128
129
|
const key = safeLang(lang);
|
|
129
|
-
const
|
|
130
|
-
if (cached) return cached;
|
|
130
|
+
const n = nonceAttr(nonce);
|
|
131
131
|
const cssLinks = (distManifest.css ?? [])
|
|
132
132
|
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
133
133
|
.join("\n ");
|
|
134
|
-
|
|
134
|
+
return (
|
|
135
135
|
`<!DOCTYPE html>\n<html lang="${key}">\n<head>\n` +
|
|
136
136
|
` <meta charset="UTF-8">\n` +
|
|
137
137
|
` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
138
138
|
` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n` +
|
|
139
139
|
` ${cssLinks}\n` +
|
|
140
140
|
` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
|
|
141
|
-
` <script>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
|
|
142
|
-
` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}"
|
|
143
|
-
|
|
144
|
-
return result;
|
|
141
|
+
` <script${n}>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
|
|
142
|
+
` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
|
|
143
|
+
);
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
const SPINNER =
|
|
@@ -208,25 +207,27 @@ export function buildHtmlTail(
|
|
|
208
207
|
formData: any = null,
|
|
209
208
|
ssr = true,
|
|
210
209
|
bodyEndExtras?: string[],
|
|
210
|
+
nonce?: string,
|
|
211
211
|
): string {
|
|
212
|
-
|
|
212
|
+
const n = nonceAttr(nonce);
|
|
213
|
+
let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
|
|
213
214
|
out += `\n<div id="app">${body}</div>`;
|
|
214
215
|
if (head)
|
|
215
|
-
out += `\n<script>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
|
|
216
|
+
out += `\n<script${n}>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
|
|
216
217
|
if (csr) {
|
|
217
218
|
const publicEnv = getPublicDynamicEnv();
|
|
218
219
|
if (Object.keys(publicEnv).length > 0) {
|
|
219
|
-
out += `\n<script>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
|
|
220
|
+
out += `\n<script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
|
|
220
221
|
}
|
|
221
222
|
const formInject =
|
|
222
223
|
formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
|
|
223
224
|
const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
|
|
224
225
|
out +=
|
|
225
|
-
`\n<script>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
|
|
226
|
+
`\n<script${n}>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
|
|
226
227
|
`window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formInject}</script>`;
|
|
227
|
-
out += `\n<script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
|
|
228
|
+
out += `\n<script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
|
|
228
229
|
} else if (isDev) {
|
|
229
|
-
out += `\n<script>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
|
|
230
|
+
out += `\n<script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
|
|
230
231
|
}
|
|
231
232
|
if (bodyEndExtras?.length) {
|
|
232
233
|
for (const fragment of bodyEndExtras) {
|
package/src/core/plugin.ts
CHANGED
|
@@ -126,6 +126,19 @@ export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
|
|
|
126
126
|
path: "tailwindcss",
|
|
127
127
|
namespace: "bosia-empty-css",
|
|
128
128
|
}));
|
|
129
|
+
// app.css is processed by Tailwind CLI into public/bosia-tw.css and
|
|
130
|
+
// loaded via <link> tag in HTML. User layouts often `import "../app.css"`
|
|
131
|
+
// for IDE/Tailwind tooling — bundle as JS no-op so Bun doesn't emit a
|
|
132
|
+
// CSS chunk per dynamic-imported route (identical content → output
|
|
133
|
+
// collision under splitting:true).
|
|
134
|
+
build.onResolve({ filter: /(?:^|.*\/)app\.css$/ }, () => ({
|
|
135
|
+
path: "app.css",
|
|
136
|
+
namespace: "bosia-empty-app-css",
|
|
137
|
+
}));
|
|
138
|
+
build.onLoad({ filter: /.*/, namespace: "bosia-empty-app-css" }, () => ({
|
|
139
|
+
contents: "",
|
|
140
|
+
loader: "js",
|
|
141
|
+
}));
|
|
129
142
|
build.onLoad({ filter: /.*/, namespace: "bosia-empty-css" }, () => ({
|
|
130
143
|
contents: "",
|
|
131
144
|
loader: "css",
|
package/src/core/prerender.ts
CHANGED
|
@@ -44,6 +44,17 @@ interface PrerenderTarget {
|
|
|
44
44
|
export function substituteParams(pattern: string, entry: Record<string, string>): string {
|
|
45
45
|
let resolved = pattern;
|
|
46
46
|
for (const [key, value] of Object.entries(entry)) {
|
|
47
|
+
// `..` and `\` are never legitimate in a route segment — they let a build
|
|
48
|
+
// emit prerendered HTML outside the intended output tree. Forward slashes
|
|
49
|
+
// are only allowed for catch-all (`[...key]`) segments, which by design
|
|
50
|
+
// expand to multiple path parts. Validate accordingly.
|
|
51
|
+
const isRest = pattern.includes(`[...${key}]`);
|
|
52
|
+
if (/\\|\.\./.test(value) || (!isRest && value.includes("/"))) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Prerender entries(): unsafe value "${value}" for [${key}] — ` +
|
|
55
|
+
`path traversal characters are not allowed in dynamic segment values.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
47
58
|
resolved = resolved.replace(`[...${key}]`, value);
|
|
48
59
|
resolved = resolved.replace(`[${key}]`, value);
|
|
49
60
|
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { findMatch } from "./matcher.ts";
|
|
|
4
4
|
import { serverRoutes, errorPage } from "bosia:routes";
|
|
5
5
|
import type { RouteMatch } from "./types.ts";
|
|
6
6
|
import type { Cookies } from "./hooks.ts";
|
|
7
|
+
import { CSP_ENABLED } from "./csp.ts";
|
|
7
8
|
import { HttpError, Redirect } from "./errors.ts";
|
|
8
9
|
import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
|
|
9
10
|
import App from "./client/App.svelte";
|
|
@@ -296,6 +297,7 @@ export async function renderSSRStream(
|
|
|
296
297
|
if (!match) return null;
|
|
297
298
|
|
|
298
299
|
const { route, params } = match;
|
|
300
|
+
const nonce = CSP_ENABLED && typeof locals.nonce === "string" ? locals.nonce : undefined;
|
|
299
301
|
|
|
300
302
|
// ── Pre-stream phase: resolve metadata before committing to a 200 ──
|
|
301
303
|
// Errors here return a proper error response with correct status code.
|
|
@@ -307,7 +309,17 @@ export async function renderSSRStream(
|
|
|
307
309
|
return Response.redirect(err.location, err.status);
|
|
308
310
|
}
|
|
309
311
|
if (err instanceof HttpError) {
|
|
310
|
-
return renderErrorPage(
|
|
312
|
+
return renderErrorPage(
|
|
313
|
+
err.status,
|
|
314
|
+
err.message,
|
|
315
|
+
url,
|
|
316
|
+
req,
|
|
317
|
+
route,
|
|
318
|
+
undefined,
|
|
319
|
+
undefined,
|
|
320
|
+
undefined,
|
|
321
|
+
nonce,
|
|
322
|
+
);
|
|
311
323
|
}
|
|
312
324
|
if (isDev) console.error("Metadata load error:", err);
|
|
313
325
|
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
@@ -344,14 +356,36 @@ export async function renderSSRStream(
|
|
|
344
356
|
e.errorDepth,
|
|
345
357
|
e.errorOrigin,
|
|
346
358
|
e.partialLayoutData,
|
|
359
|
+
nonce,
|
|
347
360
|
);
|
|
348
361
|
}
|
|
349
362
|
if (isDev) console.error("SSR load error:", err);
|
|
350
363
|
else console.error("SSR load error:", (err as Error).message ?? err);
|
|
351
|
-
return renderErrorPage(
|
|
364
|
+
return renderErrorPage(
|
|
365
|
+
500,
|
|
366
|
+
"Internal Server Error",
|
|
367
|
+
url,
|
|
368
|
+
req,
|
|
369
|
+
route,
|
|
370
|
+
undefined,
|
|
371
|
+
undefined,
|
|
372
|
+
undefined,
|
|
373
|
+
nonce,
|
|
374
|
+
);
|
|
352
375
|
}
|
|
353
376
|
|
|
354
|
-
if (!data)
|
|
377
|
+
if (!data)
|
|
378
|
+
return renderErrorPage(
|
|
379
|
+
404,
|
|
380
|
+
"Not Found",
|
|
381
|
+
url,
|
|
382
|
+
req,
|
|
383
|
+
undefined,
|
|
384
|
+
undefined,
|
|
385
|
+
undefined,
|
|
386
|
+
undefined,
|
|
387
|
+
nonce,
|
|
388
|
+
);
|
|
355
389
|
|
|
356
390
|
const enc = new TextEncoder();
|
|
357
391
|
const renderCtx: RenderContext = {
|
|
@@ -374,9 +408,19 @@ export async function renderSSRStream(
|
|
|
374
408
|
);
|
|
375
409
|
}
|
|
376
410
|
const html =
|
|
377
|
-
buildHtmlShellOpen(metadata?.lang) +
|
|
411
|
+
buildHtmlShellOpen(metadata?.lang, nonce) +
|
|
378
412
|
buildMetadataChunk(metadata, headExtras) +
|
|
379
|
-
buildHtmlTail(
|
|
413
|
+
buildHtmlTail(
|
|
414
|
+
"",
|
|
415
|
+
"",
|
|
416
|
+
data.pageData,
|
|
417
|
+
data.layoutData,
|
|
418
|
+
true,
|
|
419
|
+
null,
|
|
420
|
+
false,
|
|
421
|
+
bodyEndExtras,
|
|
422
|
+
nonce,
|
|
423
|
+
);
|
|
380
424
|
return new Response(html, {
|
|
381
425
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
382
426
|
});
|
|
@@ -408,12 +452,13 @@ export async function renderSSRStream(
|
|
|
408
452
|
route.layoutModules.length,
|
|
409
453
|
"page",
|
|
410
454
|
data.layoutData,
|
|
455
|
+
nonce,
|
|
411
456
|
);
|
|
412
457
|
}
|
|
413
458
|
|
|
414
459
|
// Pre-compute all chunks; pull-based stream gives Bun native backpressure.
|
|
415
460
|
const chunks: Uint8Array[] = [
|
|
416
|
-
enc.encode(buildHtmlShellOpen(metadata?.lang)),
|
|
461
|
+
enc.encode(buildHtmlShellOpen(metadata?.lang, nonce)),
|
|
417
462
|
enc.encode(buildMetadataChunk(metadata, headExtras)),
|
|
418
463
|
enc.encode(
|
|
419
464
|
buildHtmlTail(
|
|
@@ -425,6 +470,7 @@ export async function renderSSRStream(
|
|
|
425
470
|
null,
|
|
426
471
|
true,
|
|
427
472
|
bodyEndExtras,
|
|
473
|
+
nonce,
|
|
428
474
|
),
|
|
429
475
|
),
|
|
430
476
|
];
|
|
@@ -473,8 +519,20 @@ export async function renderPageWithFormData(
|
|
|
473
519
|
status: number,
|
|
474
520
|
match?: RouteMatch<(typeof serverRoutes)[number]> | null,
|
|
475
521
|
): Promise<Response> {
|
|
522
|
+
const nonce = CSP_ENABLED && typeof locals.nonce === "string" ? locals.nonce : undefined;
|
|
476
523
|
match ??= findMatch(serverRoutes, url.pathname);
|
|
477
|
-
if (!match)
|
|
524
|
+
if (!match)
|
|
525
|
+
return renderErrorPage(
|
|
526
|
+
404,
|
|
527
|
+
"Not Found",
|
|
528
|
+
url,
|
|
529
|
+
req,
|
|
530
|
+
undefined,
|
|
531
|
+
undefined,
|
|
532
|
+
undefined,
|
|
533
|
+
undefined,
|
|
534
|
+
nonce,
|
|
535
|
+
);
|
|
478
536
|
|
|
479
537
|
const { route } = match;
|
|
480
538
|
|
|
@@ -485,7 +543,18 @@ export async function renderPageWithFormData(
|
|
|
485
543
|
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
486
544
|
]);
|
|
487
545
|
|
|
488
|
-
if (!data)
|
|
546
|
+
if (!data)
|
|
547
|
+
return renderErrorPage(
|
|
548
|
+
404,
|
|
549
|
+
"Not Found",
|
|
550
|
+
url,
|
|
551
|
+
req,
|
|
552
|
+
undefined,
|
|
553
|
+
undefined,
|
|
554
|
+
undefined,
|
|
555
|
+
undefined,
|
|
556
|
+
nonce,
|
|
557
|
+
);
|
|
489
558
|
|
|
490
559
|
if (!data.ssr) {
|
|
491
560
|
if (!data.csr && isDev) {
|
|
@@ -502,6 +571,7 @@ export async function renderPageWithFormData(
|
|
|
502
571
|
formData,
|
|
503
572
|
undefined,
|
|
504
573
|
false,
|
|
574
|
+
nonce,
|
|
505
575
|
);
|
|
506
576
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
507
577
|
}
|
|
@@ -517,7 +587,17 @@ export async function renderPageWithFormData(
|
|
|
517
587
|
},
|
|
518
588
|
});
|
|
519
589
|
|
|
520
|
-
const html = buildHtml(
|
|
590
|
+
const html = buildHtml(
|
|
591
|
+
body,
|
|
592
|
+
head,
|
|
593
|
+
data.pageData,
|
|
594
|
+
data.layoutData,
|
|
595
|
+
data.csr,
|
|
596
|
+
formData,
|
|
597
|
+
undefined,
|
|
598
|
+
true,
|
|
599
|
+
nonce,
|
|
600
|
+
);
|
|
521
601
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
522
602
|
}
|
|
523
603
|
|
|
@@ -536,7 +616,11 @@ export async function renderErrorPage(
|
|
|
536
616
|
errorDepth?: number,
|
|
537
617
|
errorOrigin?: ErrorOrigin,
|
|
538
618
|
partialLayoutData?: Record<string, any>[],
|
|
619
|
+
nonce?: string,
|
|
539
620
|
): Promise<Response> {
|
|
621
|
+
// Strip the nonce from emitted scripts when CSP is off — the attribute
|
|
622
|
+
// is dead bytes without a matching policy header.
|
|
623
|
+
if (!CSP_ENABLED) nonce = undefined;
|
|
540
624
|
// 1. Nested boundary
|
|
541
625
|
if (route && errorDepth !== undefined && route.errorPages?.length) {
|
|
542
626
|
const origin = errorOrigin ?? "page";
|
|
@@ -567,7 +651,17 @@ export async function renderErrorPage(
|
|
|
567
651
|
},
|
|
568
652
|
});
|
|
569
653
|
// csr=false: no client hydration on the error page itself.
|
|
570
|
-
const html = buildHtml(
|
|
654
|
+
const html = buildHtml(
|
|
655
|
+
body,
|
|
656
|
+
head,
|
|
657
|
+
{ status, message },
|
|
658
|
+
layoutData,
|
|
659
|
+
false,
|
|
660
|
+
null,
|
|
661
|
+
undefined,
|
|
662
|
+
true,
|
|
663
|
+
nonce,
|
|
664
|
+
);
|
|
571
665
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
572
666
|
} catch (err) {
|
|
573
667
|
if (isDev) console.error("Nested error page render failed:", err);
|
|
@@ -591,7 +685,17 @@ export async function renderErrorPage(
|
|
|
591
685
|
const { body, head } = render(mod.default, {
|
|
592
686
|
props: { error: { status, message } },
|
|
593
687
|
});
|
|
594
|
-
const html = buildHtml(
|
|
688
|
+
const html = buildHtml(
|
|
689
|
+
body,
|
|
690
|
+
head,
|
|
691
|
+
{ status, message },
|
|
692
|
+
[],
|
|
693
|
+
false,
|
|
694
|
+
null,
|
|
695
|
+
undefined,
|
|
696
|
+
true,
|
|
697
|
+
nonce,
|
|
698
|
+
);
|
|
595
699
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
596
700
|
} catch (err) {
|
|
597
701
|
if (isDev) console.error("Error page render failed:", err);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { join, resolve as resolvePath } from "path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve `untrusted` relative to `base` and verify the result stays inside
|
|
5
|
+
* `base`. Returns the absolute resolved path on success, or `null` when the
|
|
6
|
+
* resolved location escapes the base directory (traversal, absolute path
|
|
7
|
+
* pointing elsewhere, etc.). Use this on every untrusted path segment before
|
|
8
|
+
* touching the filesystem.
|
|
9
|
+
*/
|
|
10
|
+
export function safePath(base: string, untrusted: string): string | null {
|
|
11
|
+
const root = resolvePath(base);
|
|
12
|
+
const full = resolvePath(join(base, untrusted));
|
|
13
|
+
return full.startsWith(root + "/") || full === root ? full : null;
|
|
14
|
+
}
|
package/src/core/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Elysia } from "elysia";
|
|
2
2
|
|
|
3
3
|
import { existsSync, readFileSync } from "fs";
|
|
4
|
-
import { join
|
|
4
|
+
import { join } from "path";
|
|
5
5
|
|
|
6
6
|
import { findMatch, compileRoutes, canonicalPathname } from "./matcher.ts";
|
|
7
7
|
import { apiRoutes, serverRoutes } from "bosia:routes";
|
|
@@ -14,10 +14,12 @@ compileRoutes(serverRoutes);
|
|
|
14
14
|
import type { Handle, RequestEvent } from "./hooks.ts";
|
|
15
15
|
import { HttpError, Redirect, ActionFailure } from "./errors.ts";
|
|
16
16
|
import { CookieJar } from "./cookies.ts";
|
|
17
|
+
import { safePath } from "./safePath.ts";
|
|
17
18
|
import { checkCsrf } from "./csrf.ts";
|
|
18
19
|
import type { CsrfConfig } from "./csrf.ts";
|
|
19
|
-
import { getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
20
|
+
import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
20
21
|
import type { CorsConfig } from "./cors.ts";
|
|
22
|
+
import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
|
|
21
23
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
22
24
|
import { dedup, dedupKey } from "./dedup.ts";
|
|
23
25
|
import {
|
|
@@ -93,6 +95,12 @@ if (_corsAllowedOrigins?.length) {
|
|
|
93
95
|
console.log(`🌐 CORS allowed origins: ${_corsAllowedOrigins.join(", ")}`);
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// ─── CSP Config ──────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
if (CSP_DIRECTIVES_TEMPLATE) {
|
|
101
|
+
console.log(`🔒 CSP: opt-in header active`);
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
// ─── Core Request Resolver ────────────────────────────────
|
|
97
105
|
// This is the inner handler that hooks wrap around.
|
|
98
106
|
|
|
@@ -104,13 +112,6 @@ function isValidRoutePath(path: string, origin: string): boolean {
|
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
/** Resolve a file path and verify it stays within the allowed base directory. Returns null if traversal detected. */
|
|
108
|
-
function safePath(base: string, untrusted: string): string | null {
|
|
109
|
-
const root = resolvePath(base);
|
|
110
|
-
const full = resolvePath(join(base, untrusted));
|
|
111
|
-
return full.startsWith(root + "/") || full === root ? full : null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
115
|
/** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
|
|
115
116
|
function parseActionName(url: URL): string {
|
|
116
117
|
for (const key of url.searchParams.keys()) {
|
|
@@ -359,6 +360,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
359
360
|
`Action "${actionName}" not found`,
|
|
360
361
|
url,
|
|
361
362
|
request,
|
|
363
|
+
undefined,
|
|
364
|
+
undefined,
|
|
365
|
+
undefined,
|
|
366
|
+
undefined,
|
|
367
|
+
locals.nonce,
|
|
362
368
|
);
|
|
363
369
|
}
|
|
364
370
|
|
|
@@ -387,7 +393,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
387
393
|
{ status: err.status },
|
|
388
394
|
);
|
|
389
395
|
}
|
|
390
|
-
return renderErrorPage(
|
|
396
|
+
return renderErrorPage(
|
|
397
|
+
err.status,
|
|
398
|
+
err.message,
|
|
399
|
+
url,
|
|
400
|
+
request,
|
|
401
|
+
undefined,
|
|
402
|
+
undefined,
|
|
403
|
+
undefined,
|
|
404
|
+
undefined,
|
|
405
|
+
locals.nonce,
|
|
406
|
+
);
|
|
391
407
|
}
|
|
392
408
|
throw err;
|
|
393
409
|
}
|
|
@@ -482,7 +498,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
482
498
|
|
|
483
499
|
// SSR pages (+page.svelte) — streaming by default
|
|
484
500
|
const streamResponse = await renderSSRStream(url, locals, request, cookies, pageMatch);
|
|
485
|
-
if (!streamResponse)
|
|
501
|
+
if (!streamResponse)
|
|
502
|
+
return renderErrorPage(
|
|
503
|
+
404,
|
|
504
|
+
"Not Found",
|
|
505
|
+
url,
|
|
506
|
+
request,
|
|
507
|
+
undefined,
|
|
508
|
+
undefined,
|
|
509
|
+
undefined,
|
|
510
|
+
undefined,
|
|
511
|
+
locals.nonce,
|
|
512
|
+
);
|
|
486
513
|
return streamResponse;
|
|
487
514
|
}
|
|
488
515
|
|
|
@@ -518,13 +545,26 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
518
545
|
}
|
|
519
546
|
|
|
520
547
|
const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
|
|
521
|
-
const
|
|
548
|
+
const nonce = CSP_ENABLED ? generateNonce() : "";
|
|
549
|
+
const event: RequestEvent = {
|
|
550
|
+
request,
|
|
551
|
+
url,
|
|
552
|
+
locals: { nonce },
|
|
553
|
+
params: {},
|
|
554
|
+
cookies: cookieJar,
|
|
555
|
+
};
|
|
522
556
|
const response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
|
|
523
557
|
|
|
524
558
|
const headers = new Headers(response.headers);
|
|
525
559
|
for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
|
|
526
|
-
|
|
560
|
+
const cspHeader = buildCspHeader(nonce);
|
|
561
|
+
if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
|
|
562
|
+
// Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
|
|
563
|
+
// CORS is configured — even on responses to non-allowed origins — so
|
|
564
|
+
// downstream caches (CDNs, browser HTTP cache) key on the Origin header
|
|
565
|
+
// instead of serving an Access-Control-Allow-Origin response across origins.
|
|
527
566
|
if (CORS_CONFIG) {
|
|
567
|
+
applyCorsVary(headers);
|
|
528
568
|
const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
|
|
529
569
|
if (corsHeaders) {
|
|
530
570
|
for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
|