bosia 0.2.0 → 0.2.2

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": "bosia",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
@@ -2,9 +2,12 @@ import { hydrate } from "svelte";
2
2
  import App from "./App.svelte";
3
3
  import { router } from "./router.svelte.ts";
4
4
  import { initPrefetch } from "./prefetch.ts";
5
- import { findMatch } from "../matcher.ts";
5
+ import { findMatch, compileRoutes } from "../matcher.ts";
6
6
  import { clientRoutes } from "bosia:routes";
7
7
 
8
+ // Pre-compile route patterns into RegExp at startup (shared by App.svelte and router via module reference)
9
+ compileRoutes(clientRoutes);
10
+
8
11
  // ─── Hydration ────────────────────────────────────────────
9
12
 
10
13
  async function main() {
@@ -9,29 +9,37 @@ export function dataUrl(path: string): string {
9
9
  return `/__bosia/data${p || "/index"}.json${url.search}`;
10
10
  }
11
11
 
12
- export const prefetchCache = new Map<string, any>();
12
+ export const prefetchCache = new Map<string, { data: any; ts: number }>();
13
+ const MAX_PREFETCH_ENTRIES = 50;
13
14
 
14
15
  // In-flight fetch deduplication
15
16
  const pending = new Set<string>();
16
17
 
17
18
  /** Returns cached prefetch data for a path and removes it from cache. */
18
19
  export function consumePrefetch(path: string): any | null {
19
- const data = prefetchCache.get(path);
20
- if (data === undefined) return null;
20
+ const entry = prefetchCache.get(path);
21
+ if (entry === undefined) return null;
21
22
  prefetchCache.delete(path);
22
- return data;
23
+ if (Date.now() - entry.ts > 30_000) return null;
24
+ return entry.data;
23
25
  }
24
26
 
25
27
  /** Prefetches data for a path and stores in cache. No-op if already cached/in-flight. */
26
28
  export async function prefetchPath(path: string): Promise<void> {
27
- if (prefetchCache.has(path)) return;
29
+ const existing = prefetchCache.get(path);
30
+ if (existing && Date.now() - existing.ts <= 30_000) return;
31
+ if (existing) prefetchCache.delete(path);
28
32
  if (pending.has(path)) return;
29
33
 
30
34
  pending.add(path);
31
35
  try {
32
36
  const res = await fetch(dataUrl(path));
33
37
  if (res.ok) {
34
- prefetchCache.set(path, await res.json());
38
+ if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
39
+ const oldest = prefetchCache.keys().next().value;
40
+ if (oldest !== undefined) prefetchCache.delete(oldest);
41
+ }
42
+ prefetchCache.set(path, { data: await res.json(), ts: Date.now() });
35
43
  }
36
44
  } catch {
37
45
  // Silently ignore — prefetch is best-effort
@@ -31,11 +31,17 @@ export const router = new class Router {
31
31
 
32
32
  // Intercept <a> clicks for client-side navigation
33
33
  window.addEventListener("click", (e) => {
34
+ // Let browser handle non-primary buttons, modifier-clicks, already-handled events
35
+ if (e.button !== 0) return;
36
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
37
+ if (e.defaultPrevented) return;
38
+
34
39
  const anchor = (e.target as HTMLElement).closest("a");
35
40
  if (!anchor) return;
36
41
  if (anchor.origin !== window.location.origin) return;
37
42
  if (anchor.target) return;
38
43
  if (anchor.hasAttribute("download")) return;
44
+ if (anchor.rel.split(/\s+/).includes("external")) return;
39
45
  if (anchor.protocol !== "https:" && anchor.protocol !== "http:") return;
40
46
 
41
47
  e.preventDefault();
package/src/core/env.ts CHANGED
@@ -8,6 +8,7 @@ const FRAMEWORK_VARS = new Set([
8
8
  "NODE_ENV",
9
9
  "BODY_SIZE_LIMIT",
10
10
  "CSRF_ALLOWED_ORIGINS",
11
+ "INTERNAL_HOSTS",
11
12
  "CORS_ALLOWED_ORIGINS",
12
13
  "CORS_ALLOWED_METHODS",
13
14
  "CORS_ALLOWED_HEADERS",
@@ -9,6 +9,78 @@ import type { RouteMatch } from "./types.ts";
9
9
  // 2. Dynamic match — "/blog/[slug]" matches "/blog/hello"
10
10
  // 3. Catch-all match — "/[...rest]" matches anything
11
11
 
12
+ // ─── Compiled Route Types ────────────────────────────────
13
+
14
+ interface CompiledRoute {
15
+ regex: RegExp;
16
+ paramNames: string[];
17
+ isExact: boolean;
18
+ }
19
+
20
+ // ─── Pattern Compiler ────────────────────────────────────
21
+
22
+ /** Escape regex special chars in a literal string segment. */
23
+ function escapeRegex(s: string): string {
24
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ }
26
+
27
+ /**
28
+ * Pre-compile a route pattern into a RegExp for fast matching.
29
+ */
30
+ function compilePattern(pattern: string): CompiledRoute {
31
+ // No dynamic segments — exact match via ===
32
+ if (!pattern.includes("[")) {
33
+ return { regex: null!, paramNames: [], isExact: true };
34
+ }
35
+
36
+ const paramNames: string[] = [];
37
+
38
+ // Catch-all: /prefix/[...name]
39
+ const catchallMatch = pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
40
+ if (catchallMatch) {
41
+ const prefix = catchallMatch[1] || "";
42
+ paramNames.push(catchallMatch[2]!);
43
+ const escaped = prefix ? escapeRegex(prefix) : "";
44
+ // Root catch-all /[...rest] must have at least one char after /
45
+ const regex = prefix
46
+ ? new RegExp(`^${escaped}\\/(.+)$`)
47
+ : new RegExp(`^\\/(.+)$`);
48
+ return { regex, paramNames, isExact: false };
49
+ }
50
+
51
+ // Dynamic segments: /blog/[slug]/comments → ^\/blog\/([^/]+)\/comments$
52
+ const segments = pattern.split("/").filter(Boolean);
53
+ let regexStr = "^";
54
+ for (const seg of segments) {
55
+ regexStr += "\\/";
56
+ if (seg.startsWith("[") && seg.endsWith("]")) {
57
+ paramNames.push(seg.slice(1, -1));
58
+ regexStr += "([^/]+)";
59
+ } else {
60
+ regexStr += escapeRegex(seg);
61
+ }
62
+ }
63
+ regexStr += "$";
64
+
65
+ return { regex: new RegExp(regexStr), paramNames, isExact: false };
66
+ }
67
+
68
+ /**
69
+ * Pre-compile all route patterns in-place.
70
+ * Mutates each route by adding a `_compiled` property.
71
+ * Call once at startup — all modules sharing the same route array see the result.
72
+ */
73
+ export function compileRoutes<T extends { pattern: string }>(
74
+ routes: T[],
75
+ ): (T & { _compiled: CompiledRoute })[] {
76
+ for (const route of routes) {
77
+ (route as any)._compiled = compilePattern(route.pattern);
78
+ }
79
+ return routes as (T & { _compiled: CompiledRoute })[];
80
+ }
81
+
82
+ // ─── Legacy Pattern Matcher (fallback for uncompiled routes) ─
83
+
12
84
  /**
13
85
  * Match a URL pathname against a single route pattern.
14
86
  * Returns extracted params if matched, null otherwise.
@@ -60,6 +132,31 @@ function matchPattern(
60
132
  return params;
61
133
  }
62
134
 
135
+ // ─── Route Matching ──────────────────────────────────────
136
+
137
+ /**
138
+ * Match a compiled route against a pathname using regex.
139
+ * Returns extracted params if matched, null otherwise.
140
+ */
141
+ function matchCompiled(
142
+ compiled: CompiledRoute,
143
+ pattern: string,
144
+ pathname: string,
145
+ ): Record<string, string> | null {
146
+ if (compiled.isExact) {
147
+ return pattern === pathname ? {} : null;
148
+ }
149
+
150
+ const m = compiled.regex.exec(pathname);
151
+ if (!m) return null;
152
+
153
+ const params: Record<string, string> = {};
154
+ for (let i = 0; i < compiled.paramNames.length; i++) {
155
+ params[compiled.paramNames[i]!] = m[i + 1]!;
156
+ }
157
+ return params;
158
+ }
159
+
63
160
  /**
64
161
  * Find the first matching route from a list.
65
162
  * Routes must be pre-sorted by priority (exact → dynamic → catch-all).
@@ -75,7 +172,10 @@ export function findMatch<T extends { pattern: string }>(
75
172
  }
76
173
 
77
174
  for (const route of routes) {
78
- const params = matchPattern(route.pattern, pathname);
175
+ const compiled = (route as any)._compiled as CompiledRoute | undefined;
176
+ const params = compiled
177
+ ? matchCompiled(compiled, route.pattern, pathname)
178
+ : matchPattern(route.pattern, pathname);
79
179
  if (params !== null) return { route, params };
80
180
  }
81
181
 
@@ -37,22 +37,59 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
37
37
  ]);
38
38
  }
39
39
 
40
+ // ─── Internal-Host Allowlist ─────────────────────────────
41
+ // Origins that share the user's session cookie. Cookie is auto-forwarded
42
+ // to same-origin requests AND to any origin in this set. Anything else
43
+ // (third-party APIs) gets no Cookie header by default.
44
+
45
+ const INTERNAL_HOSTS: Set<string> = (() => {
46
+ const raw = process.env.INTERNAL_HOSTS?.split(",").map(s => s.trim()).filter(Boolean) ?? [];
47
+ const valid = new Set<string>();
48
+ for (const entry of raw) {
49
+ try {
50
+ valid.add(new URL(entry).origin);
51
+ } catch {
52
+ console.warn(`⚠️ INTERNAL_HOSTS: ignoring invalid origin "${entry}"`);
53
+ }
54
+ }
55
+ return valid;
56
+ })();
57
+
58
+ if (INTERNAL_HOSTS.size > 0) {
59
+ console.log(`🍪 Internal hosts (cookies forwarded): ${[...INTERNAL_HOSTS].join(", ")}`);
60
+ }
61
+
40
62
  // ─── Session-Aware Fetch ─────────────────────────────────
41
- // Passed to load() functions so they can call internal APIs
42
- // with the current user's cookies automatically forwarded.
63
+ // Passed to load() functions so they can call internal APIs with the
64
+ // current user's cookies automatically forwarded. Cookie is attached
65
+ // only on same-origin requests or to origins in INTERNAL_HOSTS — never
66
+ // to arbitrary third-party hosts (which would leak the session token).
43
67
 
44
68
  function makeFetch(req: Request, url: URL) {
45
69
  const cookie = req.headers.get("cookie") ?? "";
46
- const origin = url.origin;
70
+ const sameOrigin = url.origin;
47
71
 
48
72
  return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
49
- const resolved =
50
- typeof input === "string" && input.startsWith("/")
51
- ? `${origin}${input}`
52
- : input;
73
+ let targetOrigin: string | null = null;
74
+ let resolved: RequestInfo | URL = input;
75
+
76
+ try {
77
+ if (typeof input === "string") {
78
+ const parsed = new URL(input, sameOrigin);
79
+ targetOrigin = parsed.origin;
80
+ resolved = parsed.href;
81
+ } else if (input instanceof URL) {
82
+ targetOrigin = input.origin;
83
+ } else {
84
+ targetOrigin = new URL(input.url).origin;
85
+ }
86
+ } catch {
87
+ targetOrigin = null;
88
+ }
53
89
 
54
90
  const headers = new Headers(init?.headers);
55
- if (cookie && !headers.has("cookie")) headers.set("cookie", cookie);
91
+ const trusted = targetOrigin !== null && (targetOrigin === sameOrigin || INTERNAL_HOSTS.has(targetOrigin));
92
+ if (cookie && trusted && !headers.has("cookie")) headers.set("cookie", cookie);
56
93
 
57
94
  return globalThis.fetch(resolved, { ...init, headers });
58
95
  };
@@ -3,8 +3,12 @@ import { Elysia } from "elysia";
3
3
  import { existsSync } from "fs";
4
4
  import { join, resolve as resolvePath } from "path";
5
5
 
6
- import { findMatch } from "./matcher.ts";
6
+ import { findMatch, compileRoutes } from "./matcher.ts";
7
7
  import { apiRoutes, serverRoutes } from "bosia:routes";
8
+
9
+ // Pre-compile route patterns into RegExp at startup (shared by renderer.ts via module reference)
10
+ compileRoutes(apiRoutes);
11
+ compileRoutes(serverRoutes);
8
12
  import type { Handle, RequestEvent } from "./hooks.ts";
9
13
  import { HttpError, Redirect, ActionFailure } from "./errors.ts";
10
14
  import { CookieJar } from "./cookies.ts";