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
package/src/core/html.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
// ─── Dist Manifest ───────────────────────────────────────
|
|
4
|
+
// Maps hashed filenames → script/link tags.
|
|
5
|
+
// Cached at startup; server restarts on rebuild in dev anyway.
|
|
6
|
+
|
|
7
|
+
export const distManifest: { js: string[]; css: string[]; entry: string } = (() => {
|
|
8
|
+
const p = "./dist/manifest.json";
|
|
9
|
+
return existsSync(p)
|
|
10
|
+
? JSON.parse(readFileSync(p, "utf-8"))
|
|
11
|
+
: { js: [], css: [], entry: "hydrate.js" };
|
|
12
|
+
})();
|
|
13
|
+
|
|
14
|
+
export const isDev = process.env.NODE_ENV !== "production";
|
|
15
|
+
|
|
16
|
+
// ─── Safe JSON Serialization ──────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Escapes JSON for safe embedding inside <script> tags. Prevents XSS via </script> injection. */
|
|
19
|
+
export function safeJsonStringify(data: unknown): string {
|
|
20
|
+
const map: Record<string, string> = {
|
|
21
|
+
"<": "\\u003c",
|
|
22
|
+
">": "\\u003e",
|
|
23
|
+
"&": "\\u0026",
|
|
24
|
+
"\u2028": "\\u2028",
|
|
25
|
+
"\u2029": "\\u2029",
|
|
26
|
+
};
|
|
27
|
+
return JSON.stringify(data).replace(/[<>&\u2028\u2029]/g, c => map[c]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Public Env Injection ─────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Collect PUBLIC_* (non-static) vars from process.env that were declared in .bunia/env.server.ts.
|
|
34
|
+
* We read the generated server env module to know which keys to expose.
|
|
35
|
+
* Falls back to an empty object if the module hasn't been generated yet (e.g., dev before first build).
|
|
36
|
+
*/
|
|
37
|
+
function getPublicDynamicEnv(): Record<string, string> {
|
|
38
|
+
// Read keys from .bunia/env.server.ts declarations of PUBLIC_* (non-static) vars
|
|
39
|
+
// by inspecting process.env keys that start with PUBLIC_ but not PUBLIC_STATIC_.
|
|
40
|
+
// We only expose keys that came from .env files — tracked in process.env via loadEnv.
|
|
41
|
+
// At runtime the server module exports are inlined; we collect from process.env here.
|
|
42
|
+
const result: Record<string, string> = {};
|
|
43
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
44
|
+
if (key.startsWith("PUBLIC_") && !key.startsWith("PUBLIC_STATIC_") && value !== undefined) {
|
|
45
|
+
result[key] = value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── HTML Builder ─────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export function buildHtml(
|
|
54
|
+
body: string,
|
|
55
|
+
head: string,
|
|
56
|
+
pageData: any,
|
|
57
|
+
layoutData: any[],
|
|
58
|
+
csr = true,
|
|
59
|
+
): string {
|
|
60
|
+
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
61
|
+
|
|
62
|
+
const cssLinks = (distManifest.css ?? [])
|
|
63
|
+
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
64
|
+
.join("\n ");
|
|
65
|
+
|
|
66
|
+
const fallbackTitle = head.includes("<title>") ? "" : "<title>Bunia App</title>";
|
|
67
|
+
|
|
68
|
+
const publicEnv = getPublicDynamicEnv();
|
|
69
|
+
const envScript = Object.keys(publicEnv).length > 0
|
|
70
|
+
? `\n <script>window.__BUNIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
|
|
71
|
+
: "";
|
|
72
|
+
|
|
73
|
+
const scripts = csr
|
|
74
|
+
? `${envScript}\n <script>window.__BUNIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BUNIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};</script>\n <script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
|
|
75
|
+
: isDev
|
|
76
|
+
? `\n <script>!function r(){var e=new EventSource("/__bunia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
|
|
77
|
+
: "";
|
|
78
|
+
|
|
79
|
+
return `<!DOCTYPE html>
|
|
80
|
+
<html lang="en">
|
|
81
|
+
<head>
|
|
82
|
+
<meta charset="UTF-8">
|
|
83
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
84
|
+
${fallbackTitle}
|
|
85
|
+
<link rel="icon" href="data:,">
|
|
86
|
+
${head}
|
|
87
|
+
${cssLinks}
|
|
88
|
+
<link rel="stylesheet" href="/bunia-tw.css${cacheBust}">
|
|
89
|
+
</head>
|
|
90
|
+
<body>
|
|
91
|
+
<div id="app">${body}</div>${scripts}
|
|
92
|
+
</body>
|
|
93
|
+
</html>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Streaming HTML Helpers ──────────────────────────────
|
|
97
|
+
|
|
98
|
+
let _shell: string | null = null;
|
|
99
|
+
|
|
100
|
+
export function buildHtmlShell(): string {
|
|
101
|
+
if (_shell) return _shell;
|
|
102
|
+
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
103
|
+
const cssLinks = (distManifest.css ?? [])
|
|
104
|
+
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
105
|
+
.join("\n ");
|
|
106
|
+
_shell = `<!DOCTYPE html>\n<html lang="en">\n<head>\n` +
|
|
107
|
+
` <meta charset="UTF-8">\n` +
|
|
108
|
+
` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
109
|
+
` <link rel="icon" href="data:,">\n` +
|
|
110
|
+
` ${cssLinks}\n` +
|
|
111
|
+
` <link rel="stylesheet" href="/bunia-tw.css${cacheBust}">\n` +
|
|
112
|
+
` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">\n` +
|
|
113
|
+
`</head>\n<body>\n` +
|
|
114
|
+
`<div id="__bs__"><style>` +
|
|
115
|
+
`:root{--bunia-loading-color:#f73b27}` +
|
|
116
|
+
`#__bs__{position:fixed;inset:0;display:flex;align-items:center;justify-content:center}` +
|
|
117
|
+
`#__bs__ i{width:32px;height:32px;border:3px solid #e5e7eb;border-top-color:var(--bunia-loading-color);` +
|
|
118
|
+
`border-radius:50%;animation:__bs__ .8s linear infinite}` +
|
|
119
|
+
`@keyframes __bs__{to{transform:rotate(360deg)}}</style><i></i></div>`;
|
|
120
|
+
return _shell;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildHtmlTail(
|
|
124
|
+
body: string,
|
|
125
|
+
head: string,
|
|
126
|
+
pageData: any,
|
|
127
|
+
layoutData: any[],
|
|
128
|
+
csr: boolean,
|
|
129
|
+
): string {
|
|
130
|
+
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
131
|
+
let out = `<script>document.getElementById('__bs__').remove()</script>`;
|
|
132
|
+
out += `\n<div id="app">${body}</div>`;
|
|
133
|
+
if (head) out += `\n<script>document.head.innerHTML+=${safeJsonStringify(head)}</script>`;
|
|
134
|
+
if (csr) {
|
|
135
|
+
const publicEnv = getPublicDynamicEnv();
|
|
136
|
+
if (Object.keys(publicEnv).length > 0) {
|
|
137
|
+
out += `\n<script>window.__BUNIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
|
|
138
|
+
}
|
|
139
|
+
out += `\n<script>window.__BUNIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
|
|
140
|
+
`window.__BUNIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};</script>`;
|
|
141
|
+
out += `\n<script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
|
|
142
|
+
} else if (isDev) {
|
|
143
|
+
out += `\n<script>!function r(){var e=new EventSource("/__bunia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
|
|
144
|
+
}
|
|
145
|
+
out += `\n</body>\n</html>`;
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Gzip Compression ────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export function compress(body: string, contentType: string, req: Request, status = 200): Response {
|
|
152
|
+
const headers: Record<string, string> = { "Content-Type": contentType, "Vary": "Accept-Encoding" };
|
|
153
|
+
const accept = req.headers.get("accept-encoding") ?? "";
|
|
154
|
+
// Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
|
|
155
|
+
// responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
|
|
156
|
+
if (!isDev && body.length > 1024 && accept.includes("gzip")) {
|
|
157
|
+
return new Response(Bun.gzipSync(body), { status, headers: { ...headers, "Content-Encoding": "gzip" } });
|
|
158
|
+
}
|
|
159
|
+
return new Response(body, { status, headers });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Static File Detection ────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export const STATIC_EXTS = new Set([".ico", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".css", ".js", ".woff", ".woff2", ".ttf"]);
|
|
165
|
+
|
|
166
|
+
export function isStaticPath(path: string): boolean {
|
|
167
|
+
if (path.startsWith("/dist/") || path.startsWith("/__bunia/")) return true;
|
|
168
|
+
const dot = path.lastIndexOf(".");
|
|
169
|
+
return dot !== -1 && STATIC_EXTS.has(path.slice(dot));
|
|
170
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { RouteMatch } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
// ─── Route Matcher ───────────────────────────────────────
|
|
4
|
+
// Single shared matcher used by both client and server at runtime.
|
|
5
|
+
// Replaces the old code-generated match functions.
|
|
6
|
+
//
|
|
7
|
+
// Matching priority (same as old / SvelteKit):
|
|
8
|
+
// 1. Exact match — "/about" === "/about"
|
|
9
|
+
// 2. Dynamic match — "/blog/[slug]" matches "/blog/hello"
|
|
10
|
+
// 3. Catch-all match — "/[...rest]" matches anything
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Match a URL pathname against a single route pattern.
|
|
14
|
+
* Returns extracted params if matched, null otherwise.
|
|
15
|
+
*/
|
|
16
|
+
export function matchPattern(
|
|
17
|
+
pattern: string,
|
|
18
|
+
pathname: string,
|
|
19
|
+
): Record<string, string> | null {
|
|
20
|
+
// Exact match
|
|
21
|
+
if (pattern === pathname) return {};
|
|
22
|
+
|
|
23
|
+
// Catch-all pattern: /[...name] or /prefix/[...name]
|
|
24
|
+
const catchallMatch = pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
|
|
25
|
+
if (catchallMatch) {
|
|
26
|
+
const prefix = catchallMatch[1] || "";
|
|
27
|
+
const paramName = catchallMatch[2]!;
|
|
28
|
+
if (prefix === "" || pathname.startsWith(prefix + "/") || pathname === prefix) {
|
|
29
|
+
const rest = prefix ? pathname.slice(prefix.length + 1) : pathname.slice(1);
|
|
30
|
+
return { [paramName]: rest };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Dynamic segments: must have same segment count
|
|
36
|
+
if (!pattern.includes("[")) return null;
|
|
37
|
+
|
|
38
|
+
const patParts = pattern.split("/").filter(Boolean);
|
|
39
|
+
const pathParts = pathname.split("/").filter(Boolean);
|
|
40
|
+
if (patParts.length !== pathParts.length) return null;
|
|
41
|
+
|
|
42
|
+
const params: Record<string, string> = {};
|
|
43
|
+
for (let i = 0; i < patParts.length; i++) {
|
|
44
|
+
const pp = patParts[i]!;
|
|
45
|
+
const val = pathParts[i]!;
|
|
46
|
+
if (pp.startsWith("[") && pp.endsWith("]")) {
|
|
47
|
+
params[pp.slice(1, -1)] = val;
|
|
48
|
+
} else if (pp !== val) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return params;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Find the first matching route from a list.
|
|
57
|
+
* Uses 3-pass priority: exact → dynamic → catch-all.
|
|
58
|
+
*/
|
|
59
|
+
export function findMatch<T extends { pattern: string }>(
|
|
60
|
+
routes: T[],
|
|
61
|
+
pathname: string,
|
|
62
|
+
): RouteMatch<T> | null {
|
|
63
|
+
// Pass 1 — exact
|
|
64
|
+
for (const route of routes) {
|
|
65
|
+
if (route.pattern === pathname) {
|
|
66
|
+
return { route, params: {} };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Pass 2 — dynamic segments (no catch-all)
|
|
71
|
+
for (const route of routes) {
|
|
72
|
+
if (!route.pattern.includes("[") || route.pattern.includes("[...")) continue;
|
|
73
|
+
const params = matchPattern(route.pattern, pathname);
|
|
74
|
+
if (params !== null) return { route, params };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pass 3 — catch-all
|
|
78
|
+
for (const route of routes) {
|
|
79
|
+
if (!route.pattern.includes("[...")) continue;
|
|
80
|
+
const params = matchPattern(route.pattern, pathname);
|
|
81
|
+
if (params !== null) return { route, params };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
|
|
3
|
+
// ─── Bun Build Plugin ─────────────────────────────────────
|
|
4
|
+
// Resolves:
|
|
5
|
+
// bunia:routes → .bunia/routes.ts (generated route map)
|
|
6
|
+
// bunia:env → .bunia/env.server.ts (bun) or .bunia/env.client.ts (browser)
|
|
7
|
+
// $lib/* → src/lib/* (user library alias)
|
|
8
|
+
|
|
9
|
+
export function makeBuniaPlugin(target: "browser" | "bun" = "bun") {
|
|
10
|
+
return {
|
|
11
|
+
name: "bunia-resolver",
|
|
12
|
+
setup(build: import("bun").PluginBuilder) {
|
|
13
|
+
// bunia:routes → .bunia/routes.ts
|
|
14
|
+
build.onResolve({ filter: /^bunia:routes$/ }, () => ({
|
|
15
|
+
path: join(process.cwd(), ".bunia", "routes.ts"),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// bunia:env → .bunia/env.client.ts (browser) or .bunia/env.server.ts (bun)
|
|
19
|
+
build.onResolve({ filter: /^bunia:env$/ }, () => ({
|
|
20
|
+
path: join(
|
|
21
|
+
process.cwd(),
|
|
22
|
+
".bunia",
|
|
23
|
+
target === "browser" ? "env.client.ts" : "env.server.ts",
|
|
24
|
+
),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// $lib/* → src/lib/* with extension probing
|
|
28
|
+
build.onResolve({ filter: /^\$lib\// }, async (args) => {
|
|
29
|
+
const rel = args.path.slice(5); // remove "$lib/"
|
|
30
|
+
const base = join(process.cwd(), "src", "lib", rel);
|
|
31
|
+
return { path: await resolveWithExts(base) };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// "tailwindcss" inside app.css is a Tailwind CLI directive —
|
|
35
|
+
// it's already compiled to public/bunia-tw.css by the CLI step.
|
|
36
|
+
// Return an empty CSS module so Bun's CSS bundler doesn't choke on it.
|
|
37
|
+
build.onResolve({ filter: /^tailwindcss$/ }, () => ({
|
|
38
|
+
path: "tailwindcss",
|
|
39
|
+
namespace: "bunia-empty-css",
|
|
40
|
+
}));
|
|
41
|
+
build.onLoad({ filter: /.*/, namespace: "bunia-empty-css" }, () => ({
|
|
42
|
+
contents: "",
|
|
43
|
+
loader: "css",
|
|
44
|
+
}));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function resolveWithExts(base: string): Promise<string> {
|
|
50
|
+
if (await Bun.file(base).exists()) return base;
|
|
51
|
+
for (const ext of [".ts", ".svelte", ".js"]) {
|
|
52
|
+
if (await Bun.file(base + ext).exists()) return base + ext;
|
|
53
|
+
}
|
|
54
|
+
for (const idx of ["index.ts", "index.svelte", "index.js"]) {
|
|
55
|
+
const p = join(base, idx);
|
|
56
|
+
if (await Bun.file(p).exists()) return p;
|
|
57
|
+
}
|
|
58
|
+
return base;
|
|
59
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import type { RouteManifest } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const CORE_DIR = import.meta.dir;
|
|
6
|
+
const BUNIA_NODE_MODULES = join(CORE_DIR, "..", "..", "node_modules");
|
|
7
|
+
|
|
8
|
+
// ─── Prerendering ─────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
async function detectPrerenderRoutes(manifest: RouteManifest): Promise<string[]> {
|
|
11
|
+
const paths: string[] = [];
|
|
12
|
+
for (const route of manifest.pages) {
|
|
13
|
+
if (!route.pageServer) continue;
|
|
14
|
+
if (route.pattern.includes("[")) {
|
|
15
|
+
// TODO: Support dynamic routes by reading export const entries() and calling it to get param values
|
|
16
|
+
// Then prerender each route variant: /blog/slug1, /blog/slug2, etc.
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const content = await Bun.file(join("src", "routes", route.pageServer)).text();
|
|
20
|
+
if (/export\s+const\s+prerender\s*=\s*true/.test(content)) {
|
|
21
|
+
paths.push(route.pattern);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return paths;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<void> {
|
|
28
|
+
const paths = await detectPrerenderRoutes(manifest);
|
|
29
|
+
if (paths.length === 0) return;
|
|
30
|
+
|
|
31
|
+
console.log(`\n🖨️ Prerendering ${paths.length} route(s)...`);
|
|
32
|
+
|
|
33
|
+
const port = 13572;
|
|
34
|
+
const child = Bun.spawn(
|
|
35
|
+
["bun", "run", "./dist/server/index.js"],
|
|
36
|
+
{
|
|
37
|
+
env: { ...process.env, NODE_ENV: "production", PORT: String(port), NODE_PATH: BUNIA_NODE_MODULES },
|
|
38
|
+
stdout: "ignore",
|
|
39
|
+
stderr: "ignore",
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Poll /_health until ready (max 10s)
|
|
44
|
+
const base = `http://localhost:${port}`;
|
|
45
|
+
let ready = false;
|
|
46
|
+
for (let i = 0; i < 50; i++) {
|
|
47
|
+
await Bun.sleep(200);
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${base}/_health`);
|
|
50
|
+
if (res.ok) { ready = true; break; }
|
|
51
|
+
} catch { /* not ready yet */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!ready) {
|
|
55
|
+
child.kill();
|
|
56
|
+
console.error("❌ Prerender server failed to start");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
mkdirSync("./dist/prerendered", { recursive: true });
|
|
61
|
+
|
|
62
|
+
for (const routePath of paths) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`${base}${routePath}`);
|
|
65
|
+
const html = await res.text();
|
|
66
|
+
const outPath = routePath === "/"
|
|
67
|
+
? "./dist/prerendered/index.html"
|
|
68
|
+
: `./dist/prerendered${routePath}/index.html`;
|
|
69
|
+
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
70
|
+
writeFileSync(outPath, html);
|
|
71
|
+
console.log(` ✅ ${routePath} → ${outPath}`);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(` ❌ Failed to prerender ${routePath}:`, err);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
child.kill();
|
|
78
|
+
console.log("✅ Prerendering complete");
|
|
79
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { render } from "svelte/server";
|
|
2
|
+
|
|
3
|
+
import { findMatch } from "./matcher.ts";
|
|
4
|
+
import { serverRoutes, errorPage } from "bunia:routes";
|
|
5
|
+
import type { Cookies } from "./hooks.ts";
|
|
6
|
+
import { HttpError, Redirect } from "./errors.ts";
|
|
7
|
+
import App from "./client/App.svelte";
|
|
8
|
+
import { buildHtml, buildHtmlShell, buildHtmlTail, compress, safeJsonStringify, isDev } from "./html.ts";
|
|
9
|
+
|
|
10
|
+
// ─── Session-Aware Fetch ─────────────────────────────────
|
|
11
|
+
// Passed to load() functions so they can call internal APIs
|
|
12
|
+
// with the current user's cookies automatically forwarded.
|
|
13
|
+
|
|
14
|
+
function makeFetch(req: Request, url: URL) {
|
|
15
|
+
const cookie = req.headers.get("cookie") ?? "";
|
|
16
|
+
const origin = url.origin;
|
|
17
|
+
|
|
18
|
+
return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
19
|
+
const resolved =
|
|
20
|
+
typeof input === "string" && input.startsWith("/")
|
|
21
|
+
? `${origin}${input}`
|
|
22
|
+
: input;
|
|
23
|
+
|
|
24
|
+
const headers = new Headers(init?.headers);
|
|
25
|
+
if (cookie && !headers.has("cookie")) headers.set("cookie", cookie);
|
|
26
|
+
|
|
27
|
+
return globalThis.fetch(resolved, { ...init, headers });
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Route Data Loader ───────────────────────────────────
|
|
32
|
+
// Runs layout + page server loaders for a given URL.
|
|
33
|
+
// Used by both SSR and the /__bunia/data JSON endpoint.
|
|
34
|
+
|
|
35
|
+
export async function loadRouteData(
|
|
36
|
+
url: URL,
|
|
37
|
+
locals: Record<string, any>,
|
|
38
|
+
req: Request,
|
|
39
|
+
cookies: Cookies,
|
|
40
|
+
) {
|
|
41
|
+
const match = findMatch(serverRoutes, url.pathname);
|
|
42
|
+
if (!match) return null;
|
|
43
|
+
|
|
44
|
+
const { route, params } = match;
|
|
45
|
+
const fetch = makeFetch(req, url);
|
|
46
|
+
const layoutData: Record<string, any>[] = [];
|
|
47
|
+
|
|
48
|
+
// Run layout server loaders root → leaf, each gets parent() data
|
|
49
|
+
for (const ls of route.layoutServers) {
|
|
50
|
+
try {
|
|
51
|
+
const mod = await ls.loader();
|
|
52
|
+
if (typeof mod.load === "function") {
|
|
53
|
+
const parent = async () => {
|
|
54
|
+
const merged: Record<string, any> = {};
|
|
55
|
+
for (let d = 0; d < ls.depth; d++) Object.assign(merged, layoutData[d] ?? {});
|
|
56
|
+
return merged;
|
|
57
|
+
};
|
|
58
|
+
layoutData[ls.depth] = (await mod.load({ params, url, locals, cookies, parent, fetch })) ?? {};
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err instanceof HttpError || err instanceof Redirect) throw err;
|
|
62
|
+
if (isDev) console.error("Layout server load error:", err);
|
|
63
|
+
else console.error("Layout server load error:", (err as Error).message ?? err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Run page server loader
|
|
68
|
+
let pageData: Record<string, any> = {};
|
|
69
|
+
let csr = true;
|
|
70
|
+
if (route.pageServer) {
|
|
71
|
+
try {
|
|
72
|
+
const mod = await route.pageServer();
|
|
73
|
+
if (mod.csr === false) csr = false;
|
|
74
|
+
if (typeof mod.load === "function") {
|
|
75
|
+
const parent = async () => {
|
|
76
|
+
const merged: Record<string, any> = {};
|
|
77
|
+
for (const d of layoutData) if (d) Object.assign(merged, d);
|
|
78
|
+
return merged;
|
|
79
|
+
};
|
|
80
|
+
pageData = (await mod.load({ params, url, locals, cookies, parent, fetch })) ?? {};
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof HttpError || err instanceof Redirect) throw err;
|
|
84
|
+
if (isDev) console.error("Page server load error:", err);
|
|
85
|
+
else console.error("Page server load error:", (err as Error).message ?? err);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { pageData: { ...pageData, params }, layoutData, csr };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── SSR Renderer ────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export async function renderSSR(url: URL, locals: Record<string, any>, req: Request, cookies: Cookies) {
|
|
95
|
+
const match = findMatch(serverRoutes, url.pathname);
|
|
96
|
+
if (!match) return null;
|
|
97
|
+
|
|
98
|
+
const { route } = match;
|
|
99
|
+
|
|
100
|
+
// Kick off component imports in parallel with data loading
|
|
101
|
+
const pageModPromise = route.pageModule();
|
|
102
|
+
const layoutModsPromise = Promise.all(route.layoutModules.map((l: () => Promise<any>) => l()));
|
|
103
|
+
|
|
104
|
+
const data = await loadRouteData(url, locals, req, cookies);
|
|
105
|
+
if (!data) return null;
|
|
106
|
+
|
|
107
|
+
const [pageMod, layoutMods] = await Promise.all([pageModPromise, layoutModsPromise]);
|
|
108
|
+
|
|
109
|
+
const { body, head } = render(App, {
|
|
110
|
+
props: {
|
|
111
|
+
ssrMode: true,
|
|
112
|
+
ssrPageComponent: pageMod.default,
|
|
113
|
+
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
114
|
+
ssrPageData: data.pageData,
|
|
115
|
+
ssrLayoutData: data.layoutData,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { body, head, pageData: data.pageData, layoutData: data.layoutData, csr: data.csr };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Streaming SSR Renderer ──────────────────────────────
|
|
123
|
+
|
|
124
|
+
export function renderSSRStream(
|
|
125
|
+
url: URL,
|
|
126
|
+
locals: Record<string, any>,
|
|
127
|
+
req: Request,
|
|
128
|
+
cookies: Cookies,
|
|
129
|
+
): Response | null {
|
|
130
|
+
const match = findMatch(serverRoutes, url.pathname);
|
|
131
|
+
if (!match) return null;
|
|
132
|
+
|
|
133
|
+
const { route } = match;
|
|
134
|
+
const enc = new TextEncoder();
|
|
135
|
+
|
|
136
|
+
// Kick off imports immediately (parallel with data loading)
|
|
137
|
+
const pageModPromise = route.pageModule();
|
|
138
|
+
const layoutModsPromise = Promise.all(route.layoutModules.map((l: () => Promise<any>) => l()));
|
|
139
|
+
|
|
140
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
141
|
+
async start(controller) {
|
|
142
|
+
// Chunk 1: shell (cached at startup)
|
|
143
|
+
controller.enqueue(enc.encode(buildHtmlShell()));
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const [data, pageMod, layoutMods] = await Promise.all([
|
|
147
|
+
loadRouteData(url, locals, req, cookies),
|
|
148
|
+
pageModPromise,
|
|
149
|
+
layoutModsPromise,
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
if (!data) {
|
|
153
|
+
controller.enqueue(enc.encode(`</body></html>`));
|
|
154
|
+
controller.close();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { body, head } = render(App, {
|
|
159
|
+
props: {
|
|
160
|
+
ssrMode: true,
|
|
161
|
+
ssrPageComponent: pageMod.default,
|
|
162
|
+
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
163
|
+
ssrPageData: data.pageData,
|
|
164
|
+
ssrLayoutData: data.layoutData,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Chunk 2: content
|
|
169
|
+
controller.enqueue(enc.encode(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)));
|
|
170
|
+
controller.close();
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err instanceof Redirect) {
|
|
173
|
+
controller.enqueue(enc.encode(
|
|
174
|
+
`<script>location.replace(${safeJsonStringify(err.location)})</script></body></html>`
|
|
175
|
+
));
|
|
176
|
+
controller.close();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (err instanceof HttpError) {
|
|
180
|
+
controller.enqueue(enc.encode(
|
|
181
|
+
`<script>location.replace("/__bunia/error?status=${err.status}&message=${encodeURIComponent(err.message)}")</script></body></html>`
|
|
182
|
+
));
|
|
183
|
+
controller.close();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (isDev) console.error("SSR stream error:", err);
|
|
187
|
+
else console.error("SSR stream error:", (err as Error).message ?? err);
|
|
188
|
+
controller.enqueue(enc.encode(`<p>Internal Server Error</p></body></html>`));
|
|
189
|
+
controller.close();
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return new Response(stream, {
|
|
195
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Error Page Renderer ──────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export async function renderErrorPage(status: number, message: string, url: URL, req: Request): Promise<Response> {
|
|
202
|
+
if (errorPage) {
|
|
203
|
+
try {
|
|
204
|
+
const mod = await errorPage();
|
|
205
|
+
const { body, head } = render(App, {
|
|
206
|
+
props: {
|
|
207
|
+
ssrMode: true,
|
|
208
|
+
ssrPageComponent: mod.default,
|
|
209
|
+
ssrLayoutComponents: [],
|
|
210
|
+
ssrPageData: { status, message },
|
|
211
|
+
ssrLayoutData: [],
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
const html = buildHtml(body, head, { status, message }, [], false);
|
|
215
|
+
return compress(html, "text/html; charset=utf-8", req, status);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (isDev) console.error("Error page render failed:", err);
|
|
218
|
+
else console.error("Error page render failed:", (err as Error).message ?? err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return new Response(message, { status, headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
222
|
+
}
|