bosbun 0.0.3 → 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.3",
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": [
@@ -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 {
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;
@@ -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
 
@@ -8,6 +8,34 @@ import App from "./client/App.svelte";
8
8
  import { buildHtml, buildHtmlShell, buildHtmlShellOpen, buildMetadataChunk, buildHtmlTail, compress, safeJsonStringify, isDev } from "./html.ts";
9
9
  import type { Metadata } from "./hooks.ts";
10
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
+ }
38
+
11
39
  // ─── Session-Aware Fetch ─────────────────────────────────
12
40
  // Passed to load() functions so they can call internal APIs
13
41
  // with the current user's cookies automatically forwarded.
@@ -57,7 +85,7 @@ export async function loadRouteData(
57
85
  for (let d = 0; d < ls.depth; d++) Object.assign(merged, layoutData[d] ?? {});
58
86
  return merged;
59
87
  };
60
- layoutData[ls.depth] = (await mod.load({ params, url, locals, cookies, parent, fetch, metadata: null })) ?? {};
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})`)) ?? {};
61
89
  }
62
90
  } catch (err) {
63
91
  if (err instanceof HttpError || err instanceof Redirect) throw err;
@@ -79,7 +107,7 @@ export async function loadRouteData(
79
107
  for (const d of layoutData) if (d) Object.assign(merged, d);
80
108
  return merged;
81
109
  };
82
- pageData = (await mod.load({ params, url, locals, cookies, parent, fetch, metadata: metadataData })) ?? {};
110
+ pageData = (await withTimeout(mod.load({ params, url, locals, cookies, parent, fetch, metadata: metadataData }), LOAD_TIMEOUT, `page load (${url.pathname})`)) ?? {};
83
111
  }
84
112
  } catch (err) {
85
113
  if (err instanceof HttpError || err instanceof Redirect) throw err;
@@ -136,7 +164,7 @@ async function loadMetadata(
136
164
  const mod = await route.pageServer();
137
165
  if (typeof mod.metadata === "function") {
138
166
  const fetch = makeFetch(req, url);
139
- return (await mod.metadata({ params, url, locals, cookies, fetch })) ?? null;
167
+ return (await withTimeout(mod.metadata({ params, url, locals, cookies, fetch }), METADATA_TIMEOUT, `metadata (${url.pathname})`)) ?? null;
140
168
  }
141
169
  } catch (err) {
142
170
  if (isDev) console.error("Metadata load error:", err);
@@ -147,32 +175,49 @@ async function loadMetadata(
147
175
 
148
176
  // ─── Streaming SSR Renderer ──────────────────────────────
149
177
 
150
- export function renderSSRStream(
178
+ export async function renderSSRStream(
151
179
  url: URL,
152
180
  locals: Record<string, any>,
153
181
  req: Request,
154
182
  cookies: Cookies,
155
- ): Response | null {
183
+ ): Promise<Response | null> {
156
184
  const match = findMatch(serverRoutes, url.pathname);
157
185
  if (!match) return null;
158
186
 
159
187
  const { route, params } = match;
160
- const enc = new TextEncoder();
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
+ }
161
205
 
162
206
  // Kick off imports immediately (parallel with data loading)
163
207
  const pageModPromise = route.pageModule();
164
208
  const layoutModsPromise = Promise.all(route.layoutModules.map((l: () => Promise<any>) => l()));
165
209
 
210
+ const enc = new TextEncoder();
211
+
166
212
  const stream = new ReadableStream<Uint8Array>({
167
213
  async start(controller) {
168
214
  // Chunk 1: head opening (CSS, modulepreload — cached)
169
215
  controller.enqueue(enc.encode(buildHtmlShellOpen()));
170
216
 
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)));
217
+ // Chunk 2: metadata tags, close </head>, open <body> + spinner
218
+ controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
175
219
 
220
+ try {
176
221
  // Pass metadata.data to load() so it can reuse fetched data
177
222
  const metadataData = metadata?.data ?? null;
178
223
 
@@ -203,6 +248,7 @@ export function renderSSRStream(
203
248
  controller.enqueue(enc.encode(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)));
204
249
  controller.close();
205
250
  } catch (err) {
251
+ // Head is closed and body is open at this point — HTML structure is valid
206
252
  if (err instanceof Redirect) {
207
253
  controller.enqueue(enc.encode(
208
254
  `<script>location.replace(${safeJsonStringify(err.location)})</script></body></html>`
@@ -212,7 +258,7 @@ export function renderSSRStream(
212
258
  }
213
259
  if (err instanceof HttpError) {
214
260
  controller.enqueue(enc.encode(
215
- `<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>`
216
262
  ));
217
263
  controller.close();
218
264
  return;
@@ -1,7 +1,7 @@
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
7
  import { apiRoutes, serverRoutes } from "bunia:routes";
@@ -94,6 +94,13 @@ 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
+
97
104
  /** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
98
105
  function parseActionName(url: URL): string {
99
106
  for (const key of url.searchParams.keys()) {
@@ -143,35 +150,48 @@ async function resolve(event: RequestEvent): Promise<Response> {
143
150
  if (isStaticPath(path)) {
144
151
  // dist/client: serve with cache headers based on whether filename is hashed
145
152
  if (path.startsWith("/dist/client/")) {
146
- const file = Bun.file(`.${path.split("?")[0]}`);
147
- if (await file.exists()) {
148
- const filename = path.split("/").pop() ?? "";
149
- const isHashed = /\-[a-z0-9]{8,}\.[a-z]+$/.test(filename);
150
- const cacheControl = !isDev && isHashed
151
- ? "public, max-age=31536000, immutable"
152
- : "no-cache";
153
- 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
+ }
154
164
  }
155
165
  return new Response("Not Found", { status: 404 });
156
166
  }
157
- const pub = Bun.file(`./public${path}`);
158
- if (await pub.exists()) return new Response(pub);
159
- const dist = Bun.file(`.${path}`);
160
- 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
+ }
161
177
  return new Response("Not Found", { status: 404 });
162
178
  }
163
179
 
164
180
  // Prerendered pages — serve static HTML built at build time
165
- const prerenderFile = Bun.file(
166
- path === "/" ? "./dist/prerendered/index.html" : `./dist/prerendered${path}/index.html`
181
+ const prerenderPath = safePath(
182
+ "./dist/prerendered",
183
+ path === "/" ? "index.html" : `${path}/index.html`,
167
184
  );
168
- if (await prerenderFile.exists()) {
169
- return new Response(prerenderFile, {
170
- headers: {
171
- "Content-Type": "text/html; charset=utf-8",
172
- "Cache-Control": "public, max-age=3600",
173
- },
174
- });
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
+ }
175
195
  }
176
196
 
177
197
  // API routes (+server.ts)
@@ -262,7 +282,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
262
282
  }
263
283
 
264
284
  // SSR pages (+page.svelte) — streaming by default
265
- const streamResponse = renderSSRStream(url, locals, request, cookies);
285
+ const streamResponse = await renderSSRStream(url, locals, request, cookies);
266
286
  if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
267
287
  return streamResponse;
268
288
  }
@@ -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