bosia 0.9.7 → 0.9.9

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,8 +1,8 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "type": "module",
5
- "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
5
+ "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing. No Node.js, no Vite. Runs on Bun or Cloudflare Workers.",
6
6
  "keywords": [
7
7
  "bun",
8
8
  "svelte",
package/src/ambient.d.ts CHANGED
@@ -11,6 +11,7 @@ declare module "bosia:routes" {
11
11
  layouts: Loader[];
12
12
  hasServerData: boolean;
13
13
  trailingSlash: TrailingSlash;
14
+ prerender: boolean;
14
15
  }>;
15
16
 
16
17
  export const serverRoutes: Array<{
@@ -29,3 +30,9 @@ declare module "bosia:routes" {
29
30
 
30
31
  export const errorPage: Loader | null;
31
32
  }
33
+
34
+ // Workers only — backed by .bosia/runtime.workers.ts (see core/workersCodegen.ts).
35
+ declare module "bosia:workers-runtime" {
36
+ export const handle: import("./core/hooks.ts").Handle | null;
37
+ export const config: import("./core/types/plugin.ts").BosiaConfig;
38
+ }
package/src/cli/build.ts CHANGED
@@ -2,8 +2,13 @@ import { spawn } from "bun";
2
2
  import { resolve } from "path";
3
3
  import { loadEnv } from "../core/env.ts";
4
4
 
5
- export async function runBuild() {
5
+ export async function runBuild(args: string[] = []) {
6
6
  loadEnv("production");
7
+ // --target=workers or --target workers; overrides bosia.config's `target`.
8
+ const eq = args.find((a) => a.startsWith("--target="));
9
+ const i = args.indexOf("--target");
10
+ const target = eq ? eq.slice("--target=".length) : i !== -1 ? args[i + 1] : undefined;
11
+ if (target) process.env.BOSIA_TARGET = target;
7
12
  const buildScript = resolve(import.meta.dir, "../core/build.ts");
8
13
  const proc = spawn(["bun", "run", buildScript], {
9
14
  stdout: "inherit",
package/src/cli/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // ─── Bosia CLI ────────────────────────────────────────────
3
3
  // bun x bosia@latest create <name> scaffold a new project
4
4
  // bun x bosia dev start the development server
5
- // bun x bosia build build for production
5
+ // bun x bosia build [--target=workers] build for production (Bun or Cloudflare Workers)
6
6
  // bun x bosia start run the production server
7
7
  // bun x bosia@latest add <name> add a UI component from the registry
8
8
  // bun x bosia@latest feat <name> add a feature scaffold from the registry
@@ -40,7 +40,7 @@ async function main() {
40
40
  }
41
41
  case "build": {
42
42
  const { runBuild } = await import("./build.ts");
43
- await runBuild();
43
+ await runBuild(args);
44
44
  break;
45
45
  }
46
46
  case "sync": {
@@ -114,7 +114,7 @@ Usage:
114
114
  Commands:
115
115
  create <name> [--template <t>] Scaffold a new Bosia project
116
116
  dev Start the development server
117
- build Build for production
117
+ build Build for production (--target=workers for Cloudflare)
118
118
  sync Generate .bosia/ codegen (routes, $types, env) without building
119
119
  start Run the production server
120
120
  test [args] Run tests with bun test (auto-loads .env.test, sets BOSIA_ENV=test)
package/src/cli/start.ts CHANGED
@@ -6,12 +6,20 @@ export async function runStart() {
6
6
  loadEnv("production");
7
7
 
8
8
  let serverEntry = "index.js";
9
+ let target = "bun";
9
10
  try {
10
11
  const manifest = await Bun.file(`${OUT_DIR}/manifest.json`).json();
11
12
  serverEntry = manifest.serverEntry ?? "index.js";
13
+ target = manifest.target ?? "bun";
12
14
  } catch {}
13
15
 
14
- const proc = spawn(["bun", "run", `${OUT_DIR}/server/${serverEntry}`], {
16
+ // A Workers build runs in workerd, locally via wrangler (fetched on first use).
17
+ const cmd =
18
+ target === "workers"
19
+ ? ["bunx", "wrangler", "dev", ...(process.env.PORT ? ["--port", process.env.PORT] : [])]
20
+ : ["bun", "run", `${OUT_DIR}/server/${serverEntry}`];
21
+
22
+ const proc = spawn(cmd, {
15
23
  stdout: "inherit",
16
24
  stderr: "inherit",
17
25
  cwd: process.cwd(),
package/src/cli/sync.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { scanRoutes } from "../core/scanner.ts";
1
+ import { scanRoutes, RouteConflictError } from "../core/scanner.ts";
2
2
  import { generateRoutesFile } from "../core/routeFile.ts";
3
3
  import { generateRouteTypes, ensureRootDirs } from "../core/routeTypes.ts";
4
4
  import { loadEnv, classifyEnvVars } from "../core/env.ts";
@@ -8,7 +8,14 @@ import { findBrandPlaceholders, BRAND_SENTINEL } from "../core/brandGuard.ts";
8
8
  export async function runSync() {
9
9
  const envMode = process.env.NODE_ENV === "production" ? "production" : "development";
10
10
  const classifiedEnv = classifyEnvVars(loadEnv(envMode));
11
- const manifest = scanRoutes();
11
+ let manifest;
12
+ try {
13
+ manifest = scanRoutes();
14
+ } catch (err) {
15
+ if (!(err instanceof RouteConflictError)) throw err;
16
+ console.error(`❌ ${err.message}`);
17
+ process.exit(1);
18
+ }
12
19
  generateRoutesFile(manifest);
13
20
  generateRouteTypes(manifest);
14
21
  ensureRootDirs();
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
2
  import { join, dirname } from "path";
3
3
 
4
4
  import { OUT_DIR } from "./paths.ts";
5
+ import { readArtifact } from "./artifacts.ts";
5
6
  import { rebaseHtmlAttrs } from "./basePath.ts";
6
7
  import { currentBase } from "./appBase.ts";
7
8
 
@@ -86,16 +87,6 @@ export function writeAppHtmlSegments(segments: AppHtmlSegments, outDir: string =
86
87
  return target;
87
88
  }
88
89
 
89
- function readPersistedSegments(cwd: string): AppHtmlSegments | undefined {
90
- const persistedPath = join(cwd, OUT_DIR, "app-html.json");
91
- if (!existsSync(persistedPath)) return undefined;
92
- try {
93
- return JSON.parse(readFileSync(persistedPath, "utf-8")) as AppHtmlSegments;
94
- } catch {
95
- return undefined;
96
- }
97
- }
98
-
99
90
  // ─── Cached Getter ────────────────────────────────────────
100
91
 
101
92
  export function getAppHtmlSegments(cwd: string = process.cwd()): AppHtmlSegments {
@@ -104,7 +95,8 @@ export function getAppHtmlSegments(cwd: string = process.cwd()): AppHtmlSegments
104
95
  }
105
96
  // Prefer persisted dist artifact (production runtime — no `src/` in image).
106
97
  // Fall back to parsing `src/app.html` directly (dev mode, build step).
107
- cachedSegments = readPersistedSegments(cwd) ?? loadAppHtmlTemplate(cwd);
98
+ cachedSegments =
99
+ readArtifact<AppHtmlSegments>("app-html.json", join(cwd, OUT_DIR)) ?? loadAppHtmlTemplate(cwd);
108
100
  return cachedSegments;
109
101
  }
110
102
 
@@ -0,0 +1,21 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ import { OUT_DIR } from "./paths.ts";
5
+
6
+ // ─── Build Artifacts ─────────────────────────────────────
7
+ // The one place the runtime reads the JSON the build left in OUT_DIR
8
+ // (manifest.json, app-html.json, route-manifest.json). Runtimes without a
9
+ // filesystem (Cloudflare Workers) swap this module for `.bosia/artifacts.ts`,
10
+ // which holds the same JSON inlined — see artifactCodegen.ts and plugin.ts.
11
+
12
+ /** Parsed `<dir>/<name>`, or undefined when it is missing or unreadable. */
13
+ export function readArtifact<T>(name: string, dir: string = OUT_DIR): T | undefined {
14
+ const p = join(dir, name);
15
+ if (!existsSync(p)) return undefined;
16
+ try {
17
+ return JSON.parse(readFileSync(p, "utf-8")) as T;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
package/src/core/build.ts CHANGED
@@ -2,7 +2,7 @@ import { writeFileSync, readFileSync, rmSync, mkdirSync, existsSync } from "fs";
2
2
  import { basename, join, relative } from "path";
3
3
  import type { RouteManifest } from "./types.ts";
4
4
 
5
- import { scanRoutes } from "./scanner.ts";
5
+ import { scanRoutes, RouteConflictError } from "./scanner.ts";
6
6
  import { generateRoutesFile } from "./routeFile.ts";
7
7
  import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
8
8
  import { makeBosiaPlugin } from "./plugin.ts";
@@ -14,9 +14,15 @@ import { generateEnvModules } from "./envCodegen.ts";
14
14
  import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin, toPosix } from "./paths.ts";
15
15
  import { currentBase } from "./appBase.ts";
16
16
  import { finalizeTailwindCss, TW_TEMP_BASENAME } from "./twHash.ts";
17
- import { loadPlugins } from "./config.ts";
18
- import type { BuildContext } from "./types/plugin.ts";
17
+ import { loadBosiaConfig, loadPlugins } from "./config.ts";
18
+ import type { BuildContext, RuntimeTarget } from "./types/plugin.ts";
19
19
  import { loadAppHtmlTemplate, writeAppHtmlSegments } from "./appHtml.ts";
20
+ import {
21
+ generateArtifactsModule,
22
+ generateWorkersRuntime,
23
+ generateWranglerConfig,
24
+ } from "./workersCodegen.ts";
25
+ import { workersGuardReport } from "./workersGuard.ts";
20
26
 
21
27
  // Resolved from this file's location inside the bosia package
22
28
  const CORE_DIR = import.meta.dir;
@@ -42,6 +48,23 @@ const buildCtx: BuildContext = {
42
48
  cwd: process.cwd(),
43
49
  };
44
50
 
51
+ // 0-bis. Runtime target: `bosia build --target=` (BOSIA_TARGET) beats bosia.config.
52
+ const target = (process.env.BOSIA_TARGET ||
53
+ (await loadBosiaConfig()).target ||
54
+ "bun") as RuntimeTarget;
55
+ if (target !== "bun" && target !== "workers") {
56
+ console.error(`❌ Unknown target "${target}". Use "bun" or "workers".`);
57
+ process.exit(1);
58
+ }
59
+ if (target !== "bun") console.log(`🎯 Target: ${target}`);
60
+ if (target === "workers") {
61
+ const guard = workersGuardReport();
62
+ if (guard) {
63
+ console.error(`❌ ${guard}`);
64
+ process.exit(1);
65
+ }
66
+ }
67
+
45
68
  for (const p of userPlugins) {
46
69
  if (p.build?.preBuild) {
47
70
  await p.build.preBuild(buildCtx);
@@ -77,6 +100,8 @@ for (const p of [
77
100
  ".bosia/routes.client.ts",
78
101
  ".bosia/env.server.ts",
79
102
  ".bosia/env.client.ts",
103
+ ".bosia/artifacts.ts",
104
+ ".bosia/runtime.workers.ts",
80
105
  ".bosia/types",
81
106
  ]) {
82
107
  try {
@@ -85,7 +110,14 @@ for (const p of [
85
110
  }
86
111
 
87
112
  // 1. Scan routes (or reuse the cached manifest — see 0b-pre)
88
- const manifest = cachedManifest ?? scanRoutes();
113
+ let manifest: RouteManifest;
114
+ try {
115
+ manifest = cachedManifest ?? scanRoutes();
116
+ } catch (err) {
117
+ if (!(err instanceof RouteConflictError)) throw err;
118
+ console.error(`❌ ${err.message}`);
119
+ process.exit(1);
120
+ }
89
121
  buildCtx.manifest = manifest;
90
122
  console.log(
91
123
  `📂 Found ${manifest.pages.length} page route(s)${cachedManifest ? " (cached scan)" : ""}:`,
@@ -180,7 +212,9 @@ const clientPromise = Bun.build({
180
212
  target: "browser",
181
213
  conditions: ["svelte"],
182
214
  splitting: true,
183
- naming: { entry: "[name]-[hash].[ext]", chunk: "[name]-[hash].[ext]" },
215
+ // Chunks are named after their source, which for routes is `+page` — and
216
+ // Cloudflare's asset server answers a `+` in the path with a redirect.
217
+ naming: { entry: "[name]-[hash].[ext]", chunk: "chunk-[hash].[ext]" },
184
218
  minify: isProduction,
185
219
  sourcemap: isProduction ? "none" : "linked",
186
220
  define: {
@@ -191,7 +225,7 @@ const clientPromise = Bun.build({
191
225
  });
192
226
 
193
227
  const serverPromise = Bun.build({
194
- entrypoints: [join(CORE_DIR, "server.ts")],
228
+ entrypoints: [join(CORE_DIR, "server.bun.ts")],
195
229
  outdir: `${OUT_DIR}/server`,
196
230
  target: "bun",
197
231
  conditions: ["svelte"],
@@ -285,10 +319,14 @@ const distManifest = {
285
319
  jsFiles.find((f) => f.startsWith("hydrate")) ??
286
320
  "hydrate.js",
287
321
  serverEntry,
322
+ target,
288
323
  tw: twFile,
289
324
  // The CSS urls and the client route table are baked in with this prefix.
290
325
  // Stamped so the server can warn when it boots with a different one.
291
326
  basePath: currentBase(),
327
+ // Names of PUBLIC_* runtime vars the page may expose to the browser. The
328
+ // server is its own process, so it can't see which names loadEnv() declared.
329
+ publicEnv: Object.keys(classifiedEnv.publicDynamic),
292
330
  };
293
331
  writeFileSync(`${OUT_DIR}/manifest.json`, JSON.stringify(distManifest, null, 2));
294
332
  console.log(`✅ Client bundle: ${jsFiles.join(", ")}`);
@@ -315,6 +353,10 @@ await prerenderStaticRoutes(manifest);
315
353
  // 10. Generate static site output (HTML + client assets + public → dist/static/)
316
354
  generateStaticSite();
317
355
 
356
+ // 11. Workers target: a second server bundle for Cloudflare. The Bun one above
357
+ // still exists — prerender just booted it to crawl static routes.
358
+ if (target === "workers") await buildWorker();
359
+
318
360
  for (const p of userPlugins) {
319
361
  if (p.build?.postBuild) {
320
362
  await p.build.postBuild(buildCtx);
@@ -325,6 +367,63 @@ console.log(`\n🎉 Build complete in ${Math.round(performance.now() - buildStar
325
367
 
326
368
  // ─── Helpers ─────────────────────────────────────────────
327
369
 
370
+ // Dev-only plugins in bosia.config.ts (the inspector) import svelte/compiler, which
371
+ // would put ~820KB of never-run code in the worker, 60% of the demo's bundle.
372
+ // Nothing compiles Svelte at runtime, so every export becomes a function that throws.
373
+ function stubSvelteCompiler(): import("bun").BunPlugin {
374
+ return {
375
+ name: "bosia-stub-svelte-compiler",
376
+ setup(build) {
377
+ build.onResolve({ filter: /^svelte\/compiler$/ }, () => ({
378
+ path: "svelte/compiler",
379
+ namespace: "bosia-stub",
380
+ }));
381
+ build.onLoad({ filter: /.*/, namespace: "bosia-stub" }, async () => {
382
+ const names = Object.keys(await import("svelte/compiler"));
383
+ const fail = `() => { throw new Error("The Svelte compiler isn't available on Cloudflare Workers"); }`;
384
+ return {
385
+ loader: "js",
386
+ contents: names.map((n) => `export const ${n} = ${fail};`).join("\n"),
387
+ };
388
+ });
389
+ },
390
+ };
391
+ }
392
+
393
+ async function buildWorker(): Promise<void> {
394
+ // The isolate has no filesystem: inline the artifacts and static-import the
395
+ // user's hooks + config instead of reading them off disk at boot.
396
+ generateArtifactsModule();
397
+ generateWorkersRuntime();
398
+ // target "node" would emit createRequire(import.meta.url), and import.meta.url
399
+ // is undefined in workerd. Node builtins stay `node:` imports (nodejs_compat).
400
+ const result = await Bun.build({
401
+ entrypoints: [join(CORE_DIR, "server.workers.ts")],
402
+ outdir: `${OUT_DIR}/worker`,
403
+ target: "browser",
404
+ format: "esm",
405
+ conditions: ["workerd", "worker", "svelte"],
406
+ naming: { entry: "index.[ext]" },
407
+ minify: isProduction,
408
+ external: ["node:*"],
409
+ define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development") },
410
+ plugins: [
411
+ stubSvelteCompiler(),
412
+ makeBosiaPlugin("bun", "workers"),
413
+ ...userServerBunPlugins,
414
+ makeBosiaSvelteCompiler("bun"),
415
+ ],
416
+ });
417
+ if (!result.success) {
418
+ console.error("❌ Worker build failed:");
419
+ for (const msg of result.logs) console.error(msg);
420
+ process.exit(1);
421
+ }
422
+ const kb = Math.round((result.outputs[0]?.size ?? 0) / 1024);
423
+ console.log(`✅ Worker entry: ${OUT_DIR}/worker/index.js (${kb}KB)`);
424
+ if (generateWranglerConfig()) console.log("☁️ Wrote wrangler.jsonc");
425
+ }
426
+
328
427
  async function readUserDependencyNames(cwd: string): Promise<string[]> {
329
428
  try {
330
429
  const pkg = (await Bun.file(join(cwd, "package.json")).json()) as {
package/src/core/cache.ts CHANGED
@@ -5,10 +5,14 @@
5
5
  //
6
6
  // See docs/guides/response-cache.md.
7
7
 
8
- import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
8
+ // node:crypto / node:zlib rather than Bun.* — the same code runs on Bun and on
9
+ // Cloudflare Workers (nodejs_compat), and both stay sync there.
10
+ import { createHash } from "node:crypto";
11
+ import { brotliCompressSync, gzipSync, constants as zlibConstants } from "node:zlib";
9
12
  import type { Cookies, LoaderDeps } from "./hooks.ts";
10
13
  import type { CookieJar } from "./cookies.ts";
11
14
  import { dedupKey } from "./dedup.ts";
15
+ import { compressionOn, PRECOMPRESSED } from "./html.ts";
12
16
 
13
17
  // ─── Config ──────────────────────────────────────────────
14
18
 
@@ -125,7 +129,7 @@ const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
125
129
 
126
130
  /** SHA-256 truncated to 64 bits — identity buckets must not collide across users. */
127
131
  function identityDigest(s: string): string {
128
- return new Bun.CryptoHasher("sha256").update(s).digest("hex").slice(0, 16);
132
+ return createHash("sha256").update(s).digest("hex").slice(0, 16);
129
133
  }
130
134
 
131
135
  export function computeIdentityHash(req: Request, cookies: Pick<CookieJar, "peek">): string {
@@ -282,11 +286,11 @@ export function buildCompressedVariants(body: Bytes): {
282
286
  brotli: Bytes | null;
283
287
  } {
284
288
  const COMPRESS_MIN_BYTES = 2048;
285
- if (body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
289
+ if (!compressionOn || body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
286
290
  let gzip: Bytes | null = null;
287
291
  let brotli: Bytes | null = null;
288
292
  try {
289
- gzip = Bun.gzipSync(body) as Bytes;
293
+ gzip = new Uint8Array(gzipSync(body)) as Bytes;
290
294
  } catch {
291
295
  gzip = null;
292
296
  }
@@ -360,11 +364,11 @@ export function serveCached(entry: CacheEntry, req: Request): Response {
360
364
  };
361
365
  if (entry.brotli && accept.includes("br")) {
362
366
  headers["content-encoding"] = "br";
363
- return new Response(entry.brotli, { status: entry.status, headers });
367
+ return new Response(entry.brotli, { ...PRECOMPRESSED, status: entry.status, headers });
364
368
  }
365
369
  if (entry.gzip && accept.includes("gzip")) {
366
370
  headers["content-encoding"] = "gzip";
367
- return new Response(entry.gzip, { status: entry.status, headers });
371
+ return new Response(entry.gzip, { ...PRECOMPRESSED, status: entry.status, headers });
368
372
  }
369
373
  return new Response(entry.raw, { status: entry.status, headers });
370
374
  }
@@ -219,7 +219,10 @@
219
219
  // Forward cached parent data for skipped layers so downstream loaders see
220
220
  // real parent() data, not {}. POST only when there's something to carry —
221
221
  // keeps the no-skip case a cacheable/dedupable GET.
222
- const snapshots = buildParentSnapshots(path, maskBits);
222
+ // A prerendered route's data is a fixed file, so there is nothing to skip —
223
+ // and on Workers the asset server answers anything but GET with 405.
224
+ const prerendered = match.route.prerender;
225
+ const snapshots = prerendered ? {} : buildParentSnapshots(path, maskBits);
223
226
  const dataInit: RequestInit =
224
227
  Object.keys(snapshots).length > 0
225
228
  ? {
@@ -231,7 +234,7 @@
231
234
  const dataFetch = cached
232
235
  ? Promise.resolve(cached)
233
236
  : match.route.hasServerData
234
- ? fetch(dataUrl(path, maskBits), dataInit)
237
+ ? fetch(dataUrl(path, prerendered ? undefined : maskBits), dataInit)
235
238
  .then(readDataResponse)
236
239
  // Only a failed request reaches here now — offline, DNS, aborted.
237
240
  // A response that arrived is read for what it says, not discarded.
@@ -17,7 +17,9 @@ export function buildMaskBits(path: string): string | null {
17
17
  const url = new URL(path, window.location.origin);
18
18
  const pathname = url.pathname;
19
19
  const match = findMatch(clientRoutes, pathname);
20
- if (!match) return null;
20
+ // Prerendered data is a fixed file: nothing to skip, and on Workers only a
21
+ // plain GET reaches it (the asset server answers a POST with 405).
22
+ if (!match || match.route.prerender) return null;
21
23
  const ctx = liveContext(pathname, match.params, url);
22
24
  const layoutIds = (match.route as any).layoutIds as (string | null)[];
23
25
  const pageId = (match.route as any).pageId as string | null;
@@ -9,7 +9,7 @@ let cachedFromPath: string | null = null;
9
9
 
10
10
  const CONFIG_NAMES = ["bosia.config.ts", "bosia.config.js", "bosia.config.mjs"];
11
11
 
12
- function findConfigPath(cwd: string): string | null {
12
+ export function findConfigPath(cwd: string): string | null {
13
13
  for (const name of CONFIG_NAMES) {
14
14
  const p = join(cwd, name);
15
15
  if (existsSync(p)) return p;
@@ -39,9 +39,7 @@ export async function loadBosiaConfig(cwd: string = process.cwd()): Promise<Bosi
39
39
  `${prebuiltPath} must export a default object (use \`export default defineConfig({...})\`).`,
40
40
  );
41
41
  }
42
- const rawPlugins = Array.isArray(config.plugins) ? config.plugins : [];
43
- const plugins = rawPlugins.filter((p): p is BosiaPlugin => Boolean(p));
44
- cached = { plugins };
42
+ cached = normalizeConfig(config);
45
43
  cachedFromPath = cwd;
46
44
  return cached;
47
45
  }
@@ -89,13 +87,26 @@ export async function loadBosiaConfig(cwd: string = process.cwd()): Promise<Bosi
89
87
  );
90
88
  }
91
89
 
90
+ cached = normalizeConfig(config);
91
+ cachedFromPath = cwd;
92
+ return cached;
93
+ }
94
+
95
+ // Keep every field — only `plugins` needs cleaning. Rebuilding the object as
96
+ // `{ plugins }` silently dropped `strictImports` and anything added later.
97
+ function normalizeConfig(config: BosiaConfig): BosiaConfig {
92
98
  const rawPlugins = Array.isArray(config.plugins) ? config.plugins : [];
93
- const plugins = rawPlugins.filter((p): p is BosiaPlugin => Boolean(p));
94
- const normalized: BosiaConfig = { plugins };
99
+ return { ...config, plugins: rawPlugins.filter((p): p is BosiaPlugin => Boolean(p)) };
100
+ }
95
101
 
96
- cached = normalized;
102
+ /**
103
+ * Hand over an already-imported config. For runtimes that can't load
104
+ * `bosia.config.ts` off disk (Workers bundles it statically); call before
105
+ * anything reads the config.
106
+ */
107
+ export function setBosiaConfig(config: BosiaConfig, cwd: string = process.cwd()): void {
108
+ cached = normalizeConfig(config);
97
109
  cachedFromPath = cwd;
98
- return normalized;
99
110
  }
100
111
 
101
112
  /** Test-only — drops the in-memory cache so tests can reload fresh config files. */
package/src/core/dev.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
2
  import { readdirSync, statSync, watch, type Dirent } from "fs";
3
3
  import { join } from "path";
4
- import { loadEnv, resetDeclaredKeys } from "./env.ts";
4
+ import { loadEnv } from "./env.ts";
5
5
  import { BOSIA_NODE_PATH } from "./paths.ts";
6
6
  import { pidsOnPort } from "./port.ts";
7
7
  import { affectsRouteManifest, shouldIgnoreForRebuild } from "./devWatch.ts";
@@ -31,7 +31,6 @@ function reloadEnv() {
31
31
  for (const [k, v] of Object.entries(SHELL_ENV_SNAPSHOT)) {
32
32
  if (v !== undefined) process.env[k] = v;
33
33
  }
34
- resetDeclaredKeys();
35
34
  loadEnv("development");
36
35
  }
37
36
 
package/src/core/env.ts CHANGED
@@ -162,11 +162,6 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
162
162
  for (const key of Object.keys(parsed)) declaredNames.add(key);
163
163
  }
164
164
 
165
- // Track declared keys so html.ts only exposes .env-declared PUBLIC_* vars
166
- for (const key of declaredNames) {
167
- _declaredKeys.add(key);
168
- }
169
-
170
165
  // Apply to process.env — system env wins (don't overwrite existing).
171
166
  // Name-only keys stay absent so `process.env.X ?? default` still falls back.
172
167
  for (const [key, value] of Object.entries(merged)) {
@@ -186,21 +181,6 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
186
181
  return result;
187
182
  }
188
183
 
189
- // ─── Declared Key Tracking ───────────────────────────
190
- // Track which keys were declared in .env files so html.ts only exposes those to the client.
191
-
192
- const _declaredKeys = new Set<string>();
193
-
194
- /** Returns the set of env var keys that were declared in .env files. */
195
- export function getDeclaredEnvKeys(): ReadonlySet<string> {
196
- return _declaredKeys;
197
- }
198
-
199
- /** Clear the declared-keys set. Call before re-running `loadEnv` on hot-reload so removed PUBLIC_* keys stop leaking to the client. */
200
- export function resetDeclaredKeys(): void {
201
- _declaredKeys.clear();
202
- }
203
-
204
184
  // ─── Classifier ──────────────────────────────────────────
205
185
 
206
186
  export interface ClassifiedEnv {
package/src/core/hooks.ts CHANGED
@@ -31,6 +31,19 @@ export interface Cookies {
31
31
 
32
32
  // ─── Event Types ──────────────────────────────────────────
33
33
 
34
+ /**
35
+ * Runtime bindings (Cloudflare Workers: D1, KV, R2, vars). Augment to type them:
36
+ * `declare module "bosia" { interface PlatformEnv { DB: D1Database } }`
37
+ */
38
+ export interface PlatformEnv {
39
+ [key: string]: any;
40
+ }
41
+
42
+ /** What the host runtime hands the app. */
43
+ export interface Platform {
44
+ env: PlatformEnv;
45
+ }
46
+
34
47
  export type RequestEvent = {
35
48
  request: Request;
36
49
  url: URL;
@@ -56,6 +69,11 @@ export type RequestEvent = {
56
69
  * authorization: a check that runs on one kind and not the other is a hole.
57
70
  */
58
71
  isDataRequest: boolean;
72
+ /**
73
+ * Runtime platform context. On Cloudflare Workers, `platform.env` holds the
74
+ * bindings (D1, KV, R2, …). `undefined` on Bun.
75
+ */
76
+ platform?: Platform;
59
77
  };
60
78
 
61
79
  export type LoadEvent = {
@@ -73,6 +91,11 @@ export type LoadEvent = {
73
91
  * namespaced (e.g. `"app:user"`).
74
92
  */
75
93
  depends: (...keys: string[]) => void;
94
+ /**
95
+ * Runtime platform context. On Cloudflare Workers, `platform.env` holds the
96
+ * bindings (D1, KV, R2, …). `undefined` on Bun.
97
+ */
98
+ platform?: Platform;
76
99
  /**
77
100
  * Set response headers for this request. Headers accumulate across
78
101
  * layout and page loaders and land on both the SSR HTML response and
@@ -150,6 +173,11 @@ export type MetadataEvent = {
150
173
  locals: Record<string, any>;
151
174
  cookies: Cookies;
152
175
  fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
176
+ /**
177
+ * Runtime platform context. On Cloudflare Workers, `platform.env` holds the
178
+ * bindings (D1, KV, R2, …). `undefined` on Bun.
179
+ */
180
+ platform?: Platform;
153
181
  };
154
182
 
155
183
  export type Metadata = {