bosia 0.5.2 → 0.5.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 +1 -1
- package/src/core/apiResolver.ts +36 -0
- package/src/core/plugins/inspector/bun-plugin.ts +9 -2
- package/src/core/prerender.ts +58 -2
- package/src/core/server.ts +28 -9
- package/templates/default/.prettierrc.json +1 -1
- package/templates/demo/.prettierrc.json +1 -1
- package/templates/todo/.prettierrc.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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": [
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { findMatch } from "./matcher.ts";
|
|
2
|
+
import type { RouteMatch } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
interface ApiRouteLike {
|
|
5
|
+
pattern: string;
|
|
6
|
+
module: () => Promise<{ prerender?: unknown }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve an incoming request path to an API route, applying the `.json` alias.
|
|
11
|
+
*
|
|
12
|
+
* When the URL ends in `.json` the bare path is tried first. If the bare-path
|
|
13
|
+
* route opted into `prerender = true`, the alias wins — this prevents a
|
|
14
|
+
* catch-all sibling (e.g. `/api/components/[...path]`) from swallowing the
|
|
15
|
+
* `.json` suffix as part of its rest-segment param and returning a 4xx from
|
|
16
|
+
* the catch-all handler. Non-prerender bare-path matches fall through to the
|
|
17
|
+
* literal `.json` path so legitimate `<segment>.json` routes still resolve.
|
|
18
|
+
*/
|
|
19
|
+
export async function resolveApiMatch<T extends ApiRouteLike>(
|
|
20
|
+
routes: T[],
|
|
21
|
+
path: string,
|
|
22
|
+
): Promise<RouteMatch<T> | null> {
|
|
23
|
+
if (path.endsWith(".json")) {
|
|
24
|
+
const bare = path.slice(0, -".json".length);
|
|
25
|
+
const aliased = findMatch(routes, bare);
|
|
26
|
+
if (aliased) {
|
|
27
|
+
try {
|
|
28
|
+
const mod = await aliased.route.module();
|
|
29
|
+
if (mod.prerender === true) return aliased;
|
|
30
|
+
} catch {
|
|
31
|
+
/* fall through to literal-path match */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return findMatch(routes, path);
|
|
36
|
+
}
|
|
@@ -121,8 +121,15 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
let js = result.js.code;
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
// Skip empty CSS — multiple +page.svelte routes with no <style> would
|
|
125
|
+
// otherwise each emit an empty CSS chunk, all hashing to the same
|
|
126
|
+
// content → Bun fails with "Multiple files share the same output path".
|
|
127
|
+
// Also strip dots from basename: Bun's `[name]-[hash].[ext]` chunk
|
|
128
|
+
// naming treats the first `.` as the extension boundary, so
|
|
129
|
+
// `+page.svelte-…` collapses to `[name]="+page"` for every route.
|
|
130
|
+
if (result.css?.code?.trim() && generate !== "server") {
|
|
131
|
+
const safeBase = basename(args.path).replace(/\./g, "_");
|
|
132
|
+
const uid = `${safeBase}-${fnv(args.path)}-style.css`;
|
|
126
133
|
const virtualName = `${VIRTUAL_NS}:${uid}`;
|
|
127
134
|
virtualCss.set(virtualName, result.css.code);
|
|
128
135
|
js += `\nimport ${JSON.stringify(virtualName)};`;
|
package/src/core/prerender.ts
CHANGED
|
@@ -32,6 +32,8 @@ const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s
|
|
|
32
32
|
|
|
33
33
|
interface PrerenderTarget {
|
|
34
34
|
path: string;
|
|
35
|
+
kind: "page" | "api";
|
|
36
|
+
/** Page targets only; APIs always write a single `.json` file regardless of slash mode. */
|
|
35
37
|
trailingSlash: TrailingSlash;
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -86,6 +88,15 @@ export function prerenderDataPath(routePath: string): string {
|
|
|
86
88
|
return routePath === "/" ? "/index.json" : `${routePath.replace(/\/$/, "")}.json`;
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Output filename for a prerendered API route. Always emits a single `.json`
|
|
93
|
+
* file at the route's path (no trailing-slash variants — static hosts serve
|
|
94
|
+
* `/api/foo.json` regardless of the request URL's slash).
|
|
95
|
+
*/
|
|
96
|
+
export function prerenderApiOutPath(routePath: string): string {
|
|
97
|
+
return `./dist/prerendered${routePath.replace(/\/$/, "")}.json`;
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
|
|
90
101
|
const targets: PrerenderTarget[] = [];
|
|
91
102
|
for (const route of manifest.pages) {
|
|
@@ -116,6 +127,7 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
|
|
|
116
127
|
for (const entry of entryList) {
|
|
117
128
|
targets.push({
|
|
118
129
|
path: substituteParams(route.pattern, entry),
|
|
130
|
+
kind: "page",
|
|
119
131
|
trailingSlash: ts,
|
|
120
132
|
});
|
|
121
133
|
}
|
|
@@ -123,9 +135,40 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
|
|
|
123
135
|
console.error(` ❌ Failed to resolve entries() for ${route.pattern}:`, err);
|
|
124
136
|
}
|
|
125
137
|
} else {
|
|
126
|
-
targets.push({ path: route.pattern, trailingSlash: ts });
|
|
138
|
+
targets.push({ path: route.pattern, kind: "page", trailingSlash: ts });
|
|
127
139
|
}
|
|
128
140
|
}
|
|
141
|
+
|
|
142
|
+
for (const route of manifest.apis) {
|
|
143
|
+
const filePath = join("src", "routes", route.server);
|
|
144
|
+
const content = await Bun.file(filePath).text();
|
|
145
|
+
if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) continue;
|
|
146
|
+
|
|
147
|
+
if (route.pattern.includes("[")) {
|
|
148
|
+
try {
|
|
149
|
+
const mod = await import(join(process.cwd(), filePath));
|
|
150
|
+
if (typeof mod.entries !== "function") {
|
|
151
|
+
console.warn(
|
|
152
|
+
` ⚠️ ${route.pattern} has prerender=true but no entries() export — skipped`,
|
|
153
|
+
);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const entryList: Record<string, string>[] = await mod.entries();
|
|
157
|
+
for (const entry of entryList) {
|
|
158
|
+
targets.push({
|
|
159
|
+
path: substituteParams(route.pattern, entry),
|
|
160
|
+
kind: "api",
|
|
161
|
+
trailingSlash: "never",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error(` ❌ Failed to resolve entries() for ${route.pattern}:`, err);
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
targets.push({ path: route.pattern, kind: "api", trailingSlash: "never" });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
129
172
|
return targets;
|
|
130
173
|
}
|
|
131
174
|
|
|
@@ -178,8 +221,21 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
178
221
|
|
|
179
222
|
mkdirSync("./dist/prerendered", { recursive: true });
|
|
180
223
|
|
|
181
|
-
for (const { path: routePath, trailingSlash: ts } of targets) {
|
|
224
|
+
for (const { path: routePath, kind, trailingSlash: ts } of targets) {
|
|
182
225
|
try {
|
|
226
|
+
if (kind === "api") {
|
|
227
|
+
// APIs: fetch the bare route URL, write body to `<path>.json`.
|
|
228
|
+
const res = await fetch(`${base}${routePath.replace(/\/$/, "")}`, {
|
|
229
|
+
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
230
|
+
});
|
|
231
|
+
const body = await res.text();
|
|
232
|
+
const outPath = prerenderApiOutPath(routePath);
|
|
233
|
+
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
234
|
+
writeFileSync(outPath, body);
|
|
235
|
+
console.log(` ✅ ${routePath} → ${outPath}`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
183
239
|
// Hit the canonical URL so the server doesn't 308 us mid-prerender
|
|
184
240
|
const canonicalRoute = canonicalRouteFor(routePath, ts);
|
|
185
241
|
|
package/src/core/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "fs";
|
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
|
|
6
6
|
import { findMatch, compileRoutes, canonicalPathname } from "./matcher.ts";
|
|
7
|
+
import { resolveApiMatch } from "./apiResolver.ts";
|
|
7
8
|
import { apiRoutes, serverRoutes } from "bosia:routes";
|
|
8
9
|
import { loadPlugins } from "./config.ts";
|
|
9
10
|
import type { RouteManifest } from "./types.ts";
|
|
@@ -338,8 +339,8 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
338
339
|
}
|
|
339
340
|
}
|
|
340
341
|
|
|
341
|
-
// API routes (+server.ts)
|
|
342
|
-
const apiMatch =
|
|
342
|
+
// API routes (+server.ts) — resolve with `.json` alias preference.
|
|
343
|
+
const apiMatch = await resolveApiMatch(apiRoutes, path);
|
|
343
344
|
if (apiMatch) {
|
|
344
345
|
try {
|
|
345
346
|
const mod = await apiMatch.route.module();
|
|
@@ -690,6 +691,24 @@ if (BODY_SIZE_LIMIT === 0) {
|
|
|
690
691
|
console.log(`📦 Body size limit: ${BODY_SIZE_LIMIT} bytes`);
|
|
691
692
|
}
|
|
692
693
|
|
|
694
|
+
// ─── Idle Timeout ─────────────────────────────────────────
|
|
695
|
+
// Parsed once at startup from IDLE_TIMEOUT env var.
|
|
696
|
+
// Integer seconds; Bun caps it at 255. Default: 10 (Bun's default).
|
|
697
|
+
// Raise when API routes hold streaming responses with long gaps
|
|
698
|
+
// between chunks (e.g. AI tool calls that shell out and wait).
|
|
699
|
+
|
|
700
|
+
function parseIdleTimeout(value?: string): number {
|
|
701
|
+
if (!value) return 10;
|
|
702
|
+
const n = parseInt(value, 10);
|
|
703
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid IDLE_TIMEOUT: "${value}"`);
|
|
704
|
+
if (n > 255) throw new Error(`Invalid IDLE_TIMEOUT: "${value}" (max 255)`);
|
|
705
|
+
return n;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const IDLE_TIMEOUT = parseIdleTimeout(process.env.IDLE_TIMEOUT);
|
|
709
|
+
|
|
710
|
+
console.log(`⏱ Idle timeout: ${IDLE_TIMEOUT}s`);
|
|
711
|
+
|
|
693
712
|
// ─── Graceful Shutdown State ──────────────────────────────
|
|
694
713
|
|
|
695
714
|
let shuttingDown = false;
|
|
@@ -736,13 +755,13 @@ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 :
|
|
|
736
755
|
|
|
737
756
|
// Elysia's chained generics drift when plugins add routes — track the app as a
|
|
738
757
|
// loose `Elysia` so plugin-extended types stay assignable.
|
|
739
|
-
let app: Elysia = new Elysia({
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
},
|
|
745
|
-
) as unknown as Elysia;
|
|
758
|
+
let app: Elysia = new Elysia({
|
|
759
|
+
serve: { maxRequestBodySize: BODY_SIZE_LIMIT, idleTimeout: IDLE_TIMEOUT },
|
|
760
|
+
}).onError(({ error }) => {
|
|
761
|
+
if (isDev) console.error("Uncaught server error:", error);
|
|
762
|
+
else console.error("Uncaught server error:", (error as Error)?.message ?? error);
|
|
763
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
764
|
+
}) as unknown as Elysia;
|
|
746
765
|
|
|
747
766
|
// Plugins.backend.before — runs before framework middleware/routes.
|
|
748
767
|
// Plugin-registered routes here BYPASS the framework (CSRF, hooks, etc.).
|