bosia 0.5.1 → 0.5.3

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.5.1",
3
+ "version": "0.5.3",
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/cli/add.ts CHANGED
@@ -47,10 +47,10 @@ export async function initAddRegistry(root: string | null) {
47
47
  registryIndex = await loadIndex();
48
48
  }
49
49
 
50
- export async function runAdd(name: string | undefined, flags: string[] = []) {
51
- if (!name) {
50
+ export async function runAdd(names: string[], flags: string[] = []) {
51
+ if (names.length === 0) {
52
52
  console.error(
53
- "❌ Please provide a component name.\n Usage: bun x bosia@latest add <component> [--local]",
53
+ "❌ Please provide a component name.\n Usage: bun x bosia@latest add <component...> [-y] [--local]",
54
54
  );
55
55
  process.exit(1);
56
56
  }
@@ -60,11 +60,15 @@ export async function runAdd(name: string | undefined, flags: string[] = []) {
60
60
  console.log(`⬡ Using local registry: ${registryRoot}\n`);
61
61
  }
62
62
 
63
+ const skipPrompts = flags.includes("-y") || flags.includes("--yes");
64
+
63
65
  // Load index once to resolve component paths
64
66
  registryIndex = await loadIndex();
65
67
 
66
68
  ensureUtils();
67
- await addComponent(name, true);
69
+ for (const name of names) {
70
+ await addComponent(name, true, { skipPrompts });
71
+ }
68
72
  }
69
73
 
70
74
  /**
package/src/cli/index.ts CHANGED
@@ -37,18 +37,20 @@ async function main() {
37
37
  break;
38
38
  }
39
39
  case "add": {
40
- const positional = args.filter((a) => !a.startsWith("--"));
41
- const flags = args.filter((a) => a.startsWith("--"));
40
+ const positional = args.filter((a) => !a.startsWith("-"));
41
+ const flags = args.filter((a) => a.startsWith("-"));
42
42
  const sub = positional[0];
43
43
  if (sub === "block") {
44
+ const blockFlags = args.filter((a) => a.startsWith("--"));
44
45
  const { runAddBlock } = await import("./block.ts");
45
- await runAddBlock(positional[1], flags);
46
+ await runAddBlock(positional[1], blockFlags);
46
47
  } else if (sub === "theme") {
48
+ const themeFlags = args.filter((a) => a.startsWith("--"));
47
49
  const { runAddTheme } = await import("./theme.ts");
48
- await runAddTheme(positional[1], flags);
50
+ await runAddTheme(positional[1], themeFlags);
49
51
  } else {
50
52
  const { runAdd } = await import("./add.ts");
51
- await runAdd(sub, flags);
53
+ await runAdd(positional, flags);
52
54
  }
53
55
  break;
54
56
  }
@@ -72,7 +74,7 @@ Commands:
72
74
  build Build for production
73
75
  start Run the production server
74
76
  test [args] Run tests with bun test (auto-loads .env.test, sets BOSIA_ENV=test)
75
- add <component> Add a UI component from the registry
77
+ add <component...> [-y] Add one or more UI components from the registry
76
78
  add block <cat>/<name> Add a composed block from the registry
77
79
  add theme <name> Add a theme (tokens.css) from the registry
78
80
  feat <feature> Add a feature scaffold from the registry [--local]
@@ -87,6 +89,8 @@ Examples:
87
89
  bun x bosia test --watch
88
90
  bun x bosia test --coverage
89
91
  bun x bosia@latest add button → src/lib/components/ui/button/
92
+ bun x bosia@latest add button card input → install multiple at once
93
+ bun x bosia@latest add -y button card → auto-confirm overwrites (CI / scripts)
90
94
  bun x bosia@latest add shop/cart → src/lib/components/shop/cart/
91
95
  bun x bosia@latest add block cards/feature-editorial
92
96
  bun x bosia@latest add theme editorial
@@ -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
+ }
package/src/core/dev.ts CHANGED
@@ -212,29 +212,43 @@ const devServer = Bun.serve({
212
212
  // the app's CSRF origin check (gated behind TRUST_PROXY=true, also set in the
213
213
  // app env above) reconstructs the public-facing origin from the dev proxy
214
214
  // rather than the inner-app's host (localhost:APP_PORT).
215
- try {
216
- const reqUrl = new URL(req.url);
217
- const target = new URL(req.url);
218
- target.hostname = "localhost";
219
- target.port = String(APP_PORT);
220
-
221
- const forwardedHeaders = new Headers(req.headers);
222
- forwardedHeaders.set("x-forwarded-host", reqUrl.host);
223
- forwardedHeaders.set("x-forwarded-proto", reqUrl.protocol.replace(":", ""));
224
-
225
- return await fetch(
226
- new Request(target.toString(), {
227
- method: req.method,
228
- headers: forwardedHeaders,
229
- body: req.body,
230
- redirect: "manual",
231
- }),
232
- );
233
- } catch {
234
- return new Response("App server is starting...", {
235
- status: 503,
236
- headers: { "Content-Type": "text/plain", "Retry-After": "1" },
237
- });
215
+ const reqUrl = new URL(req.url);
216
+ const target = new URL(req.url);
217
+ target.hostname = "localhost";
218
+ target.port = String(APP_PORT);
219
+
220
+ const forwardedHeaders = new Headers(req.headers);
221
+ forwardedHeaders.set("x-forwarded-host", reqUrl.host);
222
+ forwardedHeaders.set("x-forwarded-proto", reqUrl.protocol.replace(":", ""));
223
+
224
+ // HMR-driven reloads can land on the proxy before the freshly-respawned
225
+ // inner has bound APP_PORT. Retry for a few seconds on idempotent HTML
226
+ // navigations so the browser doesn't get stuck rendering the 503 body.
227
+ const accept = req.headers.get("accept") ?? "";
228
+ const retryable =
229
+ (req.method === "GET" || req.method === "HEAD") && accept.includes("text/html");
230
+ const deadline = Date.now() + (retryable ? 10_000 : 0);
231
+
232
+ while (true) {
233
+ try {
234
+ return await fetch(
235
+ new Request(target.toString(), {
236
+ method: req.method,
237
+ headers: forwardedHeaders,
238
+ body: req.body,
239
+ redirect: "manual",
240
+ }),
241
+ );
242
+ } catch {
243
+ if (retryable && Date.now() < deadline) {
244
+ await Bun.sleep(250);
245
+ continue;
246
+ }
247
+ return new Response("App server is starting...", {
248
+ status: 503,
249
+ headers: { "Content-Type": "text/plain", "Retry-After": "1" },
250
+ });
251
+ }
238
252
  }
239
253
  },
240
254
  });
@@ -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
 
@@ -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 = findMatch(apiRoutes, path);
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();