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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosbun",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "description": "A minimalist fullstack framework — SSR + Svelte 5 Runes + Bun + ElysiaJS",
6
6
  "keywords": [
@@ -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}
@@ -37,6 +37,7 @@ async function main() {
37
37
  ssrLayoutComponents,
38
38
  ssrPageData: (window as any).__BUNIA_PAGE_DATA__ ?? {},
39
39
  ssrLayoutData: (window as any).__BUNIA_LAYOUT_DATA__ ?? [],
40
+ ssrFormData: (window as any).__BUNIA_FORM_DATA__ ?? null,
40
41
  },
41
42
  });
42
43
  }
@@ -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)};</script>\n <script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
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
- _shell = `<!DOCTYPE html>\n<html lang="en">\n<head>\n` +
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}">\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;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
162
+ }
163
+
164
+ function escapeAttr(s: string): string {
165
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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)};</script>`;
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>`;
@@ -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
- * Uses 3-pass priority: exact → dynamic → catch-all.
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
- // 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 };
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
  }
@@ -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: shell (cached at startup)
143
- controller.enqueue(enc.encode(buildHtmlShell()));
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 2: content
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> {
@@ -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 manifest.pages) {
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 manifest.pages) {
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 manifest.apis) {
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))}),`);
@@ -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> };`);
@@ -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("*", () => new Response("Not Found", { status: 404 }))
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,