bosbun 0.0.2 → 0.0.4
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/core/client/App.svelte +6 -2
- package/src/core/client/hydrate.ts +1 -0
- package/src/core/cookies.ts +20 -4
- package/src/core/env.ts +18 -0
- package/src/core/errors.ts +12 -0
- package/src/core/hooks.ts +18 -0
- package/src/core/html.ts +76 -23
- package/src/core/matcher.ts +10 -15
- package/src/core/prerender.ts +8 -2
- package/src/core/renderer.ts +133 -12
- package/src/core/routeFile.ts +25 -3
- package/src/core/routeTypes.ts +11 -0
- package/src/core/server.ts +121 -27
- package/src/lib/index.ts +4 -2
- package/templates/default/.env.example +44 -3
package/package.json
CHANGED
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
ssrLayoutComponents = [],
|
|
10
10
|
ssrPageData = {},
|
|
11
11
|
ssrLayoutData = [],
|
|
12
|
+
ssrFormData = null,
|
|
12
13
|
}: {
|
|
13
14
|
ssrMode?: boolean;
|
|
14
15
|
ssrPageComponent?: any;
|
|
15
16
|
ssrLayoutComponents?: any[];
|
|
16
17
|
ssrPageData?: Record<string, any>;
|
|
17
18
|
ssrLayoutData?: Record<string, any>[];
|
|
19
|
+
ssrFormData?: any;
|
|
18
20
|
} = $props();
|
|
19
21
|
|
|
20
22
|
let PageComponent = $state<any>(ssrPageComponent);
|
|
@@ -23,6 +25,7 @@
|
|
|
23
25
|
let layoutData = $state<Record<string, any>[]>(ssrLayoutData ?? []);
|
|
24
26
|
// Kept separate to avoid a read→write cycle inside the $effect below
|
|
25
27
|
let routeParams = $state<Record<string, string>>(ssrPageData?.params ?? {});
|
|
28
|
+
let formData = $state<any>(ssrFormData);
|
|
26
29
|
let navigating = $state(false);
|
|
27
30
|
let navDone = $state(false);
|
|
28
31
|
// Skip bar on the very first effect run (initial hydration — data already present)
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
const isFirst = firstNav;
|
|
42
45
|
firstNav = false;
|
|
43
46
|
if (!isFirst) {
|
|
47
|
+
formData = null;
|
|
44
48
|
if (navDoneTimer) { clearTimeout(navDoneTimer); navDoneTimer = null; }
|
|
45
49
|
navDone = false;
|
|
46
50
|
navigating = true;
|
|
@@ -94,7 +98,7 @@
|
|
|
94
98
|
{#if layoutComponents.length > 0}
|
|
95
99
|
{@render renderLayout(0)}
|
|
96
100
|
{:else if PageComponent}
|
|
97
|
-
<PageComponent data={{ ...pageData, params: routeParams }} />
|
|
101
|
+
<PageComponent data={{ ...pageData, params: routeParams }} form={formData} />
|
|
98
102
|
{:else}
|
|
99
103
|
<p>Loading...</p>
|
|
100
104
|
{/if}
|
|
@@ -110,7 +114,7 @@
|
|
|
110
114
|
{:else}
|
|
111
115
|
<Layout {data}>
|
|
112
116
|
{#if PageComponent}
|
|
113
|
-
<PageComponent data={{ ...pageData, params: routeParams }} />
|
|
117
|
+
<PageComponent data={{ ...pageData, params: routeParams }} form={formData} />
|
|
114
118
|
{:else}
|
|
115
119
|
<p>Loading...</p>
|
|
116
120
|
{/if}
|
package/src/core/cookies.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Cookies, CookieOptions } from "./hooks.ts";
|
|
2
2
|
|
|
3
|
+
// ─── Cookie Validation ───────────────────────────────────
|
|
4
|
+
/** Rejects characters that could inject into Set-Cookie headers. */
|
|
5
|
+
const UNSAFE_COOKIE_VALUE = /[;\r\n]/;
|
|
6
|
+
const VALID_SAMESITE = new Set(["Strict", "Lax", "None"]);
|
|
7
|
+
|
|
3
8
|
// ─── Cookie Helpers ──────────────────────────────────────
|
|
4
9
|
|
|
5
10
|
function parseCookies(header: string): Record<string, string> {
|
|
@@ -9,7 +14,10 @@ function parseCookies(header: string): Record<string, string> {
|
|
|
9
14
|
if (idx === -1) continue;
|
|
10
15
|
const name = pair.slice(0, idx).trim();
|
|
11
16
|
const value = pair.slice(idx + 1).trim();
|
|
12
|
-
if (name)
|
|
17
|
+
if (name) {
|
|
18
|
+
try { result[name] = decodeURIComponent(value); }
|
|
19
|
+
catch { result[name] = value; }
|
|
20
|
+
}
|
|
13
21
|
}
|
|
14
22
|
return result;
|
|
15
23
|
}
|
|
@@ -32,13 +40,21 @@ export class CookieJar implements Cookies {
|
|
|
32
40
|
|
|
33
41
|
set(name: string, value: string, options?: CookieOptions): void {
|
|
34
42
|
let header = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
35
|
-
|
|
36
|
-
if (
|
|
43
|
+
const path = options?.path ?? "/";
|
|
44
|
+
if (UNSAFE_COOKIE_VALUE.test(path)) throw new Error(`Invalid cookie path: ${path}`);
|
|
45
|
+
header += `; Path=${path}`;
|
|
46
|
+
if (options?.domain) {
|
|
47
|
+
if (UNSAFE_COOKIE_VALUE.test(options.domain)) throw new Error(`Invalid cookie domain: ${options.domain}`);
|
|
48
|
+
header += `; Domain=${options.domain}`;
|
|
49
|
+
}
|
|
37
50
|
if (options?.maxAge != null) header += `; Max-Age=${options.maxAge}`;
|
|
38
51
|
if (options?.expires) header += `; Expires=${options.expires.toUTCString()}`;
|
|
39
52
|
if (options?.httpOnly) header += "; HttpOnly";
|
|
40
53
|
if (options?.secure) header += "; Secure";
|
|
41
|
-
if (options?.sameSite)
|
|
54
|
+
if (options?.sameSite) {
|
|
55
|
+
if (!VALID_SAMESITE.has(options.sameSite)) throw new Error(`Invalid cookie sameSite: ${options.sameSite}`);
|
|
56
|
+
header += `; SameSite=${options.sameSite}`;
|
|
57
|
+
}
|
|
42
58
|
this._outgoing.push(header);
|
|
43
59
|
}
|
|
44
60
|
|
package/src/core/env.ts
CHANGED
|
@@ -14,6 +14,9 @@ const FRAMEWORK_VARS = new Set([
|
|
|
14
14
|
"CORS_EXPOSED_HEADERS",
|
|
15
15
|
"CORS_CREDENTIALS",
|
|
16
16
|
"CORS_MAX_AGE",
|
|
17
|
+
"LOAD_TIMEOUT",
|
|
18
|
+
"METADATA_TIMEOUT",
|
|
19
|
+
"PRERENDER_TIMEOUT",
|
|
17
20
|
]);
|
|
18
21
|
|
|
19
22
|
// ─── .env File Parser ────────────────────────────────────
|
|
@@ -81,6 +84,11 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
|
|
|
81
84
|
console.log(`✓ Loaded ${loaded.join(", ")}`);
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
// Track declared keys so html.ts only exposes .env-declared PUBLIC_* vars
|
|
88
|
+
for (const key of Object.keys(merged)) {
|
|
89
|
+
_declaredKeys.add(key);
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
// Apply to process.env — system env wins (don't overwrite existing)
|
|
85
93
|
for (const [key, value] of Object.entries(merged)) {
|
|
86
94
|
if (!(key in process.env)) {
|
|
@@ -99,6 +107,16 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
|
|
|
99
107
|
return result;
|
|
100
108
|
}
|
|
101
109
|
|
|
110
|
+
// ─── Declared Key Tracking ───────────────────────────
|
|
111
|
+
// Track which keys were declared in .env files so html.ts only exposes those to the client.
|
|
112
|
+
|
|
113
|
+
const _declaredKeys = new Set<string>();
|
|
114
|
+
|
|
115
|
+
/** Returns the set of env var keys that were declared in .env files. */
|
|
116
|
+
export function getDeclaredEnvKeys(): ReadonlySet<string> {
|
|
117
|
+
return _declaredKeys;
|
|
118
|
+
}
|
|
119
|
+
|
|
102
120
|
// ─── Classifier ──────────────────────────────────────────
|
|
103
121
|
|
|
104
122
|
export interface ClassifiedEnv {
|
package/src/core/errors.ts
CHANGED
|
@@ -21,3 +21,15 @@ export function error(status: number, message: string): never {
|
|
|
21
21
|
export function redirect(status: number, location: string): never {
|
|
22
22
|
throw new Redirect(status, location);
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
// ─── Form Action Helpers ─────────────────────────────────
|
|
26
|
+
// Return from form actions — not thrown, just returned.
|
|
27
|
+
|
|
28
|
+
export class ActionFailure<T extends Record<string, any> = Record<string, any>> {
|
|
29
|
+
constructor(public status: number, public data: T) {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Return a failure from a form action with a status code and data. */
|
|
33
|
+
export function fail<T extends Record<string, any>>(status: number, data: T): ActionFailure<T> {
|
|
34
|
+
return new ActionFailure(status, data);
|
|
35
|
+
}
|
package/src/core/hooks.ts
CHANGED
|
@@ -46,6 +46,7 @@ export type LoadEvent = {
|
|
|
46
46
|
cookies: Cookies;
|
|
47
47
|
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
48
48
|
parent: () => Promise<Record<string, any>>;
|
|
49
|
+
metadata: Record<string, any> | null;
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
export type ResolveFunction = (event: RequestEvent) => MaybePromise<Response>;
|
|
@@ -55,6 +56,23 @@ export type Handle = (input: {
|
|
|
55
56
|
resolve: ResolveFunction;
|
|
56
57
|
}) => MaybePromise<Response>;
|
|
57
58
|
|
|
59
|
+
// ─── Metadata Types ──────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export type MetadataEvent = {
|
|
62
|
+
params: Record<string, string>;
|
|
63
|
+
url: URL;
|
|
64
|
+
locals: Record<string, any>;
|
|
65
|
+
cookies: Cookies;
|
|
66
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type Metadata = {
|
|
70
|
+
title?: string;
|
|
71
|
+
description?: string;
|
|
72
|
+
meta?: Array<{ name?: string; property?: string; content: string }>;
|
|
73
|
+
data?: Record<string, any>;
|
|
74
|
+
};
|
|
75
|
+
|
|
58
76
|
type MaybePromise<T> = T | Promise<T>;
|
|
59
77
|
|
|
60
78
|
// ─── Middleware Composition ────────────────────────────────
|
package/src/core/html.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { getDeclaredEnvKeys } from "./env.ts";
|
|
2
3
|
|
|
3
4
|
// ─── Dist Manifest ───────────────────────────────────────
|
|
4
5
|
// Maps hashed filenames → script/link tags.
|
|
@@ -24,25 +25,30 @@ export function safeJsonStringify(data: unknown): string {
|
|
|
24
25
|
"\u2028": "\\u2028",
|
|
25
26
|
"\u2029": "\\u2029",
|
|
26
27
|
};
|
|
27
|
-
|
|
28
|
+
let json: string;
|
|
29
|
+
try {
|
|
30
|
+
json = JSON.stringify(data);
|
|
31
|
+
} catch {
|
|
32
|
+
console.error("safeJsonStringify: failed to serialize data (circular reference?)");
|
|
33
|
+
json = "null";
|
|
34
|
+
}
|
|
35
|
+
return json.replace(/[<>&\u2028\u2029]/g, c => map[c]);
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
// ─── Public Env Injection ─────────────────────────────────
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
|
-
* Collect PUBLIC_* (non-static) vars
|
|
34
|
-
*
|
|
35
|
-
*
|
|
41
|
+
* Collect PUBLIC_* (non-static) vars that were declared in .env files.
|
|
42
|
+
* Only exposes keys tracked by loadEnv() — never leaks system env vars
|
|
43
|
+
* that happen to start with PUBLIC_.
|
|
36
44
|
*/
|
|
37
45
|
function getPublicDynamicEnv(): Record<string, string> {
|
|
38
|
-
|
|
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.
|
|
46
|
+
const declared = getDeclaredEnvKeys();
|
|
42
47
|
const result: Record<string, string> = {};
|
|
43
|
-
for (const
|
|
44
|
-
if (key.startsWith("PUBLIC_") && !key.startsWith("PUBLIC_STATIC_")
|
|
45
|
-
|
|
48
|
+
for (const key of declared) {
|
|
49
|
+
if (key.startsWith("PUBLIC_") && !key.startsWith("PUBLIC_STATIC_")) {
|
|
50
|
+
const value = process.env[key];
|
|
51
|
+
if (value !== undefined) result[key] = value;
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
return result;
|
|
@@ -56,6 +62,7 @@ export function buildHtml(
|
|
|
56
62
|
pageData: any,
|
|
57
63
|
layoutData: any[],
|
|
58
64
|
csr = true,
|
|
65
|
+
formData: any = null,
|
|
59
66
|
): string {
|
|
60
67
|
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
61
68
|
|
|
@@ -70,8 +77,12 @@ export function buildHtml(
|
|
|
70
77
|
? `\n <script>window.__BUNIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
|
|
71
78
|
: "";
|
|
72
79
|
|
|
80
|
+
const formScript = formData != null
|
|
81
|
+
? `window.__BUNIA_FORM_DATA__=${safeJsonStringify(formData)};`
|
|
82
|
+
: "";
|
|
83
|
+
|
|
73
84
|
const scripts = csr
|
|
74
|
-
? `${envScript}\n <script>window.__BUNIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BUNIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)}
|
|
85
|
+
? `${envScript}\n <script>window.__BUNIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BUNIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
|
|
75
86
|
: isDev
|
|
76
87
|
? `\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
88
|
: "";
|
|
@@ -95,29 +106,69 @@ export function buildHtml(
|
|
|
95
106
|
|
|
96
107
|
// ─── Streaming HTML Helpers ──────────────────────────────
|
|
97
108
|
|
|
109
|
+
import type { Metadata } from "./hooks.ts";
|
|
110
|
+
|
|
98
111
|
let _shell: string | null = null;
|
|
99
112
|
|
|
100
113
|
export function buildHtmlShell(): string {
|
|
101
114
|
if (_shell) return _shell;
|
|
115
|
+
_shell = buildHtmlShellOpen() + buildMetadataChunk(null);
|
|
116
|
+
return _shell;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let _shellOpen: string | null = null;
|
|
120
|
+
|
|
121
|
+
/** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
|
|
122
|
+
export function buildHtmlShellOpen(): string {
|
|
123
|
+
if (_shellOpen) return _shellOpen;
|
|
102
124
|
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
103
125
|
const cssLinks = (distManifest.css ?? [])
|
|
104
126
|
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
105
127
|
.join("\n ");
|
|
106
|
-
|
|
128
|
+
_shellOpen = `<!DOCTYPE html>\n<html lang="en">\n<head>\n` +
|
|
107
129
|
` <meta charset="UTF-8">\n` +
|
|
108
130
|
` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
109
131
|
` <link rel="icon" href="data:,">\n` +
|
|
110
132
|
` ${cssLinks}\n` +
|
|
111
133
|
` <link rel="stylesheet" href="/bunia-tw.css${cacheBust}">\n` +
|
|
112
|
-
` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
134
|
+
` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`;
|
|
135
|
+
return _shellOpen;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const SPINNER = `<div id="__bs__"><style>` +
|
|
139
|
+
`:root{--bunia-loading-color:#f73b27}` +
|
|
140
|
+
`#__bs__{position:fixed;inset:0;display:flex;align-items:center;justify-content:center}` +
|
|
141
|
+
`#__bs__ i{width:32px;height:32px;border:3px solid #e5e7eb;border-top-color:var(--bunia-loading-color);` +
|
|
142
|
+
`border-radius:50%;animation:__bs__ .8s linear infinite}` +
|
|
143
|
+
`@keyframes __bs__{to{transform:rotate(360deg)}}</style><i></i></div>`;
|
|
144
|
+
|
|
145
|
+
/** Chunk 2: metadata tags + close </head> + open <body> + spinner */
|
|
146
|
+
export function buildMetadataChunk(metadata: Metadata | null): string {
|
|
147
|
+
let out = "\n";
|
|
148
|
+
if (metadata) {
|
|
149
|
+
if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
|
|
150
|
+
if (metadata.description) {
|
|
151
|
+
out += ` <meta name="description" content="${escapeAttr(metadata.description)}">\n`;
|
|
152
|
+
}
|
|
153
|
+
if (metadata.meta) {
|
|
154
|
+
for (const m of metadata.meta) {
|
|
155
|
+
const attrs = m.name ? `name="${escapeAttr(m.name)}"` : `property="${escapeAttr(m.property ?? "")}"`;
|
|
156
|
+
out += ` <meta ${attrs} content="${escapeAttr(m.content)}">\n`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
out += ` <title>Bunia App</title>\n`;
|
|
161
|
+
}
|
|
162
|
+
out += `</head>\n<body>\n${SPINNER}`;
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function escapeHtml(s: string): string {
|
|
167
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function escapeAttr(s: string): string {
|
|
171
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
121
172
|
}
|
|
122
173
|
|
|
123
174
|
export function buildHtmlTail(
|
|
@@ -126,6 +177,7 @@ export function buildHtmlTail(
|
|
|
126
177
|
pageData: any,
|
|
127
178
|
layoutData: any[],
|
|
128
179
|
csr: boolean,
|
|
180
|
+
formData: any = null,
|
|
129
181
|
): string {
|
|
130
182
|
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
131
183
|
let out = `<script>document.getElementById('__bs__').remove()</script>`;
|
|
@@ -136,8 +188,9 @@ export function buildHtmlTail(
|
|
|
136
188
|
if (Object.keys(publicEnv).length > 0) {
|
|
137
189
|
out += `\n<script>window.__BUNIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
|
|
138
190
|
}
|
|
191
|
+
const formInject = formData != null ? `window.__BUNIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
|
|
139
192
|
out += `\n<script>window.__BUNIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
|
|
140
|
-
`window.__BUNIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)}
|
|
193
|
+
`window.__BUNIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formInject}</script>`;
|
|
141
194
|
out += `\n<script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
|
|
142
195
|
} else if (isDev) {
|
|
143
196
|
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>`;
|
package/src/core/matcher.ts
CHANGED
|
@@ -17,6 +17,11 @@ export function matchPattern(
|
|
|
17
17
|
pattern: string,
|
|
18
18
|
pathname: string,
|
|
19
19
|
): Record<string, string> | null {
|
|
20
|
+
// Strip trailing slash (but keep "/" as-is)
|
|
21
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
22
|
+
pathname = pathname.slice(0, -1);
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
// Exact match
|
|
21
26
|
if (pattern === pathname) return {};
|
|
22
27
|
|
|
@@ -54,29 +59,19 @@ export function matchPattern(
|
|
|
54
59
|
|
|
55
60
|
/**
|
|
56
61
|
* Find the first matching route from a list.
|
|
57
|
-
*
|
|
62
|
+
* Routes must be pre-sorted by priority (exact → dynamic → catch-all).
|
|
63
|
+
* Single pass — first match wins.
|
|
58
64
|
*/
|
|
59
65
|
export function findMatch<T extends { pattern: string }>(
|
|
60
66
|
routes: T[],
|
|
61
67
|
pathname: string,
|
|
62
68
|
): RouteMatch<T> | null {
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
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 };
|
|
69
|
+
// Strip trailing slash (but keep "/" as-is)
|
|
70
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
71
|
+
pathname = pathname.slice(0, -1);
|
|
75
72
|
}
|
|
76
73
|
|
|
77
|
-
// Pass 3 — catch-all
|
|
78
74
|
for (const route of routes) {
|
|
79
|
-
if (!route.pattern.includes("[...")) continue;
|
|
80
75
|
const params = matchPattern(route.pattern, pathname);
|
|
81
76
|
if (params !== null) return { route, params };
|
|
82
77
|
}
|
package/src/core/prerender.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { RouteManifest } from "./types.ts";
|
|
|
5
5
|
const CORE_DIR = import.meta.dir;
|
|
6
6
|
const BUNIA_NODE_MODULES = join(CORE_DIR, "..", "..", "node_modules");
|
|
7
7
|
|
|
8
|
+
const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
|
|
9
|
+
|
|
8
10
|
// ─── Prerendering ─────────────────────────────────────────
|
|
9
11
|
|
|
10
12
|
async function detectPrerenderRoutes(manifest: RouteManifest): Promise<string[]> {
|
|
@@ -61,7 +63,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
61
63
|
|
|
62
64
|
for (const routePath of paths) {
|
|
63
65
|
try {
|
|
64
|
-
const res = await fetch(`${base}${routePath}
|
|
66
|
+
const res = await fetch(`${base}${routePath}`, { signal: AbortSignal.timeout(PRERENDER_TIMEOUT) });
|
|
65
67
|
const html = await res.text();
|
|
66
68
|
const outPath = routePath === "/"
|
|
67
69
|
? "./dist/prerendered/index.html"
|
|
@@ -70,7 +72,11 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
70
72
|
writeFileSync(outPath, html);
|
|
71
73
|
console.log(` ✅ ${routePath} → ${outPath}`);
|
|
72
74
|
} catch (err) {
|
|
73
|
-
|
|
75
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
76
|
+
console.error(` ❌ Prerender timed out for ${routePath} after ${PRERENDER_TIMEOUT / 1000}s — increase PRERENDER_TIMEOUT to raise the limit`);
|
|
77
|
+
} else {
|
|
78
|
+
console.error(` ❌ Failed to prerender ${routePath}:`, err);
|
|
79
|
+
}
|
|
74
80
|
}
|
|
75
81
|
}
|
|
76
82
|
|
package/src/core/renderer.ts
CHANGED
|
@@ -5,7 +5,36 @@ import { serverRoutes, errorPage } from "bunia:routes";
|
|
|
5
5
|
import type { Cookies } from "./hooks.ts";
|
|
6
6
|
import { HttpError, Redirect } from "./errors.ts";
|
|
7
7
|
import App from "./client/App.svelte";
|
|
8
|
-
import { buildHtml, buildHtmlShell, buildHtmlTail, compress, safeJsonStringify, isDev } from "./html.ts";
|
|
8
|
+
import { buildHtml, buildHtmlShell, buildHtmlShellOpen, buildMetadataChunk, buildHtmlTail, compress, safeJsonStringify, isDev } from "./html.ts";
|
|
9
|
+
import type { Metadata } from "./hooks.ts";
|
|
10
|
+
|
|
11
|
+
// ─── Timeout Helpers ─────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
class LoadTimeoutError extends Error {
|
|
14
|
+
constructor(label: string, ms: number) {
|
|
15
|
+
super(`${label} timed out after ${ms}ms`);
|
|
16
|
+
this.name = "LoadTimeoutError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseTimeout(raw: string | undefined, fallback: number): number {
|
|
21
|
+
if (!raw || raw === "Infinity") return 0;
|
|
22
|
+
const n = parseInt(raw, 10);
|
|
23
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const LOAD_TIMEOUT = parseTimeout(process.env.LOAD_TIMEOUT, 5000);
|
|
27
|
+
const METADATA_TIMEOUT = parseTimeout(process.env.METADATA_TIMEOUT, 3000);
|
|
28
|
+
|
|
29
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
30
|
+
if (ms <= 0) return promise;
|
|
31
|
+
return Promise.race([
|
|
32
|
+
promise,
|
|
33
|
+
new Promise<never>((_, reject) =>
|
|
34
|
+
setTimeout(() => reject(new LoadTimeoutError(label, ms)), ms)
|
|
35
|
+
),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
9
38
|
|
|
10
39
|
// ─── Session-Aware Fetch ─────────────────────────────────
|
|
11
40
|
// Passed to load() functions so they can call internal APIs
|
|
@@ -37,6 +66,7 @@ export async function loadRouteData(
|
|
|
37
66
|
locals: Record<string, any>,
|
|
38
67
|
req: Request,
|
|
39
68
|
cookies: Cookies,
|
|
69
|
+
metadataData: Record<string, any> | null = null,
|
|
40
70
|
) {
|
|
41
71
|
const match = findMatch(serverRoutes, url.pathname);
|
|
42
72
|
if (!match) return null;
|
|
@@ -55,7 +85,7 @@ export async function loadRouteData(
|
|
|
55
85
|
for (let d = 0; d < ls.depth; d++) Object.assign(merged, layoutData[d] ?? {});
|
|
56
86
|
return merged;
|
|
57
87
|
};
|
|
58
|
-
layoutData[ls.depth] = (await mod.load({ params, url, locals, cookies, parent, fetch })) ?? {};
|
|
88
|
+
layoutData[ls.depth] = (await withTimeout(mod.load({ params, url, locals, cookies, parent, fetch, metadata: null }), LOAD_TIMEOUT, `layout load (depth=${ls.depth}, ${url.pathname})`)) ?? {};
|
|
59
89
|
}
|
|
60
90
|
} catch (err) {
|
|
61
91
|
if (err instanceof HttpError || err instanceof Redirect) throw err;
|
|
@@ -77,7 +107,7 @@ export async function loadRouteData(
|
|
|
77
107
|
for (const d of layoutData) if (d) Object.assign(merged, d);
|
|
78
108
|
return merged;
|
|
79
109
|
};
|
|
80
|
-
pageData = (await mod.load({ params, url, locals, cookies, parent, fetch })) ?? {};
|
|
110
|
+
pageData = (await withTimeout(mod.load({ params, url, locals, cookies, parent, fetch, metadata: metadataData }), LOAD_TIMEOUT, `page load (${url.pathname})`)) ?? {};
|
|
81
111
|
}
|
|
82
112
|
} catch (err) {
|
|
83
113
|
if (err instanceof HttpError || err instanceof Redirect) throw err;
|
|
@@ -119,32 +149,81 @@ export async function renderSSR(url: URL, locals: Record<string, any>, req: Requ
|
|
|
119
149
|
return { body, head, pageData: data.pageData, layoutData: data.layoutData, csr: data.csr };
|
|
120
150
|
}
|
|
121
151
|
|
|
152
|
+
// ─── Metadata Loader ─────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
async function loadMetadata(
|
|
155
|
+
route: any,
|
|
156
|
+
params: Record<string, string>,
|
|
157
|
+
url: URL,
|
|
158
|
+
locals: Record<string, any>,
|
|
159
|
+
cookies: Cookies,
|
|
160
|
+
req: Request,
|
|
161
|
+
): Promise<Metadata | null> {
|
|
162
|
+
if (!route.pageServer) return null;
|
|
163
|
+
try {
|
|
164
|
+
const mod = await route.pageServer();
|
|
165
|
+
if (typeof mod.metadata === "function") {
|
|
166
|
+
const fetch = makeFetch(req, url);
|
|
167
|
+
return (await withTimeout(mod.metadata({ params, url, locals, cookies, fetch }), METADATA_TIMEOUT, `metadata (${url.pathname})`)) ?? null;
|
|
168
|
+
}
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (isDev) console.error("Metadata load error:", err);
|
|
171
|
+
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
122
176
|
// ─── Streaming SSR Renderer ──────────────────────────────
|
|
123
177
|
|
|
124
|
-
export function renderSSRStream(
|
|
178
|
+
export async function renderSSRStream(
|
|
125
179
|
url: URL,
|
|
126
180
|
locals: Record<string, any>,
|
|
127
181
|
req: Request,
|
|
128
182
|
cookies: Cookies,
|
|
129
|
-
): Response | null {
|
|
183
|
+
): Promise<Response | null> {
|
|
130
184
|
const match = findMatch(serverRoutes, url.pathname);
|
|
131
185
|
if (!match) return null;
|
|
132
186
|
|
|
133
|
-
const { route } = match;
|
|
134
|
-
|
|
187
|
+
const { route, params } = match;
|
|
188
|
+
|
|
189
|
+
// ── Pre-stream phase: resolve metadata before committing to a 200 ──
|
|
190
|
+
// Errors here return a proper error response with correct status code.
|
|
191
|
+
let metadata: Metadata | null = null;
|
|
192
|
+
try {
|
|
193
|
+
metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err instanceof Redirect) {
|
|
196
|
+
return Response.redirect(err.location, err.status);
|
|
197
|
+
}
|
|
198
|
+
if (err instanceof HttpError) {
|
|
199
|
+
return renderErrorPage(err.status, err.message, url, req);
|
|
200
|
+
}
|
|
201
|
+
if (isDev) console.error("Metadata load error:", err);
|
|
202
|
+
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
203
|
+
// Continue with null metadata — don't break the page for a metadata failure
|
|
204
|
+
}
|
|
135
205
|
|
|
136
206
|
// Kick off imports immediately (parallel with data loading)
|
|
137
207
|
const pageModPromise = route.pageModule();
|
|
138
208
|
const layoutModsPromise = Promise.all(route.layoutModules.map((l: () => Promise<any>) => l()));
|
|
139
209
|
|
|
210
|
+
const enc = new TextEncoder();
|
|
211
|
+
|
|
140
212
|
const stream = new ReadableStream<Uint8Array>({
|
|
141
213
|
async start(controller) {
|
|
142
|
-
// Chunk 1:
|
|
143
|
-
controller.enqueue(enc.encode(
|
|
214
|
+
// Chunk 1: head opening (CSS, modulepreload — cached)
|
|
215
|
+
controller.enqueue(enc.encode(buildHtmlShellOpen()));
|
|
216
|
+
|
|
217
|
+
// Chunk 2: metadata tags, close </head>, open <body> + spinner
|
|
218
|
+
controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
|
|
144
219
|
|
|
145
220
|
try {
|
|
221
|
+
// Pass metadata.data to load() so it can reuse fetched data
|
|
222
|
+
const metadataData = metadata?.data ?? null;
|
|
223
|
+
|
|
224
|
+
// Wait for data + component imports
|
|
146
225
|
const [data, pageMod, layoutMods] = await Promise.all([
|
|
147
|
-
loadRouteData(url, locals, req, cookies),
|
|
226
|
+
loadRouteData(url, locals, req, cookies, metadataData),
|
|
148
227
|
pageModPromise,
|
|
149
228
|
layoutModsPromise,
|
|
150
229
|
]);
|
|
@@ -165,10 +244,11 @@ export function renderSSRStream(
|
|
|
165
244
|
},
|
|
166
245
|
});
|
|
167
246
|
|
|
168
|
-
// Chunk
|
|
247
|
+
// Chunk 3: rendered content
|
|
169
248
|
controller.enqueue(enc.encode(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)));
|
|
170
249
|
controller.close();
|
|
171
250
|
} catch (err) {
|
|
251
|
+
// Head is closed and body is open at this point — HTML structure is valid
|
|
172
252
|
if (err instanceof Redirect) {
|
|
173
253
|
controller.enqueue(enc.encode(
|
|
174
254
|
`<script>location.replace(${safeJsonStringify(err.location)})</script></body></html>`
|
|
@@ -178,7 +258,7 @@ export function renderSSRStream(
|
|
|
178
258
|
}
|
|
179
259
|
if (err instanceof HttpError) {
|
|
180
260
|
controller.enqueue(enc.encode(
|
|
181
|
-
`<script>location.replace("/__bunia/error?status=${err.status}&message
|
|
261
|
+
`<script>location.replace("/__bunia/error?status=${err.status}&message="+encodeURIComponent(${safeJsonStringify(err.message)}))</script></body></html>`
|
|
182
262
|
));
|
|
183
263
|
controller.close();
|
|
184
264
|
return;
|
|
@@ -196,6 +276,47 @@ export function renderSSRStream(
|
|
|
196
276
|
});
|
|
197
277
|
}
|
|
198
278
|
|
|
279
|
+
// ─── Form Action Page Renderer ───────────────────────────
|
|
280
|
+
// Re-runs load functions after a form action, renders with form data.
|
|
281
|
+
// Uses non-streaming buildHtml so we can control the status code.
|
|
282
|
+
|
|
283
|
+
export async function renderPageWithFormData(
|
|
284
|
+
url: URL,
|
|
285
|
+
locals: Record<string, any>,
|
|
286
|
+
req: Request,
|
|
287
|
+
cookies: Cookies,
|
|
288
|
+
formData: any,
|
|
289
|
+
status: number,
|
|
290
|
+
): Promise<Response> {
|
|
291
|
+
const match = findMatch(serverRoutes, url.pathname);
|
|
292
|
+
if (!match) return renderErrorPage(404, "Not Found", url, req);
|
|
293
|
+
|
|
294
|
+
const { route } = match;
|
|
295
|
+
|
|
296
|
+
// Load components + data in parallel
|
|
297
|
+
const [data, pageMod, layoutMods] = await Promise.all([
|
|
298
|
+
loadRouteData(url, locals, req, cookies),
|
|
299
|
+
route.pageModule(),
|
|
300
|
+
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
if (!data) return renderErrorPage(404, "Not Found", url, req);
|
|
304
|
+
|
|
305
|
+
const { body, head } = render(App, {
|
|
306
|
+
props: {
|
|
307
|
+
ssrMode: true,
|
|
308
|
+
ssrPageComponent: pageMod.default,
|
|
309
|
+
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
310
|
+
ssrPageData: data.pageData,
|
|
311
|
+
ssrLayoutData: data.layoutData,
|
|
312
|
+
ssrFormData: formData,
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const html = buildHtml(body, head, data.pageData, data.layoutData, data.csr, formData);
|
|
317
|
+
return compress(html, "text/html; charset=utf-8", req, status);
|
|
318
|
+
}
|
|
319
|
+
|
|
199
320
|
// ─── Error Page Renderer ──────────────────────────────────
|
|
200
321
|
|
|
201
322
|
export async function renderErrorPage(status: number, message: string, url: URL, req: Request): Promise<Response> {
|
package/src/core/routeFile.ts
CHANGED
|
@@ -7,11 +7,33 @@ import type { RouteManifest } from "./types.ts";
|
|
|
7
7
|
// serverRoutes — used by SSR renderer (+ pageServer + layoutServers)
|
|
8
8
|
// apiRoutes — used by API handler
|
|
9
9
|
|
|
10
|
+
function routePriority(pattern: string): number {
|
|
11
|
+
if (!pattern.includes("[")) return 0; // exact
|
|
12
|
+
if (!pattern.includes("[...")) return 1; // dynamic
|
|
13
|
+
return 2; // catch-all
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
|
|
17
|
+
return [...routes].sort((a, b) => {
|
|
18
|
+
const pa = routePriority(a.pattern);
|
|
19
|
+
const pb = routePriority(b.pattern);
|
|
20
|
+
if (pa !== pb) return pa - pb;
|
|
21
|
+
// same tier: more segments first, then alphabetical
|
|
22
|
+
const sa = a.pattern.split("/").length;
|
|
23
|
+
const sb = b.pattern.split("/").length;
|
|
24
|
+
if (sa !== sb) return sb - sa;
|
|
25
|
+
return a.pattern.localeCompare(b.pattern);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
export function generateRoutesFile(manifest: RouteManifest): void {
|
|
11
30
|
const lines: string[] = [
|
|
12
31
|
"// AUTO-GENERATED by bunia build — do not edit\n",
|
|
13
32
|
];
|
|
14
33
|
|
|
34
|
+
const pages = sortRoutes(manifest.pages);
|
|
35
|
+
const apis = sortRoutes(manifest.apis);
|
|
36
|
+
|
|
15
37
|
// clientRoutes
|
|
16
38
|
lines.push("export const clientRoutes: Array<{");
|
|
17
39
|
lines.push(" pattern: string;");
|
|
@@ -19,7 +41,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
19
41
|
lines.push(" layouts: (() => Promise<any>)[];");
|
|
20
42
|
lines.push(" hasServerData: boolean;");
|
|
21
43
|
lines.push("}> = [");
|
|
22
|
-
for (const r of
|
|
44
|
+
for (const r of pages) {
|
|
23
45
|
const layoutImports = r.layouts
|
|
24
46
|
.map(l => `() => import(${JSON.stringify(toImportPath(l))})`)
|
|
25
47
|
.join(", ");
|
|
@@ -41,7 +63,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
41
63
|
lines.push(" pageServer: (() => Promise<any>) | null;");
|
|
42
64
|
lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
|
|
43
65
|
lines.push("}> = [");
|
|
44
|
-
for (const r of
|
|
66
|
+
for (const r of pages) {
|
|
45
67
|
const layoutImports = r.layouts
|
|
46
68
|
.map(l => `() => import(${JSON.stringify(toImportPath(l))})`)
|
|
47
69
|
.join(", ");
|
|
@@ -63,7 +85,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
63
85
|
lines.push(" pattern: string;");
|
|
64
86
|
lines.push(" module: () => Promise<any>;");
|
|
65
87
|
lines.push("}> = [");
|
|
66
|
-
for (const r of
|
|
88
|
+
for (const r of apis) {
|
|
67
89
|
lines.push(" {");
|
|
68
90
|
lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
|
|
69
91
|
lines.push(` module: () => import(${JSON.stringify(toImportPath(r.server))}),`);
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -51,6 +51,17 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
51
51
|
}
|
|
52
52
|
lines.push(`export type PageProps = { data: PageData };`);
|
|
53
53
|
|
|
54
|
+
// ActionData — union of all action return types, unwrapping ActionFailure
|
|
55
|
+
if (info.pageServer) {
|
|
56
|
+
lines.push(``);
|
|
57
|
+
lines.push(`import type { actions as _actions } from '${srcBase}+page.server.ts';`);
|
|
58
|
+
lines.push(`type _ActionReturn<T> = T extends (...args: any[]) => infer R ? Awaited<R> : never;`);
|
|
59
|
+
lines.push(`type _UnwrapFailure<T> = T extends { status: number; data: infer D } ? D : T;`);
|
|
60
|
+
lines.push(`export type ActionData = _actions extends Record<string, (...args: any[]) => any>`);
|
|
61
|
+
lines.push(` ? _UnwrapFailure<_ActionReturn<_actions[keyof _actions]>> | null`);
|
|
62
|
+
lines.push(` : null;`);
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
if (info.layoutServer) {
|
|
55
66
|
lines.push(`\nimport type { load as _layoutLoad } from '${srcBase}+layout.server.ts';`);
|
|
56
67
|
lines.push(`export type LayoutData = Awaited<ReturnType<typeof _layoutLoad>> & { params: Record<string, string> };`);
|
package/src/core/server.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { Elysia } from "elysia";
|
|
2
2
|
import { staticPlugin } from "@elysiajs/static";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
|
-
import { join } from "path";
|
|
4
|
+
import { join, resolve as resolvePath } from "path";
|
|
5
5
|
|
|
6
6
|
import { findMatch } from "./matcher.ts";
|
|
7
|
-
import { apiRoutes } from "bunia:routes";
|
|
7
|
+
import { apiRoutes, serverRoutes } from "bunia:routes";
|
|
8
8
|
import type { Handle, RequestEvent } from "./hooks.ts";
|
|
9
|
-
import { HttpError, Redirect } from "./errors.ts";
|
|
9
|
+
import { HttpError, Redirect, ActionFailure } from "./errors.ts";
|
|
10
10
|
import { CookieJar } from "./cookies.ts";
|
|
11
11
|
import { checkCsrf } from "./csrf.ts";
|
|
12
12
|
import type { CsrfConfig } from "./csrf.ts";
|
|
13
13
|
import { getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
14
14
|
import type { CorsConfig } from "./cors.ts";
|
|
15
15
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
16
|
-
import { loadRouteData, renderSSRStream, renderErrorPage } from "./renderer.ts";
|
|
16
|
+
import { loadRouteData, renderSSRStream, renderErrorPage, renderPageWithFormData } from "./renderer.ts";
|
|
17
17
|
import { getServerTime } from "../lib/utils.ts";
|
|
18
18
|
|
|
19
19
|
// ─── User Hooks ──────────────────────────────────────────
|
|
@@ -94,6 +94,21 @@ function isValidRoutePath(path: string, origin: string): boolean {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** Resolve a file path and verify it stays within the allowed base directory. Returns null if traversal detected. */
|
|
98
|
+
function safePath(base: string, untrusted: string): string | null {
|
|
99
|
+
const root = resolvePath(base);
|
|
100
|
+
const full = resolvePath(join(base, untrusted));
|
|
101
|
+
return full.startsWith(root + "/") || full === root ? full : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
|
|
105
|
+
function parseActionName(url: URL): string {
|
|
106
|
+
for (const key of url.searchParams.keys()) {
|
|
107
|
+
if (key.startsWith("/")) return key.slice(1) || "default";
|
|
108
|
+
}
|
|
109
|
+
return "default";
|
|
110
|
+
}
|
|
111
|
+
|
|
97
112
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
98
113
|
const { request, url, locals, cookies } = event;
|
|
99
114
|
const path = url.pathname;
|
|
@@ -135,35 +150,48 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
135
150
|
if (isStaticPath(path)) {
|
|
136
151
|
// dist/client: serve with cache headers based on whether filename is hashed
|
|
137
152
|
if (path.startsWith("/dist/client/")) {
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
const resolved = safePath("./dist/client", path.split("?")[0].slice("/dist/client".length));
|
|
154
|
+
if (resolved) {
|
|
155
|
+
const file = Bun.file(resolved);
|
|
156
|
+
if (await file.exists()) {
|
|
157
|
+
const filename = path.split("/").pop() ?? "";
|
|
158
|
+
const isHashed = /\-[a-z0-9]{8,}\.[a-z]+$/.test(filename);
|
|
159
|
+
const cacheControl = !isDev && isHashed
|
|
160
|
+
? "public, max-age=31536000, immutable"
|
|
161
|
+
: "no-cache";
|
|
162
|
+
return new Response(file, { headers: { "Cache-Control": cacheControl } });
|
|
163
|
+
}
|
|
146
164
|
}
|
|
147
165
|
return new Response("Not Found", { status: 404 });
|
|
148
166
|
}
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
167
|
+
const pubPath = safePath("./public", path);
|
|
168
|
+
if (pubPath) {
|
|
169
|
+
const pub = Bun.file(pubPath);
|
|
170
|
+
if (await pub.exists()) return new Response(pub);
|
|
171
|
+
}
|
|
172
|
+
const distPath = safePath("./dist", path);
|
|
173
|
+
if (distPath) {
|
|
174
|
+
const dist = Bun.file(distPath);
|
|
175
|
+
if (await dist.exists()) return new Response(dist);
|
|
176
|
+
}
|
|
153
177
|
return new Response("Not Found", { status: 404 });
|
|
154
178
|
}
|
|
155
179
|
|
|
156
180
|
// Prerendered pages — serve static HTML built at build time
|
|
157
|
-
const
|
|
158
|
-
|
|
181
|
+
const prerenderPath = safePath(
|
|
182
|
+
"./dist/prerendered",
|
|
183
|
+
path === "/" ? "index.html" : `${path}/index.html`,
|
|
159
184
|
);
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
185
|
+
if (prerenderPath) {
|
|
186
|
+
const prerenderFile = Bun.file(prerenderPath);
|
|
187
|
+
if (await prerenderFile.exists()) {
|
|
188
|
+
return new Response(prerenderFile, {
|
|
189
|
+
headers: {
|
|
190
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
191
|
+
"Cache-Control": "public, max-age=3600",
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
// API routes (+server.ts)
|
|
@@ -190,8 +218,71 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
190
218
|
}
|
|
191
219
|
}
|
|
192
220
|
|
|
221
|
+
// Form actions — POST to page routes with `actions` export
|
|
222
|
+
if (method === "POST") {
|
|
223
|
+
const pageMatch = findMatch(serverRoutes, path);
|
|
224
|
+
if (pageMatch?.route.pageServer) {
|
|
225
|
+
try {
|
|
226
|
+
const mod = await pageMatch.route.pageServer();
|
|
227
|
+
if (mod.actions && typeof mod.actions === "object") {
|
|
228
|
+
const actionName = parseActionName(url);
|
|
229
|
+
const action = mod.actions[actionName];
|
|
230
|
+
if (!action) {
|
|
231
|
+
return renderErrorPage(404, `Action "${actionName}" not found`, url, request);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
event.params = pageMatch.params;
|
|
235
|
+
let result: any;
|
|
236
|
+
try {
|
|
237
|
+
result = await action(event);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err instanceof Redirect) {
|
|
240
|
+
return new Response(null, {
|
|
241
|
+
status: 303,
|
|
242
|
+
headers: { Location: err.location },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (err instanceof HttpError) {
|
|
246
|
+
return renderErrorPage(err.status, err.message, url, request);
|
|
247
|
+
}
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Redirect returned (not thrown)
|
|
252
|
+
if (result instanceof Redirect) {
|
|
253
|
+
return new Response(null, {
|
|
254
|
+
status: 303,
|
|
255
|
+
headers: { Location: result.location },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ActionFailure — re-render with failure status
|
|
260
|
+
if (result instanceof ActionFailure) {
|
|
261
|
+
return renderPageWithFormData(url, locals, request, cookies, result.data, result.status);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Success — re-render page with action return data
|
|
265
|
+
return renderPageWithFormData(url, locals, request, cookies, result ?? null, 200);
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (err instanceof Redirect) {
|
|
269
|
+
return new Response(null, {
|
|
270
|
+
status: 303,
|
|
271
|
+
headers: { Location: err.location },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (err instanceof HttpError) {
|
|
275
|
+
return renderErrorPage(err.status, err.message, url, request);
|
|
276
|
+
}
|
|
277
|
+
if (isDev) console.error("Form action error:", err);
|
|
278
|
+
else console.error("Form action error:", (err as Error).message ?? err);
|
|
279
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
193
284
|
// SSR pages (+page.svelte) — streaming by default
|
|
194
|
-
const streamResponse = renderSSRStream(url, locals, request, cookies);
|
|
285
|
+
const streamResponse = await renderSSRStream(url, locals, request, cookies);
|
|
195
286
|
if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
|
|
196
287
|
return streamResponse;
|
|
197
288
|
}
|
|
@@ -293,7 +384,10 @@ const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
|
|
|
293
384
|
return handleRequest(request, url);
|
|
294
385
|
})
|
|
295
386
|
// Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
|
|
296
|
-
.post("*", () =>
|
|
387
|
+
.post("*", ({ request }) => {
|
|
388
|
+
const url = new URL(request.url);
|
|
389
|
+
return handleRequest(request, url);
|
|
390
|
+
})
|
|
297
391
|
.put("*", () => new Response("Not Found", { status: 404 }))
|
|
298
392
|
.patch("*", () => new Response("Not Found", { status: 404 }))
|
|
299
393
|
.delete("*", () => new Response("Not Found", { status: 404 }))
|
package/src/lib/index.ts
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
export { cn, getServerTime } from "./utils.ts";
|
|
7
7
|
export { sequence } from "../core/hooks.ts";
|
|
8
|
-
export { error, redirect } from "../core/errors.ts";
|
|
9
|
-
export type { HttpError, Redirect } from "../core/errors.ts";
|
|
8
|
+
export { error, redirect, fail } from "../core/errors.ts";
|
|
9
|
+
export type { HttpError, Redirect, ActionFailure } from "../core/errors.ts";
|
|
10
10
|
export type {
|
|
11
11
|
RequestEvent,
|
|
12
12
|
LoadEvent,
|
|
13
|
+
MetadataEvent,
|
|
14
|
+
Metadata,
|
|
13
15
|
Handle,
|
|
14
16
|
ResolveFunction,
|
|
15
17
|
Cookies,
|
|
@@ -14,16 +14,57 @@
|
|
|
14
14
|
# Import in your code:
|
|
15
15
|
# import { PUBLIC_STATIC_APP_NAME, DB_PASSWORD } from 'bunia:env';
|
|
16
16
|
#
|
|
17
|
-
# Framework vars (PORT, NODE_ENV, BODY_SIZE_LIMIT, CSRF_ALLOWED_ORIGINS
|
|
18
|
-
# NOT exposed via bunia:env —
|
|
17
|
+
# Framework vars (PORT, NODE_ENV, BODY_SIZE_LIMIT, CSRF_ALLOWED_ORIGINS,
|
|
18
|
+
# CORS_*, LOAD_TIMEOUT, METADATA_TIMEOUT, PRERENDER_TIMEOUT) are NOT exposed via bunia:env —
|
|
19
|
+
# access them via process.env directly.
|
|
19
20
|
# ────────────────────────────────────────────────────────────────────────────────
|
|
20
21
|
|
|
21
22
|
# Public build-time constant (safe to expose to client)
|
|
22
23
|
PUBLIC_STATIC_APP_NAME=My Bunia App
|
|
23
24
|
|
|
24
|
-
# Framework vars — access via process.env
|
|
25
|
+
# ─── Framework vars — access via process.env (not via bunia:env) ─────────────
|
|
26
|
+
|
|
27
|
+
# Server port. Defaults to 9000 in production, 9001 in dev (proxied via :9000).
|
|
25
28
|
# PORT=9000
|
|
26
29
|
|
|
30
|
+
# Maximum request body size. Supports K/M/G suffixes or "Infinity". Defaults to 512K.
|
|
31
|
+
# BODY_SIZE_LIMIT=512K
|
|
32
|
+
|
|
33
|
+
# Timeout for load() functions (layout + page) in milliseconds. Defaults to 5000 (5s).
|
|
34
|
+
# Set to 0 or Infinity to disable.
|
|
35
|
+
# LOAD_TIMEOUT=5000
|
|
36
|
+
|
|
37
|
+
# Timeout for metadata() functions in milliseconds. Defaults to 3000 (3s).
|
|
38
|
+
# Set to 0 or Infinity to disable.
|
|
39
|
+
# METADATA_TIMEOUT=3000
|
|
40
|
+
|
|
41
|
+
# Timeout for prerender fetches during build in milliseconds. Defaults to 5000 (5s).
|
|
42
|
+
# Set to 0 or Infinity to disable.
|
|
43
|
+
# PRERENDER_TIMEOUT=5000
|
|
44
|
+
|
|
45
|
+
# Comma-separated list of allowed origins for CSRF validation.
|
|
46
|
+
# Leave unset to allow same-origin requests only.
|
|
47
|
+
# CSRF_ALLOWED_ORIGINS=
|
|
48
|
+
|
|
49
|
+
# Comma-separated list of origins allowed to make cross-origin requests.
|
|
50
|
+
# Leave unset to disable CORS.
|
|
51
|
+
# CORS_ALLOWED_ORIGINS=
|
|
52
|
+
|
|
53
|
+
# Comma-separated HTTP methods to allow in CORS requests. Default: GET, HEAD, PUT, PATCH, POST, DELETE
|
|
54
|
+
# CORS_ALLOWED_METHODS=GET, POST
|
|
55
|
+
|
|
56
|
+
# Comma-separated request headers to allow in CORS requests. Default: Content-Type, Authorization
|
|
57
|
+
# CORS_ALLOWED_HEADERS=Content-Type, Authorization
|
|
58
|
+
|
|
59
|
+
# Comma-separated response headers to expose to the browser. Default: none
|
|
60
|
+
# CORS_EXPOSED_HEADERS=
|
|
61
|
+
|
|
62
|
+
# Allow cookies and auth credentials in cross-origin requests. Default: false
|
|
63
|
+
# CORS_CREDENTIALS=true
|
|
64
|
+
|
|
65
|
+
# Preflight response cache duration in seconds. Default: 86400 (24 hours)
|
|
66
|
+
# CORS_MAX_AGE=86400
|
|
67
|
+
|
|
27
68
|
# Public runtime var (safe to expose to client, can change without rebuild)
|
|
28
69
|
# PUBLIC_API_URL=https://api.example.com
|
|
29
70
|
|