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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosbun",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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
  }
@@ -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) result[name] = decodeURIComponent(value);
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
- header += `; Path=${options?.path ?? "/"}`;
36
- if (options?.domain) header += `; Domain=${options.domain}`;
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) header += `; SameSite=${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 {
@@ -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
- return JSON.stringify(data).replace(/[<>&\u2028\u2029]/g, c => map[c]);
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 from process.env that were declared in .bunia/env.server.ts.
34
- * We read the generated server env module to know which keys to expose.
35
- * Falls back to an empty object if the module hasn't been generated yet (e.g., dev before first build).
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
- // Read keys from .bunia/env.server.ts declarations of PUBLIC_* (non-static) vars
39
- // by inspecting process.env keys that start with PUBLIC_ but not PUBLIC_STATIC_.
40
- // We only expose keys that came from .env files — tracked in process.env via loadEnv.
41
- // At runtime the server module exports are inlined; we collect from process.env here.
46
+ const declared = getDeclaredEnvKeys();
42
47
  const result: Record<string, string> = {};
43
- for (const [key, value] of Object.entries(process.env)) {
44
- if (key.startsWith("PUBLIC_") && !key.startsWith("PUBLIC_STATIC_") && value !== undefined) {
45
- result[key] = value;
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)};</script>\n <script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
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
- _shell = `<!DOCTYPE html>\n<html lang="en">\n<head>\n` +
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}">\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;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
168
+ }
169
+
170
+ function escapeAttr(s: string): string {
171
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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)};</script>`;
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>`;
@@ -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,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
- console.error(` ❌ Failed to prerender ${routePath}:`, err);
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
 
@@ -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
- const enc = new TextEncoder();
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: shell (cached at startup)
143
- controller.enqueue(enc.encode(buildHtmlShell()));
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 2: content
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=${encodeURIComponent(err.message)}")</script></body></html>`
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> {
@@ -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> };`);
@@ -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 file = Bun.file(`.${path.split("?")[0]}`);
139
- if (await file.exists()) {
140
- const filename = path.split("/").pop() ?? "";
141
- const isHashed = /\-[a-z0-9]{8,}\.[a-z]+$/.test(filename);
142
- const cacheControl = !isDev && isHashed
143
- ? "public, max-age=31536000, immutable"
144
- : "no-cache";
145
- return new Response(file, { headers: { "Cache-Control": cacheControl } });
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 pub = Bun.file(`./public${path}`);
150
- if (await pub.exists()) return new Response(pub);
151
- const dist = Bun.file(`.${path}`);
152
- if (await dist.exists()) return new Response(dist);
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 prerenderFile = Bun.file(
158
- path === "/" ? "./dist/prerendered/index.html" : `./dist/prerendered${path}/index.html`
181
+ const prerenderPath = safePath(
182
+ "./dist/prerendered",
183
+ path === "/" ? "index.html" : `${path}/index.html`,
159
184
  );
160
- if (await prerenderFile.exists()) {
161
- return new Response(prerenderFile, {
162
- headers: {
163
- "Content-Type": "text/html; charset=utf-8",
164
- "Cache-Control": "public, max-age=3600",
165
- },
166
- });
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("*", () => new Response("Not Found", { status: 404 }))
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) are
18
- # NOT exposed via bunia:env — access them via process.env directly.
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 directly (not via bunia: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