bosia 0.1.26 → 0.2.1
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/build.ts +38 -31
- package/src/core/client/hydrate.ts +4 -1
- package/src/core/dedup.ts +32 -0
- package/src/core/hooks.ts +6 -4
- package/src/core/matcher.ts +101 -1
- package/src/core/server.ts +26 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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": [
|
package/src/core/build.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { SveltePlugin } from "bun-plugin-svelte";
|
|
2
2
|
import { writeFileSync, rmSync, mkdirSync } from "fs";
|
|
3
3
|
import { join, relative } from "path";
|
|
4
|
-
import { spawnSync } from "bun";
|
|
5
4
|
|
|
6
5
|
import { scanRoutes } from "./scanner.ts";
|
|
7
6
|
import { generateRoutesFile } from "./routeFile.ts";
|
|
@@ -19,6 +18,7 @@ const CORE_DIR = import.meta.dir;
|
|
|
19
18
|
|
|
20
19
|
const isProduction = process.env.NODE_ENV === "production";
|
|
21
20
|
|
|
21
|
+
const buildStart = performance.now();
|
|
22
22
|
console.log("🏗️ Starting Bosia build...\n");
|
|
23
23
|
|
|
24
24
|
// 0. Load .env files (before cleaning .bosia so loadEnv can set process.env early)
|
|
@@ -55,21 +55,17 @@ ensureRootDirs();
|
|
|
55
55
|
// 2d. Generate .bosia/env.server.ts, .bosia/env.client.ts, .bosia/types/env.d.ts
|
|
56
56
|
generateEnvModules(classifiedEnv);
|
|
57
57
|
|
|
58
|
-
// 3.
|
|
59
|
-
console.log("\n🎨 Building Tailwind CSS...");
|
|
58
|
+
// 3. Start Tailwind CSS (async — runs concurrently with client+server builds)
|
|
60
59
|
const tailwindBin = resolveBosiaBin("tailwindcss");
|
|
61
|
-
const
|
|
60
|
+
const tailwindProc = Bun.spawn(
|
|
62
61
|
[tailwindBin, "-i", "./src/app.css", "-o", "./public/bosia-tw.css", ...(isProduction ? ["--minify"] : [])],
|
|
63
62
|
{
|
|
64
63
|
cwd: process.cwd(),
|
|
65
64
|
env: { ...process.env, NODE_PATH: BOSIA_NODE_PATH },
|
|
65
|
+
stderr: "pipe",
|
|
66
66
|
},
|
|
67
67
|
);
|
|
68
|
-
|
|
69
|
-
console.error("❌ Tailwind CSS build failed:\n" + tailwindResult.stderr.toString());
|
|
70
|
-
process.exit(1);
|
|
71
|
-
}
|
|
72
|
-
console.log("✅ Tailwind CSS built: public/bosia-tw.css");
|
|
68
|
+
const tailwindPromise = tailwindProc.exited;
|
|
73
69
|
|
|
74
70
|
// Separate plugin instances per build target ($env resolves differently)
|
|
75
71
|
const clientPlugin = makeBosiaPlugin("browser");
|
|
@@ -84,9 +80,9 @@ for (const [key, value] of Object.entries(classifiedEnv.privateStatic)) {
|
|
|
84
80
|
staticDefines[`import.meta.env.${key}`] = JSON.stringify(value);
|
|
85
81
|
}
|
|
86
82
|
|
|
87
|
-
// 5. Build client
|
|
88
|
-
console.log("\n📦 Building client
|
|
89
|
-
const
|
|
83
|
+
// 5. Build Tailwind + client + server bundles in parallel
|
|
84
|
+
console.log("\n📦 Building Tailwind + client + server...");
|
|
85
|
+
const clientPromise = Bun.build({
|
|
90
86
|
entrypoints: [join(CORE_DIR, "client", "hydrate.ts")],
|
|
91
87
|
outdir: "./dist/client",
|
|
92
88
|
target: "browser",
|
|
@@ -100,24 +96,7 @@ const clientResult = await Bun.build({
|
|
|
100
96
|
plugins: [clientPlugin, SveltePlugin()],
|
|
101
97
|
});
|
|
102
98
|
|
|
103
|
-
|
|
104
|
-
console.error("❌ Client build failed:");
|
|
105
|
-
for (const msg of clientResult.logs) console.error(msg);
|
|
106
|
-
process.exit(1);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// 6. Collect output files for dist/manifest.json
|
|
110
|
-
const jsFiles: string[] = [];
|
|
111
|
-
const cssFiles: string[] = [];
|
|
112
|
-
for (const output of clientResult.outputs) {
|
|
113
|
-
const rel = relative("./dist/client", output.path);
|
|
114
|
-
if (output.path.endsWith(".js")) jsFiles.push(rel);
|
|
115
|
-
if (output.path.endsWith(".css")) cssFiles.push(rel);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// 7. Build server bundle (before writing manifest so we can record the entry)
|
|
119
|
-
console.log("\n📦 Building server bundle...");
|
|
120
|
-
const serverResult = await Bun.build({
|
|
99
|
+
const serverPromise = Bun.build({
|
|
121
100
|
entrypoints: [join(CORE_DIR, "server.ts")],
|
|
122
101
|
outdir: "./dist/server",
|
|
123
102
|
target: "bun",
|
|
@@ -128,12 +107,40 @@ const serverResult = await Bun.build({
|
|
|
128
107
|
plugins: [serverPlugin, SveltePlugin()],
|
|
129
108
|
});
|
|
130
109
|
|
|
110
|
+
const [tailwindExitCode, clientResult, serverResult] = await Promise.all([
|
|
111
|
+
tailwindPromise,
|
|
112
|
+
clientPromise,
|
|
113
|
+
serverPromise,
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
if (tailwindExitCode !== 0) {
|
|
117
|
+
const stderr = await new Response(tailwindProc.stderr).text();
|
|
118
|
+
console.error("❌ Tailwind CSS build failed:\n" + stderr);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
console.log("✅ Tailwind CSS built: public/bosia-tw.css");
|
|
122
|
+
|
|
123
|
+
if (!clientResult.success) {
|
|
124
|
+
console.error("❌ Client build failed:");
|
|
125
|
+
for (const msg of clientResult.logs) console.error(msg);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
131
129
|
if (!serverResult.success) {
|
|
132
130
|
console.error("❌ Server build failed:");
|
|
133
131
|
for (const msg of serverResult.logs) console.error(msg);
|
|
134
132
|
process.exit(1);
|
|
135
133
|
}
|
|
136
134
|
|
|
135
|
+
// 6. Collect output files for dist/manifest.json
|
|
136
|
+
const jsFiles: string[] = [];
|
|
137
|
+
const cssFiles: string[] = [];
|
|
138
|
+
for (const output of clientResult.outputs) {
|
|
139
|
+
const rel = relative("./dist/client", output.path);
|
|
140
|
+
if (output.path.endsWith(".js")) jsFiles.push(rel);
|
|
141
|
+
if (output.path.endsWith(".css")) cssFiles.push(rel);
|
|
142
|
+
}
|
|
143
|
+
|
|
137
144
|
// Entry is always "index.js" due to naming: { entry: "index.[ext]" }
|
|
138
145
|
const serverEntry = serverResult.outputs
|
|
139
146
|
.find(o => o.path.endsWith("index.js"))
|
|
@@ -157,4 +164,4 @@ await prerenderStaticRoutes(manifest);
|
|
|
157
164
|
// 10. Generate static site output (HTML + client assets + public → dist/static/)
|
|
158
165
|
generateStaticSite();
|
|
159
166
|
|
|
160
|
-
console.log(
|
|
167
|
+
console.log(`\n🎉 Build complete in ${Math.round(performance.now() - buildStart)}ms!`);
|
|
@@ -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() {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// packages/bosia/src/core/dedup.ts
|
|
2
|
+
// Request deduplication for concurrent identical GET requests to /__bosia/data/
|
|
3
|
+
|
|
4
|
+
const inflight = new Map<string, Promise<any>>();
|
|
5
|
+
|
|
6
|
+
const AUTH_COOKIE_RE = /(?:^|;\s*)authorization=([^;]*)/i;
|
|
7
|
+
|
|
8
|
+
/** Build dedup key from route URL + request identity. Sort search params for consistency. */
|
|
9
|
+
export function dedupKey(url: URL, request: Request): string {
|
|
10
|
+
let path = url.pathname;
|
|
11
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
12
|
+
const sorted = new URLSearchParams([...url.searchParams.entries()].sort());
|
|
13
|
+
const search = sorted.toString();
|
|
14
|
+
const base = search ? `${path}?${search}` : path;
|
|
15
|
+
|
|
16
|
+
const authHeader = request.headers.get("authorization") ?? "";
|
|
17
|
+
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
18
|
+
const match = cookieHeader.match(AUTH_COOKIE_RE);
|
|
19
|
+
const authCookie = match?.[1] ?? "";
|
|
20
|
+
const identity = authHeader || authCookie;
|
|
21
|
+
if (!identity) return base;
|
|
22
|
+
return `${base}|${Bun.hash(identity).toString(36)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Run `fn` with dedup. Concurrent calls with same key share the in-flight promise. */
|
|
26
|
+
export function dedup<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
27
|
+
const existing = inflight.get(key);
|
|
28
|
+
if (existing) return existing;
|
|
29
|
+
const promise = fn().finally(() => inflight.delete(key));
|
|
30
|
+
inflight.set(key, promise);
|
|
31
|
+
return promise;
|
|
32
|
+
}
|
package/src/core/hooks.ts
CHANGED
|
@@ -85,10 +85,12 @@ type MaybePromise<T> = T | Promise<T>;
|
|
|
85
85
|
*/
|
|
86
86
|
export function sequence(...handlers: Handle[]): Handle {
|
|
87
87
|
return ({ event, resolve }) => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
let next = resolve;
|
|
89
|
+
for (let i = handlers.length - 1; i >= 0; i--) {
|
|
90
|
+
const handler = handlers[i]!;
|
|
91
|
+
const prev = next;
|
|
92
|
+
next = (e: RequestEvent) => handler({ event: e, resolve: prev });
|
|
91
93
|
}
|
|
92
|
-
return
|
|
94
|
+
return next(event);
|
|
93
95
|
};
|
|
94
96
|
}
|
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/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";
|
|
@@ -15,6 +19,7 @@ import type { CorsConfig } from "./cors.ts";
|
|
|
15
19
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
16
20
|
import { loadRouteData, loadMetadata, renderSSRStream, renderErrorPage, renderPageWithFormData } from "./renderer.ts";
|
|
17
21
|
import { getServerTime } from "../lib/utils.ts";
|
|
22
|
+
import { dedup, dedupKey } from "./dedup.ts";
|
|
18
23
|
|
|
19
24
|
// ─── User Hooks ──────────────────────────────────────────
|
|
20
25
|
// Load src/hooks.server.ts if present. Uses process.cwd() so
|
|
@@ -128,29 +133,30 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
128
133
|
}
|
|
129
134
|
// Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
|
|
130
135
|
event.url = routeUrl;
|
|
136
|
+
const dedupKeyStr = dedupKey(routeUrl, request);
|
|
131
137
|
try {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const cc = (cookies as CookieJar).accessed ? "private, no-cache" : "public, max-age=0, must-revalidate";
|
|
136
|
-
return compress(JSON.stringify({ pageData: {}, layoutData: [] }), "application/json", request, 200, { "Cache-Control": cc });
|
|
137
|
-
}
|
|
138
|
+
const result = await dedup(dedupKeyStr, async () => {
|
|
139
|
+
const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
|
|
140
|
+
const data = await loadRouteData(routeUrl, locals, request, cookies);
|
|
138
141
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
142
|
+
let metadata = null;
|
|
143
|
+
if (pageMatch) {
|
|
144
|
+
try {
|
|
145
|
+
const meta = await loadMetadata(pageMatch.route, pageMatch.params, routeUrl, locals, cookies, request);
|
|
146
|
+
if (meta) metadata = { title: meta.title, description: meta.description };
|
|
147
|
+
} catch { /* non-fatal */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
151
|
+
});
|
|
147
152
|
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
: "public, max-age=0, must-revalidate";
|
|
151
|
-
const cacheHeaders = { "Cache-Control": cacheControl };
|
|
153
|
+
const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
|
|
154
|
+
const cc = cookiesWereAccessed ? "private, no-cache" : "public, max-age=0, must-revalidate";
|
|
152
155
|
|
|
153
|
-
|
|
156
|
+
if (!result.data) {
|
|
157
|
+
return compress(JSON.stringify({ pageData: {}, layoutData: [] }), "application/json", request, 200, { "Cache-Control": cc });
|
|
158
|
+
}
|
|
159
|
+
return compress(JSON.stringify({ ...result.data, metadata: result.metadata }), "application/json", request, 200, { "Cache-Control": cc });
|
|
154
160
|
} catch (err) {
|
|
155
161
|
if (err instanceof Redirect) {
|
|
156
162
|
return compress(JSON.stringify({ redirect: err.location, status: err.status }), "application/json", request);
|