bosbun 0.0.1
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/README.md +163 -0
- package/package.json +56 -0
- package/src/cli/add.ts +83 -0
- package/src/cli/build.ts +16 -0
- package/src/cli/create.ts +54 -0
- package/src/cli/dev.ts +14 -0
- package/src/cli/feat.ts +80 -0
- package/src/cli/index.ts +75 -0
- package/src/cli/start.ts +28 -0
- package/src/core/build.ts +157 -0
- package/src/core/client/App.svelte +147 -0
- package/src/core/client/hydrate.ts +78 -0
- package/src/core/client/router.svelte.ts +46 -0
- package/src/core/cookies.ts +52 -0
- package/src/core/cors.ts +60 -0
- package/src/core/csrf.ts +65 -0
- package/src/core/dev.ts +193 -0
- package/src/core/env.ts +135 -0
- package/src/core/envCodegen.ts +94 -0
- package/src/core/errors.ts +23 -0
- package/src/core/hooks.ts +74 -0
- package/src/core/html.ts +170 -0
- package/src/core/matcher.ts +85 -0
- package/src/core/plugin.ts +59 -0
- package/src/core/prerender.ts +79 -0
- package/src/core/renderer.ts +222 -0
- package/src/core/routeFile.ts +88 -0
- package/src/core/routeTypes.ts +95 -0
- package/src/core/scanner.ts +99 -0
- package/src/core/server.ts +320 -0
- package/src/core/types.ts +37 -0
- package/src/lib/index.ts +19 -0
- package/src/lib/utils.ts +24 -0
- package/templates/default/.env.example +34 -0
- package/templates/default/README.md +102 -0
- package/templates/default/package.json +21 -0
- package/templates/default/public/.gitkeep +0 -0
- package/templates/default/src/app.css +132 -0
- package/templates/default/src/app.d.ts +7 -0
- package/templates/default/src/lib/.gitkeep +0 -0
- package/templates/default/src/routes/+error.svelte +18 -0
- package/templates/default/src/routes/+layout.svelte +6 -0
- package/templates/default/src/routes/+page.svelte +36 -0
- package/templates/default/src/routes/about/+page.server.ts +1 -0
- package/templates/default/src/routes/about/+page.svelte +8 -0
- package/templates/default/tsconfig.json +22 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import type { RouteManifest } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
// ─── Route File Generator ─────────────────────────────────
|
|
5
|
+
// Generates .bunia/routes.ts — ONE file with three exports:
|
|
6
|
+
// clientRoutes — used by client hydrator (page + layout lazy imports)
|
|
7
|
+
// serverRoutes — used by SSR renderer (+ pageServer + layoutServers)
|
|
8
|
+
// apiRoutes — used by API handler
|
|
9
|
+
|
|
10
|
+
export function generateRoutesFile(manifest: RouteManifest): void {
|
|
11
|
+
const lines: string[] = [
|
|
12
|
+
"// AUTO-GENERATED by bunia build — do not edit\n",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// clientRoutes
|
|
16
|
+
lines.push("export const clientRoutes: Array<{");
|
|
17
|
+
lines.push(" pattern: string;");
|
|
18
|
+
lines.push(" page: () => Promise<any>;");
|
|
19
|
+
lines.push(" layouts: (() => Promise<any>)[];");
|
|
20
|
+
lines.push(" hasServerData: boolean;");
|
|
21
|
+
lines.push("}> = [");
|
|
22
|
+
for (const r of manifest.pages) {
|
|
23
|
+
const layoutImports = r.layouts
|
|
24
|
+
.map(l => `() => import(${JSON.stringify(toImportPath(l))})`)
|
|
25
|
+
.join(", ");
|
|
26
|
+
const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
|
|
27
|
+
lines.push(" {");
|
|
28
|
+
lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
|
|
29
|
+
lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
|
|
30
|
+
lines.push(` layouts: [${layoutImports}],`);
|
|
31
|
+
lines.push(` hasServerData: ${hasServerData},`);
|
|
32
|
+
lines.push(" },");
|
|
33
|
+
}
|
|
34
|
+
lines.push("];\n");
|
|
35
|
+
|
|
36
|
+
// serverRoutes
|
|
37
|
+
lines.push("export const serverRoutes: Array<{");
|
|
38
|
+
lines.push(" pattern: string;");
|
|
39
|
+
lines.push(" pageModule: () => Promise<any>;");
|
|
40
|
+
lines.push(" layoutModules: (() => Promise<any>)[];");
|
|
41
|
+
lines.push(" pageServer: (() => Promise<any>) | null;");
|
|
42
|
+
lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
|
|
43
|
+
lines.push("}> = [");
|
|
44
|
+
for (const r of manifest.pages) {
|
|
45
|
+
const layoutImports = r.layouts
|
|
46
|
+
.map(l => `() => import(${JSON.stringify(toImportPath(l))})`)
|
|
47
|
+
.join(", ");
|
|
48
|
+
const layoutServerImports = r.layoutServers
|
|
49
|
+
.map(ls => `{ loader: () => import(${JSON.stringify(toImportPath(ls.path))}), depth: ${ls.depth} }`)
|
|
50
|
+
.join(", ");
|
|
51
|
+
lines.push(" {");
|
|
52
|
+
lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
|
|
53
|
+
lines.push(` pageModule: () => import(${JSON.stringify(toImportPath(r.page))}),`);
|
|
54
|
+
lines.push(` layoutModules: [${layoutImports}],`);
|
|
55
|
+
lines.push(` pageServer: ${r.pageServer ? `() => import(${JSON.stringify(toImportPath(r.pageServer))})` : "null"},`);
|
|
56
|
+
lines.push(` layoutServers: [${layoutServerImports}],`);
|
|
57
|
+
lines.push(" },");
|
|
58
|
+
}
|
|
59
|
+
lines.push("];\n");
|
|
60
|
+
|
|
61
|
+
// apiRoutes
|
|
62
|
+
lines.push("export const apiRoutes: Array<{");
|
|
63
|
+
lines.push(" pattern: string;");
|
|
64
|
+
lines.push(" module: () => Promise<any>;");
|
|
65
|
+
lines.push("}> = [");
|
|
66
|
+
for (const r of manifest.apis) {
|
|
67
|
+
lines.push(" {");
|
|
68
|
+
lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
|
|
69
|
+
lines.push(` module: () => import(${JSON.stringify(toImportPath(r.server))}),`);
|
|
70
|
+
lines.push(" },");
|
|
71
|
+
}
|
|
72
|
+
lines.push("];\n");
|
|
73
|
+
|
|
74
|
+
// errorPage
|
|
75
|
+
const ep = manifest.errorPage;
|
|
76
|
+
lines.push(`export const errorPage: (() => Promise<any>) | null = ${
|
|
77
|
+
ep ? `() => import(${JSON.stringify(toImportPath(ep))})` : "null"
|
|
78
|
+
};\n`);
|
|
79
|
+
|
|
80
|
+
mkdirSync(".bunia", { recursive: true });
|
|
81
|
+
writeFileSync(".bunia/routes.ts", lines.join("\n"));
|
|
82
|
+
console.log("✅ Routes generated: .bunia/routes.ts");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Import path from .bunia/routes.ts to src/routes/<routePath>
|
|
86
|
+
function toImportPath(routePath: string): string {
|
|
87
|
+
return "../src/routes/" + routePath.replace(/\\/g, "/");
|
|
88
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import type { RouteManifest } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
// ─── Route Types Generator ────────────────────────────────
|
|
6
|
+
// Generates .bunia/types/src/routes/**/$types.d.ts for each
|
|
7
|
+
// route directory. Combined with rootDirs in tsconfig.json,
|
|
8
|
+
// this allows `import type { PageData } from './$types'` to
|
|
9
|
+
// work in +page.svelte files — identical to SvelteKit's API.
|
|
10
|
+
|
|
11
|
+
function routeDirOf(filePath: string): string {
|
|
12
|
+
const parts = filePath.replace(/\\/g, "/").split("/");
|
|
13
|
+
parts.pop();
|
|
14
|
+
return parts.join("/") || ".";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function generateRouteTypes(manifest: RouteManifest): void {
|
|
18
|
+
// Collect { dir → { pageServer?, layoutServer? } }
|
|
19
|
+
const dirs = new Map<string, { pageServer?: string; layoutServer?: string }>();
|
|
20
|
+
|
|
21
|
+
for (const route of manifest.pages) {
|
|
22
|
+
const pageDir = routeDirOf(route.page);
|
|
23
|
+
if (!dirs.has(pageDir)) dirs.set(pageDir, {});
|
|
24
|
+
if (route.pageServer) {
|
|
25
|
+
dirs.get(pageDir)!.pageServer = route.pageServer;
|
|
26
|
+
}
|
|
27
|
+
for (const ls of route.layoutServers) {
|
|
28
|
+
const lsDir = routeDirOf(ls.path);
|
|
29
|
+
if (!dirs.has(lsDir)) dirs.set(lsDir, {});
|
|
30
|
+
dirs.get(lsDir)!.layoutServer = ls.path;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const [dir, info] of dirs) {
|
|
35
|
+
// Path segments of the route dir (empty array for root ".")
|
|
36
|
+
const segments = dir === "." ? [] : dir.split("/").filter(Boolean);
|
|
37
|
+
|
|
38
|
+
// Depth of the generated file from project root:
|
|
39
|
+
// .bunia/ + types/ + src/ + routes/ + ...segments
|
|
40
|
+
const depth = 4 + segments.length;
|
|
41
|
+
const up = "../".repeat(depth);
|
|
42
|
+
const srcBase = `${up}src/routes/${segments.length ? segments.join("/") + "/" : ""}`;
|
|
43
|
+
|
|
44
|
+
const lines: string[] = ["// AUTO-GENERATED by bunia — do not edit\n"];
|
|
45
|
+
|
|
46
|
+
if (info.pageServer) {
|
|
47
|
+
lines.push(`import type { load as _pageLoad } from '${srcBase}+page.server.ts';`);
|
|
48
|
+
lines.push(`export type PageData = Awaited<ReturnType<typeof _pageLoad>> & { params: Record<string, string> };`);
|
|
49
|
+
} else {
|
|
50
|
+
lines.push(`export type PageData = { params: Record<string, string> };`);
|
|
51
|
+
}
|
|
52
|
+
lines.push(`export type PageProps = { data: PageData };`);
|
|
53
|
+
|
|
54
|
+
if (info.layoutServer) {
|
|
55
|
+
lines.push(`\nimport type { load as _layoutLoad } from '${srcBase}+layout.server.ts';`);
|
|
56
|
+
lines.push(`export type LayoutData = Awaited<ReturnType<typeof _layoutLoad>> & { params: Record<string, string> };`);
|
|
57
|
+
lines.push(`export type LayoutProps = { data: LayoutData; children: any };`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const outDir = join(process.cwd(), ".bunia", "types", "src", "routes", ...segments);
|
|
61
|
+
mkdirSync(outDir, { recursive: true });
|
|
62
|
+
writeFileSync(join(outDir, "$types.d.ts"), lines.join("\n") + "\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`✅ Route types generated: .bunia/types/ (${dirs.size} route director${dirs.size === 1 ? "y" : "ies"})`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Ensure tsconfig rootDirs ─────────────────────────────
|
|
69
|
+
// Adds ".bunia/types" to rootDirs so TypeScript resolves
|
|
70
|
+
// `./$types` imports via the generated declaration files.
|
|
71
|
+
// Only patches the file if rootDirs is not already set.
|
|
72
|
+
|
|
73
|
+
export function ensureRootDirs(): void {
|
|
74
|
+
const tsconfigPath = join(process.cwd(), "tsconfig.json");
|
|
75
|
+
if (!existsSync(tsconfigPath)) return;
|
|
76
|
+
|
|
77
|
+
let tsconfig: any;
|
|
78
|
+
try {
|
|
79
|
+
tsconfig = JSON.parse(readFileSync(tsconfigPath, "utf-8"));
|
|
80
|
+
} catch {
|
|
81
|
+
console.warn("⚠️ Could not parse tsconfig.json — add rootDirs manually:\n" +
|
|
82
|
+
' "rootDirs": [".", ".bunia/types"]');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rootDirs: string[] = tsconfig.compilerOptions?.rootDirs ?? [];
|
|
87
|
+
if (rootDirs.includes(".bunia/types")) return;
|
|
88
|
+
|
|
89
|
+
tsconfig.compilerOptions ??= {};
|
|
90
|
+
tsconfig.compilerOptions.rootDirs = [".", ".bunia/types",
|
|
91
|
+
...rootDirs.filter((d: string) => d !== ".")];
|
|
92
|
+
|
|
93
|
+
writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
|
|
94
|
+
console.log("✅ tsconfig.json: added .bunia/types to rootDirs");
|
|
95
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readdirSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import type { PageRoute, ApiRoute, RouteManifest } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
// ─── Route Scanner ───────────────────────────────────────
|
|
6
|
+
// Walks src/routes/ and produces a RouteManifest.
|
|
7
|
+
//
|
|
8
|
+
// Conventions (SvelteKit-compatible):
|
|
9
|
+
// +page.svelte — page component
|
|
10
|
+
// +page.server.ts — server loader for the page
|
|
11
|
+
// +layout.svelte — layout component (wraps all children)
|
|
12
|
+
// +layout.server.ts — server loader for the layout
|
|
13
|
+
// +server.ts — API route (GET, POST, etc.)
|
|
14
|
+
// (group)/ — route group: invisible in URL, shares layouts
|
|
15
|
+
// [param]/ — dynamic segment
|
|
16
|
+
// [...rest]/ — catch-all segment
|
|
17
|
+
|
|
18
|
+
const ROUTES_DIR = "./src/routes";
|
|
19
|
+
|
|
20
|
+
export function scanRoutes(): RouteManifest {
|
|
21
|
+
const pages: PageRoute[] = [];
|
|
22
|
+
const apis: ApiRoute[] = [];
|
|
23
|
+
|
|
24
|
+
function walk(
|
|
25
|
+
dir: string,
|
|
26
|
+
urlSegments: string[],
|
|
27
|
+
layoutChain: string[],
|
|
28
|
+
layoutServerChain: { path: string; depth: number }[],
|
|
29
|
+
) {
|
|
30
|
+
const fullDir = join(ROUTES_DIR, dir);
|
|
31
|
+
if (!existsSync(fullDir)) return;
|
|
32
|
+
|
|
33
|
+
const items = readdirSync(fullDir, { withFileTypes: true });
|
|
34
|
+
|
|
35
|
+
// Accumulate layouts for this level
|
|
36
|
+
const currentLayouts = [...layoutChain];
|
|
37
|
+
const currentLayoutServers = [...layoutServerChain];
|
|
38
|
+
|
|
39
|
+
if (items.some(i => i.isFile() && i.name === "+layout.svelte")) {
|
|
40
|
+
currentLayouts.push(join(dir, "+layout.svelte"));
|
|
41
|
+
}
|
|
42
|
+
if (items.some(i => i.isFile() && i.name === "+layout.server.ts")) {
|
|
43
|
+
currentLayoutServers.push({
|
|
44
|
+
path: join(dir, "+layout.server.ts"),
|
|
45
|
+
depth: currentLayouts.length - 1,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// API route (+server.ts)
|
|
50
|
+
if (items.some(i => i.isFile() && i.name === "+server.ts")) {
|
|
51
|
+
apis.push({
|
|
52
|
+
pattern: toUrlPath(urlSegments),
|
|
53
|
+
server: join(dir, "+server.ts"),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Page route (+page.svelte)
|
|
58
|
+
if (items.some(i => i.isFile() && i.name === "+page.svelte")) {
|
|
59
|
+
const pageServerFile = items.some(i => i.isFile() && i.name === "+page.server.ts")
|
|
60
|
+
? join(dir, "+page.server.ts")
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
pages.push({
|
|
64
|
+
pattern: toUrlPath(urlSegments),
|
|
65
|
+
page: join(dir, "+page.svelte"),
|
|
66
|
+
layouts: [...currentLayouts],
|
|
67
|
+
pageServer: pageServerFile,
|
|
68
|
+
layoutServers: [...currentLayoutServers],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Recurse into subdirectories
|
|
73
|
+
for (const entry of items) {
|
|
74
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
75
|
+
|
|
76
|
+
const dirName = entry.name;
|
|
77
|
+
// Route groups like (public), (auth) are invisible in URLs
|
|
78
|
+
const isGroup = /^\(.*\)$/.test(dirName);
|
|
79
|
+
|
|
80
|
+
walk(
|
|
81
|
+
dir ? join(dir, dirName) : dirName,
|
|
82
|
+
isGroup ? [...urlSegments] : [...urlSegments, dirName],
|
|
83
|
+
currentLayouts,
|
|
84
|
+
currentLayoutServers,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
walk("", [], [], []);
|
|
90
|
+
|
|
91
|
+
const errorPage = existsSync(join(ROUTES_DIR, "+error.svelte")) ? "+error.svelte" : null;
|
|
92
|
+
|
|
93
|
+
return { pages, apis, errorPage };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toUrlPath(segments: string[]): string {
|
|
97
|
+
if (segments.length === 0) return "/";
|
|
98
|
+
return "/" + segments.join("/");
|
|
99
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { Elysia } from "elysia";
|
|
2
|
+
import { staticPlugin } from "@elysiajs/static";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
import { findMatch } from "./matcher.ts";
|
|
7
|
+
import { apiRoutes } from "bunia:routes";
|
|
8
|
+
import type { Handle, RequestEvent } from "./hooks.ts";
|
|
9
|
+
import { HttpError, Redirect } from "./errors.ts";
|
|
10
|
+
import { CookieJar } from "./cookies.ts";
|
|
11
|
+
import { checkCsrf } from "./csrf.ts";
|
|
12
|
+
import type { CsrfConfig } from "./csrf.ts";
|
|
13
|
+
import { getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
14
|
+
import type { CorsConfig } from "./cors.ts";
|
|
15
|
+
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
16
|
+
import { loadRouteData, renderSSRStream, renderErrorPage } from "./renderer.ts";
|
|
17
|
+
import { getServerTime } from "../lib/utils.ts";
|
|
18
|
+
|
|
19
|
+
// ─── User Hooks ──────────────────────────────────────────
|
|
20
|
+
// Load src/hooks.server.ts if present. Uses process.cwd() so
|
|
21
|
+
// Bun can resolve it at runtime without bundling user code.
|
|
22
|
+
|
|
23
|
+
let userHandle: Handle | null = null;
|
|
24
|
+
|
|
25
|
+
const hooksPath = join(process.cwd(), "src", "hooks.server.ts");
|
|
26
|
+
if (existsSync(hooksPath)) {
|
|
27
|
+
try {
|
|
28
|
+
const mod = await import(hooksPath);
|
|
29
|
+
if (typeof mod.handle === "function") {
|
|
30
|
+
userHandle = mod.handle as Handle;
|
|
31
|
+
console.log("🪝 Loaded hooks.server.ts");
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.warn("⚠️ Failed to load hooks.server.ts:", err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── CSRF Config ─────────────────────────────────────────
|
|
39
|
+
// Parsed once at startup from CSRF_ALLOWED_ORIGINS env var.
|
|
40
|
+
// Format: "https://x.com, https://y.com" — commas with or without spaces.
|
|
41
|
+
|
|
42
|
+
const _csrfAllowedOrigins = process.env.CSRF_ALLOWED_ORIGINS
|
|
43
|
+
?.split(",")
|
|
44
|
+
.map(s => s.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
|
|
47
|
+
const CSRF_CONFIG: CsrfConfig = {
|
|
48
|
+
checkOrigin: true,
|
|
49
|
+
allowedOrigins: _csrfAllowedOrigins,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (_csrfAllowedOrigins?.length) {
|
|
53
|
+
console.log(`🛡️ CSRF allowed origins: ${_csrfAllowedOrigins.join(", ")}`);
|
|
54
|
+
} else {
|
|
55
|
+
console.log("🛡️ CSRF: same-origin only");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── CORS Config ──────────────────────────────────────────
|
|
59
|
+
// Parsed once at startup from CORS_ALLOWED_ORIGINS env var.
|
|
60
|
+
// Format: "https://x.com, https://y.com" — commas with or without spaces.
|
|
61
|
+
|
|
62
|
+
const _corsAllowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
|
63
|
+
?.split(",")
|
|
64
|
+
.map(s => s.trim())
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
|
|
67
|
+
function splitCsvEnv(key: string): string[] | undefined {
|
|
68
|
+
return process.env[key]?.split(",").map(s => s.trim()).filter(Boolean) || undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CORS_CONFIG: CorsConfig | null = _corsAllowedOrigins?.length
|
|
72
|
+
? {
|
|
73
|
+
allowedOrigins: _corsAllowedOrigins,
|
|
74
|
+
allowedMethods: splitCsvEnv("CORS_ALLOWED_METHODS"),
|
|
75
|
+
allowedHeaders: splitCsvEnv("CORS_ALLOWED_HEADERS"),
|
|
76
|
+
exposedHeaders: splitCsvEnv("CORS_EXPOSED_HEADERS"),
|
|
77
|
+
credentials: process.env.CORS_CREDENTIALS === "true" || undefined,
|
|
78
|
+
maxAge: process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : undefined,
|
|
79
|
+
}
|
|
80
|
+
: null;
|
|
81
|
+
|
|
82
|
+
if (_corsAllowedOrigins?.length) {
|
|
83
|
+
console.log(`🌐 CORS allowed origins: ${_corsAllowedOrigins.join(", ")}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Core Request Resolver ────────────────────────────────
|
|
87
|
+
// This is the inner handler that hooks wrap around.
|
|
88
|
+
|
|
89
|
+
function isValidRoutePath(path: string, origin: string): boolean {
|
|
90
|
+
try {
|
|
91
|
+
return new URL(path, origin).origin === origin;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function resolve(event: RequestEvent): Promise<Response> {
|
|
98
|
+
const { request, url, locals, cookies } = event;
|
|
99
|
+
const path = url.pathname;
|
|
100
|
+
const method = request.method.toUpperCase();
|
|
101
|
+
|
|
102
|
+
// Health check endpoint — for load balancers and orchestrators
|
|
103
|
+
if (path === "/_health") {
|
|
104
|
+
const { timestamp, timezone } = getServerTime();
|
|
105
|
+
return Response.json({ status: "ok", timestamp, timezone });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Data endpoint — returns server loader data as JSON for client-side navigation
|
|
109
|
+
if (path === "/__bunia/data") {
|
|
110
|
+
const routePath = url.searchParams.get("path") ?? "/";
|
|
111
|
+
if (!isValidRoutePath(routePath, url.origin)) {
|
|
112
|
+
return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
|
|
113
|
+
}
|
|
114
|
+
const routeUrl = new URL(routePath, url.origin);
|
|
115
|
+
// Rewrite event.url so logging middleware sees the real page path, not /__bunia/data
|
|
116
|
+
event.url = routeUrl;
|
|
117
|
+
try {
|
|
118
|
+
const data = await loadRouteData(routeUrl, locals, request, cookies);
|
|
119
|
+
if (!data) return compress(JSON.stringify({ pageData: {}, layoutData: [] }), "application/json", request);
|
|
120
|
+
return compress(JSON.stringify(data), "application/json", request);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof Redirect) {
|
|
123
|
+
return compress(JSON.stringify({ redirect: err.location, status: err.status }), "application/json", request);
|
|
124
|
+
}
|
|
125
|
+
if (err instanceof HttpError) {
|
|
126
|
+
return compress(JSON.stringify({ error: err.message, status: err.status }), "application/json", request, err.status);
|
|
127
|
+
}
|
|
128
|
+
if (isDev) console.error("Data endpoint error:", err);
|
|
129
|
+
else console.error("Data endpoint error:", (err as Error).message ?? err);
|
|
130
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Static files
|
|
135
|
+
if (isStaticPath(path)) {
|
|
136
|
+
// dist/client: serve with cache headers based on whether filename is hashed
|
|
137
|
+
if (path.startsWith("/dist/client/")) {
|
|
138
|
+
const file = Bun.file(`.${path.split("?")[0]}`);
|
|
139
|
+
if (await file.exists()) {
|
|
140
|
+
const filename = path.split("/").pop() ?? "";
|
|
141
|
+
const isHashed = /\-[a-z0-9]{8,}\.[a-z]+$/.test(filename);
|
|
142
|
+
const cacheControl = !isDev && isHashed
|
|
143
|
+
? "public, max-age=31536000, immutable"
|
|
144
|
+
: "no-cache";
|
|
145
|
+
return new Response(file, { headers: { "Cache-Control": cacheControl } });
|
|
146
|
+
}
|
|
147
|
+
return new Response("Not Found", { status: 404 });
|
|
148
|
+
}
|
|
149
|
+
const pub = Bun.file(`./public${path}`);
|
|
150
|
+
if (await pub.exists()) return new Response(pub);
|
|
151
|
+
const dist = Bun.file(`.${path}`);
|
|
152
|
+
if (await dist.exists()) return new Response(dist);
|
|
153
|
+
return new Response("Not Found", { status: 404 });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Prerendered pages — serve static HTML built at build time
|
|
157
|
+
const prerenderFile = Bun.file(
|
|
158
|
+
path === "/" ? "./dist/prerendered/index.html" : `./dist/prerendered${path}/index.html`
|
|
159
|
+
);
|
|
160
|
+
if (await prerenderFile.exists()) {
|
|
161
|
+
return new Response(prerenderFile, {
|
|
162
|
+
headers: {
|
|
163
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
164
|
+
"Cache-Control": "public, max-age=3600",
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// API routes (+server.ts)
|
|
170
|
+
const apiMatch = findMatch(apiRoutes, path);
|
|
171
|
+
if (apiMatch) {
|
|
172
|
+
try {
|
|
173
|
+
const mod = await apiMatch.route.module();
|
|
174
|
+
const handler = mod[method];
|
|
175
|
+
|
|
176
|
+
if (!handler) {
|
|
177
|
+
const allowed = Object.keys(mod).filter(k => /^[A-Z]+$/.test(k)).join(", ");
|
|
178
|
+
return Response.json(
|
|
179
|
+
{ error: `Method ${method} not allowed` },
|
|
180
|
+
{ status: 405, headers: { Allow: allowed } },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
event.params = apiMatch.params;
|
|
185
|
+
return await handler({ request, params: apiMatch.params, url, locals, cookies });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (isDev) console.error("API route error:", err);
|
|
188
|
+
else console.error("API route error:", (err as Error).message ?? err);
|
|
189
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// SSR pages (+page.svelte) — streaming by default
|
|
194
|
+
const streamResponse = renderSSRStream(url, locals, request, cookies);
|
|
195
|
+
if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
|
|
196
|
+
return streamResponse;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Request Entry ────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const SECURITY_HEADERS: Record<string, string> = {
|
|
202
|
+
"X-Content-Type-Options": "nosniff",
|
|
203
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
204
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
208
|
+
try {
|
|
209
|
+
// Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
|
|
210
|
+
if (CORS_CONFIG && request.method === "OPTIONS") {
|
|
211
|
+
const preflight = handlePreflight(request, CORS_CONFIG);
|
|
212
|
+
if (preflight) return preflight;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const csrfError = checkCsrf(request, url, CSRF_CONFIG);
|
|
216
|
+
if (csrfError) {
|
|
217
|
+
console.warn(`[CSRF] Blocked request: ${csrfError}`);
|
|
218
|
+
return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const cookieJar = new CookieJar(request.headers.get("cookie") ?? "");
|
|
222
|
+
const event: RequestEvent = { request, url, locals: {}, params: {}, cookies: cookieJar };
|
|
223
|
+
const response = userHandle
|
|
224
|
+
? await userHandle({ event, resolve })
|
|
225
|
+
: await resolve(event);
|
|
226
|
+
|
|
227
|
+
const headers = new Headers(response.headers);
|
|
228
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
|
|
229
|
+
// Apply CORS headers for allowed origins
|
|
230
|
+
if (CORS_CONFIG) {
|
|
231
|
+
const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
|
|
232
|
+
if (corsHeaders) {
|
|
233
|
+
for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Apply any Set-Cookie headers accumulated during the request
|
|
237
|
+
for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
|
|
238
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (isDev) console.error("Unhandled request error:", err);
|
|
241
|
+
else console.error("Unhandled request error:", (err as Error).message ?? err);
|
|
242
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── Body Size Limit ──────────────────────────────────────
|
|
247
|
+
// Parsed once at startup from BODY_SIZE_LIMIT env var.
|
|
248
|
+
// Format: "512K", "1M", "1G", plain bytes, or "Infinity".
|
|
249
|
+
// Default: 512K (matches SvelteKit).
|
|
250
|
+
|
|
251
|
+
function parseBodySizeLimit(value?: string): number {
|
|
252
|
+
if (!value) return 512 * 1024;
|
|
253
|
+
if (value === "Infinity") return 0; // Bun: 0 = no limit
|
|
254
|
+
const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
|
|
255
|
+
if (!match) throw new Error(`Invalid BODY_SIZE_LIMIT: "${value}"`);
|
|
256
|
+
const num = parseFloat(match[1]);
|
|
257
|
+
const unit = match[2].toUpperCase();
|
|
258
|
+
if (unit === "K") return Math.floor(num * 1024);
|
|
259
|
+
if (unit === "M") return Math.floor(num * 1024 * 1024);
|
|
260
|
+
if (unit === "G") return Math.floor(num * 1024 * 1024 * 1024);
|
|
261
|
+
return Math.floor(num);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const BODY_SIZE_LIMIT = parseBodySizeLimit(process.env.BODY_SIZE_LIMIT);
|
|
265
|
+
|
|
266
|
+
if (BODY_SIZE_LIMIT === 0) {
|
|
267
|
+
console.log("📦 Body size limit: none");
|
|
268
|
+
} else {
|
|
269
|
+
console.log(`📦 Body size limit: ${BODY_SIZE_LIMIT} bytes`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Elysia App ───────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : (isDev ? 9001 : 9000);
|
|
275
|
+
|
|
276
|
+
const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
|
|
277
|
+
.onError(({ error }) => {
|
|
278
|
+
if (isDev) console.error("Uncaught server error:", error);
|
|
279
|
+
else console.error("Uncaught server error:", (error as Error)?.message ?? error);
|
|
280
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
281
|
+
})
|
|
282
|
+
.use(staticPlugin({ assets: "public", prefix: "/" }))
|
|
283
|
+
// dist/client is served by our resolve() handler with proper cache headers
|
|
284
|
+
// API routes must intercept all HTTP methods before the GET catch-all
|
|
285
|
+
.onBeforeHandle(async ({ request }) => {
|
|
286
|
+
const url = new URL(request.url);
|
|
287
|
+
if (!findMatch(apiRoutes, url.pathname)) return; // not an API route
|
|
288
|
+
return handleRequest(request, url);
|
|
289
|
+
})
|
|
290
|
+
// SSR pages
|
|
291
|
+
.get("*", ({ request }) => {
|
|
292
|
+
const url = new URL(request.url);
|
|
293
|
+
return handleRequest(request, url);
|
|
294
|
+
})
|
|
295
|
+
// Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
|
|
296
|
+
.post("*", () => new Response("Not Found", { status: 404 }))
|
|
297
|
+
.put("*", () => new Response("Not Found", { status: 404 }))
|
|
298
|
+
.patch("*", () => new Response("Not Found", { status: 404 }))
|
|
299
|
+
.delete("*", () => new Response("Not Found", { status: 404 }))
|
|
300
|
+
.options("*", ({ request }) => {
|
|
301
|
+
const url = new URL(request.url);
|
|
302
|
+
return handleRequest(request, url);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
app.listen(PORT, () => {
|
|
306
|
+
// In dev mode the proxy owns the user-facing port — don't print the internal port
|
|
307
|
+
if (!isDev) console.log(`🐰 Bunia server running at http://localhost:${PORT}`);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
function shutdown() {
|
|
311
|
+
console.log("Shutting down...");
|
|
312
|
+
app.stop().then(() => process.exit(0));
|
|
313
|
+
// Force exit if stop hangs
|
|
314
|
+
setTimeout(() => process.exit(1), 10_000);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
process.on("SIGTERM", shutdown);
|
|
318
|
+
process.on("SIGINT", shutdown);
|
|
319
|
+
|
|
320
|
+
export { app };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// ─── Bunia Core Types ────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/** A page route discovered from the file system */
|
|
4
|
+
export interface PageRoute {
|
|
5
|
+
/** URL pattern, e.g. "/" or "/blog/[slug]" or "/[...rest]" */
|
|
6
|
+
pattern: string;
|
|
7
|
+
/** Path to +page.svelte, relative to src/routes/ */
|
|
8
|
+
page: string;
|
|
9
|
+
/** Chain of +layout.svelte paths root → leaf, relative to src/routes/ */
|
|
10
|
+
layouts: string[];
|
|
11
|
+
/** Path to +page.server.ts if exists, relative to src/routes/ */
|
|
12
|
+
pageServer: string | null;
|
|
13
|
+
/** Chain of +layout.server.ts files root → leaf, with their layout depth */
|
|
14
|
+
layoutServers: { path: string; depth: number }[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** An API route discovered from the file system */
|
|
18
|
+
export interface ApiRoute {
|
|
19
|
+
/** URL pattern, e.g. "/api/hello" or "/api/users/[id]" */
|
|
20
|
+
pattern: string;
|
|
21
|
+
/** Path to +server.ts, relative to src/routes/ */
|
|
22
|
+
server: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The full route manifest produced by the scanner */
|
|
26
|
+
export interface RouteManifest {
|
|
27
|
+
pages: PageRoute[];
|
|
28
|
+
apis: ApiRoute[];
|
|
29
|
+
/** Path to root +error.svelte if it exists, relative to src/routes/ */
|
|
30
|
+
errorPage: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Result of matching a URL against a route */
|
|
34
|
+
export interface RouteMatch<T> {
|
|
35
|
+
route: T;
|
|
36
|
+
params: Record<string, string>;
|
|
37
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// ─── Bunia Public API ─────────────────────────────────────
|
|
2
|
+
// Usage in user apps:
|
|
3
|
+
// import { cn, sequence } from "bunia"
|
|
4
|
+
// import type { RequestEvent, LoadEvent, Handle, Cookies } from "bunia"
|
|
5
|
+
|
|
6
|
+
export { cn, getServerTime } from "./utils.ts";
|
|
7
|
+
export { sequence } from "../core/hooks.ts";
|
|
8
|
+
export { error, redirect } from "../core/errors.ts";
|
|
9
|
+
export type { HttpError, Redirect } from "../core/errors.ts";
|
|
10
|
+
export type {
|
|
11
|
+
RequestEvent,
|
|
12
|
+
LoadEvent,
|
|
13
|
+
Handle,
|
|
14
|
+
ResolveFunction,
|
|
15
|
+
Cookies,
|
|
16
|
+
CookieOptions,
|
|
17
|
+
} from "../core/hooks.ts";
|
|
18
|
+
export type { CsrfConfig } from "../core/csrf.ts";
|
|
19
|
+
export type { CorsConfig } from "../core/cors.ts";
|