bosbun 0.0.1 → 0.0.3
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/errors.ts +12 -0
- package/src/core/hooks.ts +18 -0
- package/src/core/html.ts +59 -12
- package/src/core/matcher.ts +10 -15
- package/src/core/renderer.ts +83 -8
- package/src/core/routeFile.ts +25 -3
- package/src/core/routeTypes.ts +11 -0
- package/src/core/server.ts +78 -4
- package/src/lib/index.ts +4 -2
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/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
|
@@ -56,6 +56,7 @@ export function buildHtml(
|
|
|
56
56
|
pageData: any,
|
|
57
57
|
layoutData: any[],
|
|
58
58
|
csr = true,
|
|
59
|
+
formData: any = null,
|
|
59
60
|
): string {
|
|
60
61
|
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
61
62
|
|
|
@@ -70,8 +71,12 @@ export function buildHtml(
|
|
|
70
71
|
? `\n <script>window.__BUNIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
|
|
71
72
|
: "";
|
|
72
73
|
|
|
74
|
+
const formScript = formData != null
|
|
75
|
+
? `window.__BUNIA_FORM_DATA__=${safeJsonStringify(formData)};`
|
|
76
|
+
: "";
|
|
77
|
+
|
|
73
78
|
const scripts = csr
|
|
74
|
-
? `${envScript}\n <script>window.__BUNIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BUNIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)}
|
|
79
|
+
? `${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
80
|
: isDev
|
|
76
81
|
? `\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
82
|
: "";
|
|
@@ -95,29 +100,69 @@ export function buildHtml(
|
|
|
95
100
|
|
|
96
101
|
// ─── Streaming HTML Helpers ──────────────────────────────
|
|
97
102
|
|
|
103
|
+
import type { Metadata } from "./hooks.ts";
|
|
104
|
+
|
|
98
105
|
let _shell: string | null = null;
|
|
99
106
|
|
|
100
107
|
export function buildHtmlShell(): string {
|
|
101
108
|
if (_shell) return _shell;
|
|
109
|
+
_shell = buildHtmlShellOpen() + buildMetadataChunk(null);
|
|
110
|
+
return _shell;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let _shellOpen: string | null = null;
|
|
114
|
+
|
|
115
|
+
/** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
|
|
116
|
+
export function buildHtmlShellOpen(): string {
|
|
117
|
+
if (_shellOpen) return _shellOpen;
|
|
102
118
|
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
103
119
|
const cssLinks = (distManifest.css ?? [])
|
|
104
120
|
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
105
121
|
.join("\n ");
|
|
106
|
-
|
|
122
|
+
_shellOpen = `<!DOCTYPE html>\n<html lang="en">\n<head>\n` +
|
|
107
123
|
` <meta charset="UTF-8">\n` +
|
|
108
124
|
` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
109
125
|
` <link rel="icon" href="data:,">\n` +
|
|
110
126
|
` ${cssLinks}\n` +
|
|
111
127
|
` <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
|
-
|
|
128
|
+
` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`;
|
|
129
|
+
return _shellOpen;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SPINNER = `<div id="__bs__"><style>` +
|
|
133
|
+
`:root{--bunia-loading-color:#f73b27}` +
|
|
134
|
+
`#__bs__{position:fixed;inset:0;display:flex;align-items:center;justify-content:center}` +
|
|
135
|
+
`#__bs__ i{width:32px;height:32px;border:3px solid #e5e7eb;border-top-color:var(--bunia-loading-color);` +
|
|
136
|
+
`border-radius:50%;animation:__bs__ .8s linear infinite}` +
|
|
137
|
+
`@keyframes __bs__{to{transform:rotate(360deg)}}</style><i></i></div>`;
|
|
138
|
+
|
|
139
|
+
/** Chunk 2: metadata tags + close </head> + open <body> + spinner */
|
|
140
|
+
export function buildMetadataChunk(metadata: Metadata | null): string {
|
|
141
|
+
let out = "\n";
|
|
142
|
+
if (metadata) {
|
|
143
|
+
if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
|
|
144
|
+
if (metadata.description) {
|
|
145
|
+
out += ` <meta name="description" content="${escapeAttr(metadata.description)}">\n`;
|
|
146
|
+
}
|
|
147
|
+
if (metadata.meta) {
|
|
148
|
+
for (const m of metadata.meta) {
|
|
149
|
+
const attrs = m.name ? `name="${escapeAttr(m.name)}"` : `property="${escapeAttr(m.property ?? "")}"`;
|
|
150
|
+
out += ` <meta ${attrs} content="${escapeAttr(m.content)}">\n`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
out += ` <title>Bunia App</title>\n`;
|
|
155
|
+
}
|
|
156
|
+
out += `</head>\n<body>\n${SPINNER}`;
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function escapeHtml(s: string): string {
|
|
161
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function escapeAttr(s: string): string {
|
|
165
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
121
166
|
}
|
|
122
167
|
|
|
123
168
|
export function buildHtmlTail(
|
|
@@ -126,6 +171,7 @@ export function buildHtmlTail(
|
|
|
126
171
|
pageData: any,
|
|
127
172
|
layoutData: any[],
|
|
128
173
|
csr: boolean,
|
|
174
|
+
formData: any = null,
|
|
129
175
|
): string {
|
|
130
176
|
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
131
177
|
let out = `<script>document.getElementById('__bs__').remove()</script>`;
|
|
@@ -136,8 +182,9 @@ export function buildHtmlTail(
|
|
|
136
182
|
if (Object.keys(publicEnv).length > 0) {
|
|
137
183
|
out += `\n<script>window.__BUNIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
|
|
138
184
|
}
|
|
185
|
+
const formInject = formData != null ? `window.__BUNIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
|
|
139
186
|
out += `\n<script>window.__BUNIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
|
|
140
|
-
`window.__BUNIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)}
|
|
187
|
+
`window.__BUNIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formInject}</script>`;
|
|
141
188
|
out += `\n<script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
|
|
142
189
|
} else if (isDev) {
|
|
143
190
|
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/renderer.ts
CHANGED
|
@@ -5,7 +5,8 @@ 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";
|
|
9
10
|
|
|
10
11
|
// ─── Session-Aware Fetch ─────────────────────────────────
|
|
11
12
|
// Passed to load() functions so they can call internal APIs
|
|
@@ -37,6 +38,7 @@ export async function loadRouteData(
|
|
|
37
38
|
locals: Record<string, any>,
|
|
38
39
|
req: Request,
|
|
39
40
|
cookies: Cookies,
|
|
41
|
+
metadataData: Record<string, any> | null = null,
|
|
40
42
|
) {
|
|
41
43
|
const match = findMatch(serverRoutes, url.pathname);
|
|
42
44
|
if (!match) return null;
|
|
@@ -55,7 +57,7 @@ export async function loadRouteData(
|
|
|
55
57
|
for (let d = 0; d < ls.depth; d++) Object.assign(merged, layoutData[d] ?? {});
|
|
56
58
|
return merged;
|
|
57
59
|
};
|
|
58
|
-
layoutData[ls.depth] = (await mod.load({ params, url, locals, cookies, parent, fetch })) ?? {};
|
|
60
|
+
layoutData[ls.depth] = (await mod.load({ params, url, locals, cookies, parent, fetch, metadata: null })) ?? {};
|
|
59
61
|
}
|
|
60
62
|
} catch (err) {
|
|
61
63
|
if (err instanceof HttpError || err instanceof Redirect) throw err;
|
|
@@ -77,7 +79,7 @@ export async function loadRouteData(
|
|
|
77
79
|
for (const d of layoutData) if (d) Object.assign(merged, d);
|
|
78
80
|
return merged;
|
|
79
81
|
};
|
|
80
|
-
pageData = (await mod.load({ params, url, locals, cookies, parent, fetch })) ?? {};
|
|
82
|
+
pageData = (await mod.load({ params, url, locals, cookies, parent, fetch, metadata: metadataData })) ?? {};
|
|
81
83
|
}
|
|
82
84
|
} catch (err) {
|
|
83
85
|
if (err instanceof HttpError || err instanceof Redirect) throw err;
|
|
@@ -119,6 +121,30 @@ export async function renderSSR(url: URL, locals: Record<string, any>, req: Requ
|
|
|
119
121
|
return { body, head, pageData: data.pageData, layoutData: data.layoutData, csr: data.csr };
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
// ─── Metadata Loader ─────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
async function loadMetadata(
|
|
127
|
+
route: any,
|
|
128
|
+
params: Record<string, string>,
|
|
129
|
+
url: URL,
|
|
130
|
+
locals: Record<string, any>,
|
|
131
|
+
cookies: Cookies,
|
|
132
|
+
req: Request,
|
|
133
|
+
): Promise<Metadata | null> {
|
|
134
|
+
if (!route.pageServer) return null;
|
|
135
|
+
try {
|
|
136
|
+
const mod = await route.pageServer();
|
|
137
|
+
if (typeof mod.metadata === "function") {
|
|
138
|
+
const fetch = makeFetch(req, url);
|
|
139
|
+
return (await mod.metadata({ params, url, locals, cookies, fetch })) ?? null;
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (isDev) console.error("Metadata load error:", err);
|
|
143
|
+
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
// ─── Streaming SSR Renderer ──────────────────────────────
|
|
123
149
|
|
|
124
150
|
export function renderSSRStream(
|
|
@@ -130,7 +156,7 @@ export function renderSSRStream(
|
|
|
130
156
|
const match = findMatch(serverRoutes, url.pathname);
|
|
131
157
|
if (!match) return null;
|
|
132
158
|
|
|
133
|
-
const { route } = match;
|
|
159
|
+
const { route, params } = match;
|
|
134
160
|
const enc = new TextEncoder();
|
|
135
161
|
|
|
136
162
|
// Kick off imports immediately (parallel with data loading)
|
|
@@ -139,12 +165,20 @@ export function renderSSRStream(
|
|
|
139
165
|
|
|
140
166
|
const stream = new ReadableStream<Uint8Array>({
|
|
141
167
|
async start(controller) {
|
|
142
|
-
// Chunk 1:
|
|
143
|
-
controller.enqueue(enc.encode(
|
|
168
|
+
// Chunk 1: head opening (CSS, modulepreload — cached)
|
|
169
|
+
controller.enqueue(enc.encode(buildHtmlShellOpen()));
|
|
144
170
|
|
|
145
171
|
try {
|
|
172
|
+
// Chunk 2: metadata() resolves → send title/meta, close head, open body + spinner
|
|
173
|
+
const metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
174
|
+
controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
|
|
175
|
+
|
|
176
|
+
// Pass metadata.data to load() so it can reuse fetched data
|
|
177
|
+
const metadataData = metadata?.data ?? null;
|
|
178
|
+
|
|
179
|
+
// Wait for data + component imports
|
|
146
180
|
const [data, pageMod, layoutMods] = await Promise.all([
|
|
147
|
-
loadRouteData(url, locals, req, cookies),
|
|
181
|
+
loadRouteData(url, locals, req, cookies, metadataData),
|
|
148
182
|
pageModPromise,
|
|
149
183
|
layoutModsPromise,
|
|
150
184
|
]);
|
|
@@ -165,7 +199,7 @@ export function renderSSRStream(
|
|
|
165
199
|
},
|
|
166
200
|
});
|
|
167
201
|
|
|
168
|
-
// Chunk
|
|
202
|
+
// Chunk 3: rendered content
|
|
169
203
|
controller.enqueue(enc.encode(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)));
|
|
170
204
|
controller.close();
|
|
171
205
|
} catch (err) {
|
|
@@ -196,6 +230,47 @@ export function renderSSRStream(
|
|
|
196
230
|
});
|
|
197
231
|
}
|
|
198
232
|
|
|
233
|
+
// ─── Form Action Page Renderer ───────────────────────────
|
|
234
|
+
// Re-runs load functions after a form action, renders with form data.
|
|
235
|
+
// Uses non-streaming buildHtml so we can control the status code.
|
|
236
|
+
|
|
237
|
+
export async function renderPageWithFormData(
|
|
238
|
+
url: URL,
|
|
239
|
+
locals: Record<string, any>,
|
|
240
|
+
req: Request,
|
|
241
|
+
cookies: Cookies,
|
|
242
|
+
formData: any,
|
|
243
|
+
status: number,
|
|
244
|
+
): Promise<Response> {
|
|
245
|
+
const match = findMatch(serverRoutes, url.pathname);
|
|
246
|
+
if (!match) return renderErrorPage(404, "Not Found", url, req);
|
|
247
|
+
|
|
248
|
+
const { route } = match;
|
|
249
|
+
|
|
250
|
+
// Load components + data in parallel
|
|
251
|
+
const [data, pageMod, layoutMods] = await Promise.all([
|
|
252
|
+
loadRouteData(url, locals, req, cookies),
|
|
253
|
+
route.pageModule(),
|
|
254
|
+
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
if (!data) return renderErrorPage(404, "Not Found", url, req);
|
|
258
|
+
|
|
259
|
+
const { body, head } = render(App, {
|
|
260
|
+
props: {
|
|
261
|
+
ssrMode: true,
|
|
262
|
+
ssrPageComponent: pageMod.default,
|
|
263
|
+
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
264
|
+
ssrPageData: data.pageData,
|
|
265
|
+
ssrLayoutData: data.layoutData,
|
|
266
|
+
ssrFormData: formData,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const html = buildHtml(body, head, data.pageData, data.layoutData, data.csr, formData);
|
|
271
|
+
return compress(html, "text/html; charset=utf-8", req, status);
|
|
272
|
+
}
|
|
273
|
+
|
|
199
274
|
// ─── Error Page Renderer ──────────────────────────────────
|
|
200
275
|
|
|
201
276
|
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
|
@@ -4,16 +4,16 @@ import { existsSync } from "fs";
|
|
|
4
4
|
import { join } 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,14 @@ function isValidRoutePath(path: string, origin: string): boolean {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
|
|
98
|
+
function parseActionName(url: URL): string {
|
|
99
|
+
for (const key of url.searchParams.keys()) {
|
|
100
|
+
if (key.startsWith("/")) return key.slice(1) || "default";
|
|
101
|
+
}
|
|
102
|
+
return "default";
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
98
106
|
const { request, url, locals, cookies } = event;
|
|
99
107
|
const path = url.pathname;
|
|
@@ -190,6 +198,69 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
190
198
|
}
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
// Form actions — POST to page routes with `actions` export
|
|
202
|
+
if (method === "POST") {
|
|
203
|
+
const pageMatch = findMatch(serverRoutes, path);
|
|
204
|
+
if (pageMatch?.route.pageServer) {
|
|
205
|
+
try {
|
|
206
|
+
const mod = await pageMatch.route.pageServer();
|
|
207
|
+
if (mod.actions && typeof mod.actions === "object") {
|
|
208
|
+
const actionName = parseActionName(url);
|
|
209
|
+
const action = mod.actions[actionName];
|
|
210
|
+
if (!action) {
|
|
211
|
+
return renderErrorPage(404, `Action "${actionName}" not found`, url, request);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
event.params = pageMatch.params;
|
|
215
|
+
let result: any;
|
|
216
|
+
try {
|
|
217
|
+
result = await action(event);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (err instanceof Redirect) {
|
|
220
|
+
return new Response(null, {
|
|
221
|
+
status: 303,
|
|
222
|
+
headers: { Location: err.location },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (err instanceof HttpError) {
|
|
226
|
+
return renderErrorPage(err.status, err.message, url, request);
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Redirect returned (not thrown)
|
|
232
|
+
if (result instanceof Redirect) {
|
|
233
|
+
return new Response(null, {
|
|
234
|
+
status: 303,
|
|
235
|
+
headers: { Location: result.location },
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ActionFailure — re-render with failure status
|
|
240
|
+
if (result instanceof ActionFailure) {
|
|
241
|
+
return renderPageWithFormData(url, locals, request, cookies, result.data, result.status);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Success — re-render page with action return data
|
|
245
|
+
return renderPageWithFormData(url, locals, request, cookies, result ?? null, 200);
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (err instanceof Redirect) {
|
|
249
|
+
return new Response(null, {
|
|
250
|
+
status: 303,
|
|
251
|
+
headers: { Location: err.location },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (err instanceof HttpError) {
|
|
255
|
+
return renderErrorPage(err.status, err.message, url, request);
|
|
256
|
+
}
|
|
257
|
+
if (isDev) console.error("Form action error:", err);
|
|
258
|
+
else console.error("Form action error:", (err as Error).message ?? err);
|
|
259
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
193
264
|
// SSR pages (+page.svelte) — streaming by default
|
|
194
265
|
const streamResponse = renderSSRStream(url, locals, request, cookies);
|
|
195
266
|
if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
|
|
@@ -293,7 +364,10 @@ const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
|
|
|
293
364
|
return handleRequest(request, url);
|
|
294
365
|
})
|
|
295
366
|
// Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
|
|
296
|
-
.post("*", () =>
|
|
367
|
+
.post("*", ({ request }) => {
|
|
368
|
+
const url = new URL(request.url);
|
|
369
|
+
return handleRequest(request, url);
|
|
370
|
+
})
|
|
297
371
|
.put("*", () => new Response("Not Found", { status: 404 }))
|
|
298
372
|
.patch("*", () => new Response("Not Found", { status: 404 }))
|
|
299
373
|
.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,
|