bosia 0.4.1 → 0.4.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.4.1",
3
+ "version": "0.4.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": [
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "scripts": {
43
43
  "check": "tsc --noEmit && prettier --check .",
44
+ "check:templates": "bun scripts/check-templates.ts",
44
45
  "test": "bun test",
45
46
  "test:watch": "bun test --watch"
46
47
  },
@@ -51,7 +52,6 @@
51
52
  "dependencies": {
52
53
  "@clack/prompts": "^1.1.0",
53
54
  "@tailwindcss/cli": "^4.2.1",
54
- "bun-plugin-svelte": "^0.0.6",
55
55
  "elysia": "^1.4.26",
56
56
  "magic-string": "^0.30.0",
57
57
  "svelte": "^5.53.6",
package/src/cli/create.ts CHANGED
@@ -148,7 +148,10 @@ function copyDir(src: string, dest: string, projectName: string, isLocal: boolea
148
148
  mkdirSync(dest, { recursive: true });
149
149
  for (const entry of readdirSync(src, { withFileTypes: true })) {
150
150
  const srcPath = join(src, entry.name);
151
- const destPath = join(dest, entry.name);
151
+ // npm pack strips `.gitignore` from published packages, so templates ship
152
+ // it as `_gitignore` and we restore the dotfile name on copy.
153
+ const destName = entry.name === "_gitignore" ? ".gitignore" : entry.name;
154
+ const destPath = join(dest, destName);
152
155
 
153
156
  // Do not copy instructions.txt or template.json to the final project
154
157
  if (entry.name === "instructions.txt" || entry.name === "template.json") continue;
package/src/core/build.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { SveltePlugin } from "bun-plugin-svelte";
2
1
  import { writeFileSync, rmSync, mkdirSync } from "fs";
3
2
  import { join, relative } from "path";
4
3
 
@@ -6,6 +5,7 @@ import { scanRoutes } from "./scanner.ts";
6
5
  import { generateRoutesFile } from "./routeFile.ts";
7
6
  import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
8
7
  import { makeBosiaPlugin } from "./plugin.ts";
8
+ import { makeBosiaSvelteCompiler } from "./svelteCompiler.ts";
9
9
  import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
10
10
  import { loadEnv, classifyEnvVars } from "./env.ts";
11
11
  import { generateEnvModules } from "./envCodegen.ts";
@@ -134,7 +134,7 @@ const clientPromise = Bun.build({
134
134
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
135
135
  ...staticDefines,
136
136
  },
137
- plugins: [clientPlugin, ...userClientBunPlugins, SveltePlugin()],
137
+ plugins: [clientPlugin, ...userClientBunPlugins, makeBosiaSvelteCompiler("browser")],
138
138
  });
139
139
 
140
140
  const serverPromise = Bun.build({
@@ -145,7 +145,7 @@ const serverPromise = Bun.build({
145
145
  naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
146
146
  minify: isProduction,
147
147
  external: ["elysia"],
148
- plugins: [serverPlugin, ...userServerBunPlugins, SveltePlugin()],
148
+ plugins: [serverPlugin, ...userServerBunPlugins, makeBosiaSvelteCompiler("bun")],
149
149
  });
150
150
 
151
151
  const [tailwindExitCode, clientResult, serverResult] = await Promise.all([
package/src/core/html.ts CHANGED
@@ -43,7 +43,7 @@ export function safeJsonStringify(data: unknown): string {
43
43
  * Only exposes keys tracked by loadEnv() — never leaks system env vars
44
44
  * that happen to start with PUBLIC_.
45
45
  */
46
- function getPublicDynamicEnv(): Record<string, string> {
46
+ const _publicDynamicEnv: Record<string, string> = (() => {
47
47
  const declared = getDeclaredEnvKeys();
48
48
  const result: Record<string, string> = {};
49
49
  for (const key of declared) {
@@ -53,6 +53,9 @@ function getPublicDynamicEnv(): Record<string, string> {
53
53
  }
54
54
  }
55
55
  return result;
56
+ })();
57
+ function getPublicDynamicEnv(): Record<string, string> {
58
+ return _publicDynamicEnv;
56
59
  }
57
60
 
58
61
  // ─── Lang Validation ──────────────────────────────────────
@@ -2,6 +2,7 @@ import { render } from "svelte/server";
2
2
 
3
3
  import { findMatch } from "./matcher.ts";
4
4
  import { serverRoutes, errorPage } from "bosia:routes";
5
+ import type { RouteMatch } from "./types.ts";
5
6
  import type { Cookies } from "./hooks.ts";
6
7
  import { HttpError, Redirect } from "./errors.ts";
7
8
  import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
@@ -166,30 +167,33 @@ export async function loadRouteData(
166
167
  req: Request,
167
168
  cookies: Cookies,
168
169
  metadataData: Record<string, any> | null = null,
170
+ match?: RouteMatch<(typeof serverRoutes)[number]> | null,
169
171
  ) {
170
- const match = findMatch(serverRoutes, url.pathname);
172
+ match ??= findMatch(serverRoutes, url.pathname);
171
173
  if (!match) return null;
172
174
 
173
175
  const { route, params } = match;
174
176
  const fetch = makeFetch(req, url);
175
177
  const layoutData: Record<string, any>[] = [];
178
+ let parentData: Record<string, any> = {};
176
179
 
177
180
  // Run layout server loaders root → leaf, each gets parent() data
178
181
  for (const ls of route.layoutServers) {
179
182
  try {
180
183
  const mod = await ls.loader();
181
184
  if (typeof mod.load === "function") {
182
- const parent = async () => {
183
- const merged: Record<string, any> = {};
184
- for (let d = 0; d < ls.depth; d++) Object.assign(merged, layoutData[d] ?? {});
185
- return merged;
186
- };
187
- layoutData[ls.depth] =
185
+ // Snapshot per layer so loaders cannot mutate the shared accumulator,
186
+ // preserving the same isolation semantics as the previous merge-on-call code.
187
+ const snapshot = { ...parentData };
188
+ const parent = async () => snapshot;
189
+ const result =
188
190
  (await withTimeout(
189
191
  mod.load({ params, url, locals, cookies, parent, fetch, metadata: null }),
190
192
  LOAD_TIMEOUT,
191
193
  `layout load (depth=${ls.depth}, ${url.pathname})`,
192
194
  )) ?? {};
195
+ layoutData[ls.depth] = result;
196
+ parentData = { ...parentData, ...result };
193
197
  }
194
198
  } catch (err) {
195
199
  if (err instanceof Redirect) throw err;
@@ -215,11 +219,8 @@ export async function loadRouteData(
215
219
  if (mod.csr === false) csr = false;
216
220
  if (mod.ssr === false) ssr = false;
217
221
  if (typeof mod.load === "function") {
218
- const parent = async () => {
219
- const merged: Record<string, any> = {};
220
- for (const d of layoutData) if (d) Object.assign(merged, d);
221
- return merged;
222
- };
222
+ const snapshot = { ...parentData };
223
+ const parent = async () => snapshot;
223
224
  pageData =
224
225
  (await withTimeout(
225
226
  mod.load({
@@ -289,8 +290,9 @@ export async function renderSSRStream(
289
290
  locals: Record<string, any>,
290
291
  req: Request,
291
292
  cookies: Cookies,
293
+ match?: RouteMatch<(typeof serverRoutes)[number]> | null,
292
294
  ): Promise<Response | null> {
293
- const match = findMatch(serverRoutes, url.pathname);
295
+ match ??= findMatch(serverRoutes, url.pathname);
294
296
  if (!match) return null;
295
297
 
296
298
  const { route, params } = match;
@@ -321,7 +323,7 @@ export async function renderSSRStream(
321
323
 
322
324
  try {
323
325
  [data, pageMod, layoutMods] = await Promise.all([
324
- loadRouteData(url, locals, req, cookies, metadataData),
326
+ loadRouteData(url, locals, req, cookies, metadataData, match),
325
327
  route.pageModule(),
326
328
  Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
327
329
  ]);
@@ -469,15 +471,16 @@ export async function renderPageWithFormData(
469
471
  cookies: Cookies,
470
472
  formData: any,
471
473
  status: number,
474
+ match?: RouteMatch<(typeof serverRoutes)[number]> | null,
472
475
  ): Promise<Response> {
473
- const match = findMatch(serverRoutes, url.pathname);
476
+ match ??= findMatch(serverRoutes, url.pathname);
474
477
  if (!match) return renderErrorPage(404, "Not Found", url, req);
475
478
 
476
479
  const { route } = match;
477
480
 
478
481
  // Load components + data in parallel
479
482
  const [data, pageMod, layoutMods] = await Promise.all([
480
- loadRouteData(url, locals, req, cookies),
483
+ loadRouteData(url, locals, req, cookies, null, match),
481
484
  route.pageModule(),
482
485
  Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
483
486
  ]);
@@ -315,12 +315,14 @@ async function resolve(event: RequestEvent): Promise<Response> {
315
315
  }
316
316
  }
317
317
 
318
+ // Resolve the page route once; reuse for trailing-slash, form-action, and SSR phases.
319
+ const pageMatch = findMatch(serverRoutes, path);
320
+
318
321
  // Trailing-slash canonicalization — 308 preserves method (form POSTs included)
319
- const canonicalMatch = findMatch(serverRoutes, path);
320
- if (canonicalMatch) {
322
+ if (pageMatch) {
321
323
  const canonical = canonicalPathname(
322
324
  path,
323
- (canonicalMatch.route as any).trailingSlash ?? "never",
325
+ (pageMatch.route as any).trailingSlash ?? "never",
324
326
  );
325
327
  if (canonical !== null) {
326
328
  return new Response(null, {
@@ -332,7 +334,6 @@ async function resolve(event: RequestEvent): Promise<Response> {
332
334
 
333
335
  // Form actions — POST to page routes with `actions` export
334
336
  if (method === "POST") {
335
- const pageMatch = findMatch(serverRoutes, path);
336
337
  if (pageMatch?.route.pageServer) {
337
338
  // `use:enhance` sets this header — return JSON instead of re-rendering HTML
338
339
  const isEnhanced = request.headers.get("x-bosia-action") === "1";
@@ -421,6 +422,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
421
422
  cookies,
422
423
  result.data,
423
424
  result.status,
425
+ pageMatch,
424
426
  );
425
427
  }
426
428
 
@@ -439,6 +441,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
439
441
  cookies,
440
442
  result ?? null,
441
443
  200,
444
+ pageMatch,
442
445
  );
443
446
  }
444
447
  } catch (err) {
@@ -478,7 +481,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
478
481
  }
479
482
 
480
483
  // SSR pages (+page.svelte) — streaming by default
481
- const streamResponse = await renderSSRStream(url, locals, request, cookies);
484
+ const streamResponse = await renderSSRStream(url, locals, request, cookies, pageMatch);
482
485
  if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
483
486
  return streamResponse;
484
487
  }
@@ -659,18 +662,12 @@ for (const plugin of plugins) {
659
662
 
660
663
  app = app
661
664
  // Static files are served by resolve() with path traversal protection and security headers
662
- // API routes must intercept all HTTP methods before the GET catch-all
663
- .onBeforeHandle(async ({ request }) => {
664
- const url = new URL(request.url);
665
- if (!findMatch(apiRoutes, url.pathname)) return; // not an API route
666
- return handleRequest(request, url);
667
- })
668
665
  // SSR pages
669
666
  .get("*", ({ request }: { request: Request }) => {
670
667
  const url = new URL(request.url);
671
668
  return handleRequest(request, url);
672
669
  })
673
- // Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
670
+ // Non-GET catch-alls route every method through handleRequest()
674
671
  .post("*", ({ request }: { request: Request }) => {
675
672
  const url = new URL(request.url);
676
673
  return handleRequest(request, url);
@@ -0,0 +1,45 @@
1
+ import { compile, compileModule } from "svelte/compiler";
2
+ import type { BunPlugin } from "bun";
3
+
4
+ const svelteHash = (s: string) => Bun.hash(s, 5381).toString(36);
5
+
6
+ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
7
+ const generate = target === "browser" ? "client" : "server";
8
+ const dev = process.env.NODE_ENV !== "production";
9
+
10
+ return {
11
+ name: "bosia-svelte-compiler",
12
+ setup(build) {
13
+ const ts = new Bun.Transpiler({
14
+ loader: "ts",
15
+ target: target === "browser" ? "browser" : "bun",
16
+ });
17
+
18
+ build.onLoad({ filter: /\.svelte$/ }, async (args) => {
19
+ const source = await Bun.file(args.path).text();
20
+ const result = compile(source, {
21
+ generate,
22
+ css: target === "browser" ? "injected" : "external",
23
+ dev,
24
+ hmr: false,
25
+ cssHash: ({ css }) => `svelte-${svelteHash(css)}`,
26
+ filename: args.path,
27
+ });
28
+ return { contents: result.js.code, loader: "ts" };
29
+ });
30
+
31
+ build.onLoad({ filter: /\.svelte\.[tj]s$/ }, async (args) => {
32
+ let source = await Bun.file(args.path).text();
33
+ if (args.path.endsWith(".ts")) {
34
+ source = await ts.transform(source);
35
+ }
36
+ const result = compileModule(source, {
37
+ generate,
38
+ dev,
39
+ filename: args.path,
40
+ });
41
+ return { contents: result.js.code, loader: "js" };
42
+ });
43
+ },
44
+ };
45
+ }
@@ -3,3 +3,4 @@ dist
3
3
  build
4
4
  .bosia
5
5
  bun.lock
6
+ public/bosia-tw.css
@@ -0,0 +1,12 @@
1
+ node_modules/
2
+ dist/
3
+ .bosia/
4
+ .DS_Store
5
+ *.log
6
+
7
+ # Generated Tailwind output
8
+ public/bosia-tw.css
9
+
10
+ # Local env overrides — never commit secrets
11
+ .env*.local
12
+ .env
@@ -3,3 +3,4 @@ dist
3
3
  build
4
4
  .bosia
5
5
  bun.lock
6
+ public/bosia-tw.css
@@ -0,0 +1,12 @@
1
+ node_modules/
2
+ dist/
3
+ .bosia/
4
+ .DS_Store
5
+ *.log
6
+
7
+ # Generated Tailwind output
8
+ public/bosia-tw.css
9
+
10
+ # Local env overrides — never commit secrets
11
+ .env*.local
12
+ .env
@@ -3,3 +3,4 @@ dist
3
3
  build
4
4
  .bosia
5
5
  bun.lock
6
+ public/bosia-tw.css
@@ -0,0 +1,12 @@
1
+ node_modules/
2
+ dist/
3
+ .bosia/
4
+ .DS_Store
5
+ *.log
6
+
7
+ # Generated Tailwind output
8
+ public/bosia-tw.css
9
+
10
+ # Local env overrides — never commit secrets
11
+ .env*.local
12
+ .env