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 +1 -1
- package/src/core/client/hydrate.ts +4 -1
- package/src/core/client/prefetch.ts +14 -6
- package/src/core/client/router.svelte.ts +6 -0
- package/src/core/env.ts +1 -0
- package/src/core/matcher.ts +101 -1
- package/src/core/renderer.ts +45 -8
- package/src/core/server.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.2.
|
|
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
|
|
20
|
-
if (
|
|
20
|
+
const entry = prefetchCache.get(path);
|
|
21
|
+
if (entry === undefined) return null;
|
|
21
22
|
prefetchCache.delete(path);
|
|
22
|
-
return
|
|
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
|
-
|
|
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.
|
|
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
package/src/core/matcher.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
70
|
+
const sameOrigin = url.origin;
|
|
47
71
|
|
|
48
72
|
return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/core/server.ts
CHANGED
|
@@ -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";
|