bosia 0.2.1 → 0.2.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.2.1",
3
+ "version": "0.2.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": [
@@ -31,7 +31,8 @@
31
31
  "package.json"
32
32
  ],
33
33
  "exports": {
34
- ".": "./src/lib/index.ts"
34
+ ".": "./src/lib/index.ts",
35
+ "./client": "./src/lib/client.ts"
35
36
  },
36
37
  "bin": {
37
38
  "bosia": "src/cli/index.ts"
package/src/cli/feat.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { join, dirname } from "path";
1
+ import { join, dirname, extname } from "path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
3
  import * as p from "@clack/prompts";
4
4
  import { addComponent, initAddRegistry } from "./add.ts";
@@ -16,13 +16,26 @@ import {
16
16
  // registry with --local) and copies route/lib files, installs npm deps.
17
17
  // Supports nested feature dependencies (e.g. todo → drizzle).
18
18
 
19
+ type FileStrategy =
20
+ | "write" // overwrite (prompt if interactive)
21
+ | "skip-if-exists" // bootstrap-once: never replace user copy
22
+ | "append-line" // idempotent line append (barrel re-exports)
23
+ | "append-block" // marker-delimited block, replaced by id on re-install
24
+ | "merge-json"; // deep-merge JSON, preserve existing keys
25
+
26
+ interface FileEntry {
27
+ src: string;
28
+ target: string;
29
+ strategy?: FileStrategy;
30
+ marker?: string; // unique id within target (default = feature name)
31
+ }
32
+
19
33
  interface FeatureMeta {
20
34
  name: string;
21
35
  description: string;
22
36
  features?: string[]; // other bosia features required
23
37
  components: string[]; // bosia components to install via `bosia add`
24
- files: string[]; // source filenames in the registry feature dir
25
- targets: string[]; // destination paths relative to project root
38
+ files: FileEntry[]; // file entries with per-file strategy
26
39
  npmDeps: Record<string, string>;
27
40
  npmDevDeps?: Record<string, string>;
28
41
  scripts?: Record<string, string>; // package.json scripts to add
@@ -82,32 +95,26 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
82
95
  console.log("");
83
96
  }
84
97
 
85
- // Copy feature files to their target paths
98
+ // Apply each file entry per its strategy
86
99
  const createdDirs = new Set<string>();
87
- for (let i = 0; i < meta.files.length; i++) {
88
- const file = meta.files[i]!;
89
- const target = meta.targets[i] ?? file;
90
- const dest = join(cwd, target);
91
-
92
- // Prompt before overwriting existing files (skip check entirely in non-interactive mode)
93
- if (!options?.skipPrompts && existsSync(dest)) {
94
- const replace = await p.confirm({
95
- message: `File "${target}" already exists. Replace it?`,
96
- });
97
- if (p.isCancel(replace) || !replace) {
98
- console.log(` ⏭️ Skipped ${target}`);
99
- continue;
100
- }
101
- }
102
-
103
- const content = await readRegistryFile(registryRoot, "features", name, file);
100
+ for (const entry of meta.files) {
101
+ const dest = join(cwd, entry.target);
102
+ const strategy: FileStrategy = entry.strategy ?? "write";
104
103
  const dir = dirname(dest);
105
104
  if (!createdDirs.has(dir)) {
106
105
  mkdirSync(dir, { recursive: true });
107
106
  createdDirs.add(dir);
108
107
  }
109
- writeFileSync(dest, content, "utf-8");
110
- console.log(` ✍️ ${target}`);
108
+ const content = await readRegistryFile(registryRoot, "features", name, entry.src);
109
+ await applyStrategy({
110
+ dest,
111
+ target: entry.target,
112
+ content,
113
+ strategy,
114
+ feat: name,
115
+ marker: entry.marker ?? name,
116
+ skipPrompts: options?.skipPrompts ?? false,
117
+ });
111
118
  }
112
119
 
113
120
  // Install npm dependencies
@@ -162,5 +169,149 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
162
169
  }
163
170
  }
164
171
 
172
+ // ─── File strategies ──────────────────────────────────────
173
+
174
+ interface StrategyArgs {
175
+ dest: string;
176
+ target: string;
177
+ content: string;
178
+ strategy: FileStrategy;
179
+ feat: string;
180
+ marker: string;
181
+ skipPrompts: boolean;
182
+ }
183
+
184
+ async function applyStrategy(args: StrategyArgs): Promise<void> {
185
+ const { dest, target, content, strategy, feat, marker, skipPrompts } = args;
186
+
187
+ switch (strategy) {
188
+ case "write": {
189
+ if (existsSync(dest) && !skipPrompts) {
190
+ const replace = await p.confirm({
191
+ message: `File "${target}" already exists. Replace it?`,
192
+ });
193
+ if (p.isCancel(replace) || !replace) {
194
+ console.log(` ⏭️ Skipped ${target}`);
195
+ return;
196
+ }
197
+ }
198
+ writeFileSync(dest, content, "utf-8");
199
+ console.log(` ✍️ ${target}`);
200
+ return;
201
+ }
202
+
203
+ case "skip-if-exists": {
204
+ if (existsSync(dest)) {
205
+ console.log(` ⏭️ Kept existing ${target}`);
206
+ return;
207
+ }
208
+ writeFileSync(dest, content, "utf-8");
209
+ console.log(` ✍️ ${target}`);
210
+ return;
211
+ }
212
+
213
+ case "append-line": {
214
+ const existing = existsSync(dest) ? readFileSync(dest, "utf-8") : "";
215
+ const existingLines = new Set(
216
+ existing.split("\n").map((l) => l.trim()).filter(Boolean),
217
+ );
218
+ const newLines = content
219
+ .split("\n")
220
+ .map((l) => l.trim())
221
+ .filter(Boolean)
222
+ .filter((l) => !existingLines.has(l));
223
+
224
+ if (newLines.length === 0) {
225
+ console.log(` ⏭️ ${target} (no new lines)`);
226
+ return;
227
+ }
228
+
229
+ const nl = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
230
+ writeFileSync(dest, existing + nl + newLines.join("\n") + "\n", "utf-8");
231
+ console.log(` ➕ ${target} (+${newLines.length} line${newLines.length === 1 ? "" : "s"})`);
232
+ return;
233
+ }
234
+
235
+ case "append-block": {
236
+ const id = `bosia:${feat}:${marker}`;
237
+ const delim = blockDelim(extname(dest));
238
+ const startLine = delim.end
239
+ ? `${delim.start} >>> ${id} ${delim.end}`
240
+ : `${delim.start} >>> ${id}`;
241
+ const endLine = delim.end
242
+ ? `${delim.start} <<< ${id} ${delim.end}`
243
+ : `${delim.start} <<< ${id}`;
244
+ const block = `${startLine}\n${content.trimEnd()}\n${endLine}`;
245
+
246
+ const existing = existsSync(dest) ? readFileSync(dest, "utf-8") : "";
247
+
248
+ if (existing.includes(startLine) && existing.includes(endLine)) {
249
+ const re = new RegExp(
250
+ `${escapeRegex(startLine)}[\\s\\S]*?${escapeRegex(endLine)}`,
251
+ );
252
+ writeFileSync(dest, existing.replace(re, block), "utf-8");
253
+ console.log(` ♻️ ${target} (replaced ${id})`);
254
+ } else {
255
+ const nl = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
256
+ writeFileSync(dest, existing + nl + block + "\n", "utf-8");
257
+ console.log(` ➕ ${target} (appended ${id})`);
258
+ }
259
+ return;
260
+ }
261
+
262
+ case "merge-json": {
263
+ const existing = existsSync(dest)
264
+ ? JSON.parse(readFileSync(dest, "utf-8"))
265
+ : {};
266
+ const incoming = JSON.parse(content);
267
+ const merged = mergeJsonPreserve(existing, incoming);
268
+ writeFileSync(dest, JSON.stringify(merged, null, 2) + "\n", "utf-8");
269
+ console.log(` 🔀 ${target} (merged json)`);
270
+ return;
271
+ }
272
+
273
+ default: {
274
+ const _exhaustive: never = strategy;
275
+ throw new Error(`Unknown file strategy: ${_exhaustive}`);
276
+ }
277
+ }
278
+ }
279
+
280
+ function blockDelim(ext: string): { start: string; end: string } {
281
+ if (ext === ".html" || ext === ".svelte") return { start: "<!--", end: "-->" };
282
+ if (ext === ".css") return { start: "/*", end: "*/" };
283
+ return { start: "//", end: "" };
284
+ }
285
+
286
+ function escapeRegex(s: string): string {
287
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
288
+ }
289
+
290
+ // Deep-merge `source` into `target`, preserving existing target values.
291
+ // Objects: recurse. Arrays: concat-dedupe by JSON identity. Primitives: keep target.
292
+ function mergeJsonPreserve(target: unknown, source: unknown): unknown {
293
+ if (Array.isArray(target) && Array.isArray(source)) {
294
+ const out = [...target];
295
+ for (const item of source) {
296
+ if (!out.some((x) => JSON.stringify(x) === JSON.stringify(item))) {
297
+ out.push(item);
298
+ }
299
+ }
300
+ return out;
301
+ }
302
+ if (isPlainObject(target) && isPlainObject(source)) {
303
+ const out: Record<string, unknown> = { ...target };
304
+ for (const [k, v] of Object.entries(source)) {
305
+ out[k] = k in target ? mergeJsonPreserve(target[k], v) : v;
306
+ }
307
+ return out;
308
+ }
309
+ return target !== undefined ? target : source;
310
+ }
311
+
312
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
313
+ return typeof v === "object" && v !== null && !Array.isArray(v);
314
+ }
315
+
165
316
  // Re-exports for create.ts
166
317
  export { resolveLocalRegistry, type InstallOptions } from "./registry.ts";
@@ -3,6 +3,7 @@
3
3
  import { findMatch } from "../matcher.ts";
4
4
  import { clientRoutes } from "bosia:routes";
5
5
  import { consumePrefetch, prefetchCache, dataUrl } from "./prefetch.ts";
6
+ import { appState } from "./appState.svelte.ts";
6
7
 
7
8
  let {
8
9
  ssrMode = false,
@@ -22,11 +23,13 @@
22
23
 
23
24
  let PageComponent = $state<any>(ssrPageComponent);
24
25
  let layoutComponents = $state<any[]>(ssrLayoutComponents ?? []);
25
- let pageData = $state<Record<string, any>>(ssrPageData ?? {});
26
- let layoutData = $state<Record<string, any>[]>(ssrLayoutData ?? []);
27
- // Kept separate to avoid a read→write cycle inside the $effect below
28
- let routeParams = $state<Record<string, string>>(ssrPageData?.params ?? {});
29
- let formData = $state<any>(ssrFormData);
26
+ // In SSR mode, render directly from props (server module singletons must
27
+ // not hold per-request state). On the client, read/write through `appState`
28
+ // so `use:enhance` and other helpers can update the same cells.
29
+ const pageData = $derived(ssrMode ? (ssrPageData ?? {}) : appState.pageData);
30
+ const layoutData = $derived(ssrMode ? (ssrLayoutData ?? []) : appState.layoutData);
31
+ const routeParams = $derived(ssrMode ? (ssrPageData?.params ?? {}) : appState.routeParams);
32
+ const formData = $derived(ssrMode ? ssrFormData : appState.form);
30
33
  let navigating = $state(false);
31
34
  let navDone = $state(false);
32
35
  // Skip bar on the very first effect run (initial hydration — data already present)
@@ -47,7 +50,7 @@
47
50
  firstNav = false;
48
51
  if (isFirst) return; // Initial hydration — data already in SSR props, no fetch needed
49
52
 
50
- formData = null;
53
+ appState.form = null;
51
54
  if (navDoneTimer) { clearTimeout(navDoneTimer); navDoneTimer = null; }
52
55
  navDone = false;
53
56
  navigating = true;
@@ -82,9 +85,9 @@
82
85
  }
83
86
  PageComponent = pageMod.default;
84
87
  layoutComponents = layoutMods.map((m: any) => m.default);
85
- pageData = result?.pageData ?? {};
86
- layoutData = result?.layoutData ?? [];
87
- routeParams = result?.pageData?.params ?? match.params;
88
+ appState.pageData = result?.pageData ?? {};
89
+ appState.layoutData = result?.layoutData ?? [];
90
+ appState.routeParams = result?.pageData?.params ?? match.params;
88
91
 
89
92
  // Scroll to top on forward navigation (not on popstate/back-forward)
90
93
  if (router.isPush) window.scrollTo(0, 0);
@@ -0,0 +1,55 @@
1
+ // ─── Shared App State ─────────────────────────────────────
2
+ // Singleton holding reactive cells that App.svelte renders from.
3
+ // Lives in a module so client-side helpers (e.g. `use:enhance`)
4
+ // can read and update the same state without going through props.
5
+ //
6
+ // Server-side: never touched. App.svelte's template branches on
7
+ // `ssrMode` and reads from `ssrXxx` props directly during SSR,
8
+ // so concurrent requests don't share these cells.
9
+
10
+ import { dataUrl } from "./prefetch.ts";
11
+ import { router } from "./router.svelte.ts";
12
+
13
+ class AppState {
14
+ pageData = $state<Record<string, any>>({});
15
+ layoutData = $state<Record<string, any>[]>([]);
16
+ routeParams = $state<Record<string, string>>({});
17
+ form = $state<any>(null);
18
+ }
19
+
20
+ export const appState = new AppState();
21
+
22
+ /**
23
+ * Re-fetch loader data for the given path and apply to `appState`.
24
+ * Used by `use:enhance` after a successful action — mirrors SvelteKit's
25
+ * `invalidateAll` default. No-op if the fetch fails or returns an error.
26
+ */
27
+ export async function refreshData(path: string): Promise<void> {
28
+ try {
29
+ const res = await fetch(dataUrl(path));
30
+ if (!res.ok) return;
31
+ const result = await res.json();
32
+ if (result?.redirect) {
33
+ router.navigate(result.redirect);
34
+ return;
35
+ }
36
+ if (result?.error) return;
37
+ appState.pageData = result?.pageData ?? {};
38
+ appState.layoutData = result?.layoutData ?? [];
39
+ appState.routeParams = result?.pageData?.params ?? appState.routeParams;
40
+ if (result?.metadata) {
41
+ if (result.metadata.title) document.title = result.metadata.title;
42
+ if (result.metadata.description) {
43
+ let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
44
+ if (!meta) {
45
+ meta = document.createElement("meta");
46
+ meta.name = "description";
47
+ document.head.appendChild(meta);
48
+ }
49
+ meta.content = result.metadata.description;
50
+ }
51
+ }
52
+ } catch {
53
+ // best-effort — silently swallow
54
+ }
55
+ }
@@ -0,0 +1,107 @@
1
+ // ─── use:enhance ──────────────────────────────────────────
2
+ // Progressive enhancement for `<form method="POST">`. Intercepts
3
+ // submission, POSTs via fetch with `x-bosia-action: 1`, parses the
4
+ // JSON action result, and updates shared form/page state without a
5
+ // full page reload. Falls back to native form submission when JS is
6
+ // disabled because nothing is wired up until this action runs.
7
+
8
+ import { appState, refreshData } from "./appState.svelte.ts";
9
+ import { router } from "./router.svelte.ts";
10
+
11
+ export type ActionResult =
12
+ | { type: "success"; status: number; data: any }
13
+ | { type: "failure"; status: number; data: any }
14
+ | { type: "redirect"; status: number; location: string }
15
+ | { type: "error"; status: number; message: string };
16
+
17
+ export type SubmitFunction = (input: {
18
+ formData: FormData;
19
+ formElement: HTMLFormElement;
20
+ action: URL;
21
+ cancel: () => void;
22
+ submitter: HTMLElement | null;
23
+ }) => void | ((opts: {
24
+ result: ActionResult;
25
+ formElement: HTMLFormElement;
26
+ update: (opts?: { reset?: boolean; invalidateAll?: boolean }) => Promise<void>;
27
+ }) => void | Promise<void>);
28
+
29
+ async function applyResult(
30
+ result: ActionResult,
31
+ form: HTMLFormElement,
32
+ opts: { reset?: boolean; invalidateAll?: boolean } = {},
33
+ ): Promise<void> {
34
+ const reset = opts.reset !== false;
35
+ const invalidateAll = opts.invalidateAll !== false;
36
+
37
+ if (result.type === "redirect") {
38
+ router.navigate(result.location);
39
+ return;
40
+ }
41
+ if (result.type === "error") {
42
+ appState.form = { error: { message: result.message, status: result.status } };
43
+ console.warn(`[bosia] action error ${result.status}: ${result.message}`);
44
+ return;
45
+ }
46
+ if (result.type === "failure") {
47
+ appState.form = result.data;
48
+ return;
49
+ }
50
+ // success
51
+ appState.form = result.data;
52
+ if (reset) form.reset();
53
+ if (invalidateAll) {
54
+ await refreshData(window.location.pathname + window.location.search);
55
+ }
56
+ }
57
+
58
+ export function enhance(form: HTMLFormElement, submit?: SubmitFunction) {
59
+ async function handleSubmit(event: SubmitEvent) {
60
+ event.preventDefault();
61
+
62
+ const submitter = (event.submitter as HTMLElement | null) ?? null;
63
+ const formData = new FormData(form, submitter as HTMLElement | undefined);
64
+
65
+ // Resolve action URL — preserve `?/actionName` if the submitter or form sets it
66
+ const actionAttr = (submitter as HTMLButtonElement | HTMLInputElement | null)?.formAction
67
+ || form.action
68
+ || window.location.href;
69
+ const action = new URL(actionAttr, window.location.href);
70
+
71
+ let cancelled = false;
72
+ const cancel = () => { cancelled = true; };
73
+
74
+ const callback = submit?.({ formData, formElement: form, action, cancel, submitter });
75
+ if (cancelled) return;
76
+
77
+ let result: ActionResult;
78
+ try {
79
+ const res = await fetch(action, {
80
+ method: "POST",
81
+ body: formData,
82
+ headers: { "x-bosia-action": "1", accept: "application/json" },
83
+ });
84
+ result = await res.json() as ActionResult;
85
+ } catch (err) {
86
+ const message = (err as Error)?.message ?? "Network error";
87
+ result = { type: "error", status: 0, message };
88
+ console.warn("[bosia] enhance: submission failed", err);
89
+ }
90
+
91
+ const update = (opts?: { reset?: boolean; invalidateAll?: boolean }) =>
92
+ applyResult(result, form, opts ?? {});
93
+
94
+ if (typeof callback === "function") {
95
+ await callback({ result, formElement: form, update });
96
+ } else {
97
+ await update();
98
+ }
99
+ }
100
+
101
+ form.addEventListener("submit", handleSubmit);
102
+ return {
103
+ destroy() {
104
+ form.removeEventListener("submit", handleSubmit);
105
+ },
106
+ };
107
+ }
@@ -1,9 +1,10 @@
1
- import { hydrate } from "svelte";
1
+ import { hydrate, mount } 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
5
  import { findMatch, compileRoutes } from "../matcher.ts";
6
6
  import { clientRoutes } from "bosia:routes";
7
+ import { appState } from "./appState.svelte.ts";
7
8
 
8
9
  // Pre-compile route patterns into RegExp at startup (shared by App.svelte and router via module reference)
9
10
  compileRoutes(clientRoutes);
@@ -34,17 +35,34 @@ async function main() {
34
35
  router.params = match.params;
35
36
  }
36
37
 
37
- hydrate(App, {
38
- target: document.getElementById("app")!,
39
- props: {
40
- ssrMode: false,
41
- ssrPageComponent,
42
- ssrLayoutComponents,
43
- ssrPageData: (window as any).__BOSIA_PAGE_DATA__ ?? {},
44
- ssrLayoutData: (window as any).__BOSIA_LAYOUT_DATA__ ?? [],
45
- ssrFormData: (window as any).__BOSIA_FORM_DATA__ ?? null,
46
- },
47
- });
38
+ const ssrPageData = (window as any).__BOSIA_PAGE_DATA__ ?? {};
39
+ const ssrLayoutData = (window as any).__BOSIA_LAYOUT_DATA__ ?? [];
40
+ const ssrFormData = (window as any).__BOSIA_FORM_DATA__ ?? null;
41
+
42
+ // Seed shared client state so `use:enhance` and other helpers
43
+ // start from the same values App.svelte renders during hydration.
44
+ appState.pageData = ssrPageData;
45
+ appState.layoutData = ssrLayoutData;
46
+ appState.routeParams = ssrPageData?.params ?? (match?.params ?? {});
47
+ appState.form = ssrFormData;
48
+
49
+ const target = document.getElementById("app")!;
50
+ const props = {
51
+ ssrMode: false,
52
+ ssrPageComponent,
53
+ ssrLayoutComponents,
54
+ ssrPageData,
55
+ ssrLayoutData,
56
+ ssrFormData,
57
+ };
58
+
59
+ // ssr=false → server shipped empty shell, no hydration markers exist.
60
+ // Use mount() instead of hydrate() to render fresh on the client.
61
+ if ((window as any).__BOSIA_SSR__ === false) {
62
+ mount(App, { target, props });
63
+ } else {
64
+ hydrate(App, { target, props });
65
+ }
48
66
  }
49
67
 
50
68
  main();
@@ -9,29 +9,37 @@ export function dataUrl(path: string): string {
9
9
  return `/__bosia/data${p || "/index"}.json${url.search}`;
10
10
  }
11
11
 
12
- export const prefetchCache = new Map<string, any>();
12
+ export const prefetchCache = new Map<string, { data: any; ts: number }>();
13
+ const MAX_PREFETCH_ENTRIES = 50;
13
14
 
14
15
  // In-flight fetch deduplication
15
16
  const pending = new Set<string>();
16
17
 
17
18
  /** Returns cached prefetch data for a path and removes it from cache. */
18
19
  export function consumePrefetch(path: string): any | null {
19
- const data = prefetchCache.get(path);
20
- if (data === undefined) return null;
20
+ const entry = prefetchCache.get(path);
21
+ if (entry === undefined) return null;
21
22
  prefetchCache.delete(path);
22
- return data;
23
+ if (Date.now() - entry.ts > 30_000) return null;
24
+ return entry.data;
23
25
  }
24
26
 
25
27
  /** Prefetches data for a path and stores in cache. No-op if already cached/in-flight. */
26
28
  export async function prefetchPath(path: string): Promise<void> {
27
- if (prefetchCache.has(path)) return;
29
+ const existing = prefetchCache.get(path);
30
+ if (existing && Date.now() - existing.ts <= 30_000) return;
31
+ if (existing) prefetchCache.delete(path);
28
32
  if (pending.has(path)) return;
29
33
 
30
34
  pending.add(path);
31
35
  try {
32
36
  const res = await fetch(dataUrl(path));
33
37
  if (res.ok) {
34
- prefetchCache.set(path, await res.json());
38
+ if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
39
+ const oldest = prefetchCache.keys().next().value;
40
+ if (oldest !== undefined) prefetchCache.delete(oldest);
41
+ }
42
+ prefetchCache.set(path, { data: await res.json(), ts: Date.now() });
35
43
  }
36
44
  } catch {
37
45
  // Silently ignore — prefetch is best-effort
@@ -31,11 +31,17 @@ export const router = new class Router {
31
31
 
32
32
  // Intercept <a> clicks for client-side navigation
33
33
  window.addEventListener("click", (e) => {
34
+ // Let browser handle non-primary buttons, modifier-clicks, already-handled events
35
+ if (e.button !== 0) return;
36
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
37
+ if (e.defaultPrevented) return;
38
+
34
39
  const anchor = (e.target as HTMLElement).closest("a");
35
40
  if (!anchor) return;
36
41
  if (anchor.origin !== window.location.origin) return;
37
42
  if (anchor.target) return;
38
43
  if (anchor.hasAttribute("download")) return;
44
+ if (anchor.rel.split(/\s+/).includes("external")) return;
39
45
  if (anchor.protocol !== "https:" && anchor.protocol !== "http:") return;
40
46
 
41
47
  e.preventDefault();
package/src/core/env.ts CHANGED
@@ -8,6 +8,7 @@ const FRAMEWORK_VARS = new Set([
8
8
  "NODE_ENV",
9
9
  "BODY_SIZE_LIMIT",
10
10
  "CSRF_ALLOWED_ORIGINS",
11
+ "INTERNAL_HOSTS",
11
12
  "CORS_ALLOWED_ORIGINS",
12
13
  "CORS_ALLOWED_METHODS",
13
14
  "CORS_ALLOWED_HEADERS",
@@ -60,6 +61,26 @@ function parseEnvFile(content: string, filename?: string): Record<string, string
60
61
  }
61
62
 
62
63
  let value = trimmed.slice(eqIdx + 1).trim();
64
+ // Strip inline comments before quote-unescaping.
65
+ // Quoted: find the real closing quote, verify trailing chars are whitespace+comment, truncate.
66
+ // Unquoted: strip \s+#... (no space before # keeps foo#bar intact).
67
+ if (value.startsWith('"')) {
68
+ let end = -1;
69
+ for (let j = 1; j < value.length; j++) {
70
+ if (value[j] === "\\") { j++; continue; } // skip escaped char
71
+ if (value[j] === '"') { end = j; break; }
72
+ }
73
+ if (end !== -1 && /^\s*(#.*)?$/.test(value.slice(end + 1))) {
74
+ value = value.slice(0, end + 1);
75
+ }
76
+ } else if (value.startsWith("'")) {
77
+ const end = value.indexOf("'", 1);
78
+ if (end !== -1 && /^\s*(#.*)?$/.test(value.slice(end + 1))) {
79
+ value = value.slice(0, end + 1);
80
+ }
81
+ } else {
82
+ value = value.startsWith("#") ? "" : value.replace(/\s+#.*$/, "");
83
+ }
63
84
  // Double-quoted: process escape sequences
64
85
  if (value.startsWith('"') && value.endsWith('"')) {
65
86
  value = processEscapes(value.slice(1, -1));
package/src/core/html.ts CHANGED
@@ -55,6 +55,13 @@ function getPublicDynamicEnv(): Record<string, string> {
55
55
  return result;
56
56
  }
57
57
 
58
+ // ─── Lang Validation ──────────────────────────────────────
59
+
60
+ const LANG_RE = /^[a-zA-Z0-9-]{1,35}$/;
61
+ function safeLang(lang?: string): string {
62
+ return lang && LANG_RE.test(lang) ? lang : "en";
63
+ }
64
+
58
65
  // ─── HTML Builder ─────────────────────────────────────────
59
66
 
60
67
  export function buildHtml(
@@ -65,6 +72,7 @@ export function buildHtml(
65
72
  csr = true,
66
73
  formData: any = null,
67
74
  lang?: string,
75
+ ssr = true,
68
76
  ): string {
69
77
  const cssLinks = (distManifest.css ?? [])
70
78
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
@@ -80,15 +88,16 @@ export function buildHtml(
80
88
  const formScript = formData != null
81
89
  ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};`
82
90
  : "";
91
+ const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
83
92
 
84
93
  const scripts = csr
85
- ? `${envScript}\n <script>window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
94
+ ? `${envScript}\n <script>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
86
95
  : isDev
87
96
  ? `\n <script>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
88
97
  : "";
89
98
 
90
99
  return `<!DOCTYPE html>
91
- <html lang="${lang || "en"}">
100
+ <html lang="${safeLang(lang)}">
92
101
  <head>
93
102
  <meta charset="UTF-8">
94
103
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -113,7 +122,7 @@ const _shellOpenCache = new Map<string, string>();
113
122
 
114
123
  /** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
115
124
  export function buildHtmlShellOpen(lang?: string): string {
116
- const key = lang || "en";
125
+ const key = safeLang(lang);
117
126
  const cached = _shellOpenCache.get(key);
118
127
  if (cached) return cached;
119
128
  const cssLinks = (distManifest.css ?? [])
@@ -181,6 +190,7 @@ export function buildHtmlTail(
181
190
  layoutData: any[],
182
191
  csr: boolean,
183
192
  formData: any = null,
193
+ ssr = true,
184
194
  ): string {
185
195
  let out = `<script>document.getElementById('__bs__').remove()</script>`;
186
196
  out += `\n<div id="app">${body}</div>`;
@@ -191,7 +201,8 @@ export function buildHtmlTail(
191
201
  out += `\n<script>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
192
202
  }
193
203
  const formInject = formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
194
- out += `\n<script>window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
204
+ const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
205
+ out += `\n<script>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
195
206
  `window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formInject}</script>`;
196
207
  out += `\n<script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
197
208
  } else if (isDev) {
@@ -203,15 +214,18 @@ export function buildHtmlTail(
203
214
 
204
215
  // ─── Gzip Compression ────────────────────────────────────
205
216
 
217
+ const GZIP_MIN_BYTES = 2048;
218
+
206
219
  export function compress(body: string, contentType: string, req: Request, status = 200, extraHeaders?: Record<string, string>): Response {
207
220
  const headers: Record<string, string> = { "Content-Type": contentType, "Vary": "Accept-Encoding", ...extraHeaders };
208
221
  const accept = req.headers.get("accept-encoding") ?? "";
222
+ const bytes = new TextEncoder().encode(body);
209
223
  // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
210
224
  // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
211
- if (!isDev && body.length > 1024 && accept.includes("gzip")) {
212
- return new Response(Bun.gzipSync(body), { status, headers: { ...headers, "Content-Encoding": "gzip" } });
225
+ if (!isDev && bytes.length > GZIP_MIN_BYTES && accept.includes("gzip")) {
226
+ return new Response(Bun.gzipSync(bytes), { status, headers: { ...headers, "Content-Encoding": "gzip" } });
213
227
  }
214
- return new Response(body, { status, headers });
228
+ return new Response(bytes, { status, headers });
215
229
  }
216
230
 
217
231
  // ─── Static File Detection ────────────────────────────────
@@ -17,6 +17,10 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<string[]>
17
17
  const filePath = join("src", "routes", route.pageServer);
18
18
  const content = await Bun.file(filePath).text();
19
19
  if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) continue;
20
+ if (/export\s+const\s+ssr\s*=\s*false/.test(content)) {
21
+ console.warn(` ⚠️ ${route.pattern} has prerender=true && ssr=false — contradictory, skipped`);
22
+ continue;
23
+ }
20
24
 
21
25
  if (route.pattern.includes("[")) {
22
26
  // Dynamic route — import module and call entries() to get param values
@@ -37,22 +37,59 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
37
37
  ]);
38
38
  }
39
39
 
40
+ // ─── Internal-Host Allowlist ─────────────────────────────
41
+ // Origins that share the user's session cookie. Cookie is auto-forwarded
42
+ // to same-origin requests AND to any origin in this set. Anything else
43
+ // (third-party APIs) gets no Cookie header by default.
44
+
45
+ const INTERNAL_HOSTS: Set<string> = (() => {
46
+ const raw = process.env.INTERNAL_HOSTS?.split(",").map(s => s.trim()).filter(Boolean) ?? [];
47
+ const valid = new Set<string>();
48
+ for (const entry of raw) {
49
+ try {
50
+ valid.add(new URL(entry).origin);
51
+ } catch {
52
+ console.warn(`⚠️ INTERNAL_HOSTS: ignoring invalid origin "${entry}"`);
53
+ }
54
+ }
55
+ return valid;
56
+ })();
57
+
58
+ if (INTERNAL_HOSTS.size > 0) {
59
+ console.log(`🍪 Internal hosts (cookies forwarded): ${[...INTERNAL_HOSTS].join(", ")}`);
60
+ }
61
+
40
62
  // ─── Session-Aware Fetch ─────────────────────────────────
41
- // Passed to load() functions so they can call internal APIs
42
- // with the current user's cookies automatically forwarded.
63
+ // Passed to load() functions so they can call internal APIs with the
64
+ // current user's cookies automatically forwarded. Cookie is attached
65
+ // only on same-origin requests or to origins in INTERNAL_HOSTS — never
66
+ // to arbitrary third-party hosts (which would leak the session token).
43
67
 
44
68
  function makeFetch(req: Request, url: URL) {
45
69
  const cookie = req.headers.get("cookie") ?? "";
46
- const origin = url.origin;
70
+ const sameOrigin = url.origin;
47
71
 
48
72
  return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
49
- const resolved =
50
- typeof input === "string" && input.startsWith("/")
51
- ? `${origin}${input}`
52
- : input;
73
+ let targetOrigin: string | null = null;
74
+ let resolved: RequestInfo | URL = input;
75
+
76
+ try {
77
+ if (typeof input === "string") {
78
+ const parsed = new URL(input, sameOrigin);
79
+ targetOrigin = parsed.origin;
80
+ resolved = parsed.href;
81
+ } else if (input instanceof URL) {
82
+ targetOrigin = input.origin;
83
+ } else {
84
+ targetOrigin = new URL(input.url).origin;
85
+ }
86
+ } catch {
87
+ targetOrigin = null;
88
+ }
53
89
 
54
90
  const headers = new Headers(init?.headers);
55
- if (cookie && !headers.has("cookie")) headers.set("cookie", cookie);
91
+ const trusted = targetOrigin !== null && (targetOrigin === sameOrigin || INTERNAL_HOSTS.has(targetOrigin));
92
+ if (cookie && trusted && !headers.has("cookie")) headers.set("cookie", cookie);
56
93
 
57
94
  return globalThis.fetch(resolved, { ...init, headers });
58
95
  };
@@ -99,10 +136,12 @@ export async function loadRouteData(
99
136
  // Run page server loader
100
137
  let pageData: Record<string, any> = {};
101
138
  let csr = true;
139
+ let ssr = true;
102
140
  if (route.pageServer) {
103
141
  try {
104
142
  const mod = await route.pageServer();
105
143
  if (mod.csr === false) csr = false;
144
+ if (mod.ssr === false) ssr = false;
106
145
  if (typeof mod.load === "function") {
107
146
  const parent = async () => {
108
147
  const merged: Record<string, any> = {};
@@ -119,7 +158,7 @@ export async function loadRouteData(
119
158
  }
120
159
  }
121
160
 
122
- return { pageData: { ...pageData, params }, layoutData, csr };
161
+ return { pageData: { ...pageData, params }, layoutData, csr, ssr };
123
162
  }
124
163
 
125
164
  // ─── Metadata Loader ─────────────────────────────────────
@@ -210,6 +249,17 @@ export async function renderSSRStream(
210
249
  controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
211
250
 
212
251
  try {
252
+ if (!data!.ssr) {
253
+ // ssr=false → skip render(); ship empty shell + hydration scripts.
254
+ // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
255
+ if (!data!.csr && isDev) {
256
+ console.warn(`⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`);
257
+ }
258
+ controller.enqueue(enc.encode(buildHtmlTail("", "", data!.pageData, data!.layoutData, true, null, false)));
259
+ controller.close();
260
+ return;
261
+ }
262
+
213
263
  const { body, head } = render(App, {
214
264
  props: {
215
265
  ssrMode: true,
@@ -264,6 +314,14 @@ export async function renderPageWithFormData(
264
314
 
265
315
  if (!data) return renderErrorPage(404, "Not Found", url, req);
266
316
 
317
+ if (!data.ssr) {
318
+ if (!data.csr && isDev) {
319
+ console.warn(`⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`);
320
+ }
321
+ const html = buildHtml("", "", data.pageData, data.layoutData, true, formData, undefined, false);
322
+ return compress(html, "text/html; charset=utf-8", req, status);
323
+ }
324
+
267
325
  const { body, head } = render(App, {
268
326
  props: {
269
327
  ssrMode: true,
@@ -72,7 +72,7 @@ const CORS_CONFIG: CorsConfig | null = _corsAllowedOrigins?.length
72
72
  allowedHeaders: splitCsvEnv("CORS_ALLOWED_HEADERS"),
73
73
  exposedHeaders: splitCsvEnv("CORS_EXPOSED_HEADERS"),
74
74
  credentials: process.env.CORS_CREDENTIALS === "true" || undefined,
75
- maxAge: process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : undefined,
75
+ maxAge: parseCorsMaxAge(process.env.CORS_MAX_AGE),
76
76
  }
77
77
  : null;
78
78
 
@@ -251,12 +251,21 @@ async function resolve(event: RequestEvent): Promise<Response> {
251
251
  if (method === "POST") {
252
252
  const pageMatch = findMatch(serverRoutes, path);
253
253
  if (pageMatch?.route.pageServer) {
254
+ // `use:enhance` sets this header — return JSON instead of re-rendering HTML
255
+ const isEnhanced = request.headers.get("x-bosia-action") === "1";
256
+
254
257
  try {
255
258
  const mod = await pageMatch.route.pageServer();
256
259
  if (mod.actions && typeof mod.actions === "object") {
257
260
  const actionName = parseActionName(url);
258
261
  const action = mod.actions[actionName];
259
262
  if (!action) {
263
+ if (isEnhanced) {
264
+ return Response.json(
265
+ { type: "error", status: 404, message: `Action "${actionName}" not found` },
266
+ { status: 404 },
267
+ );
268
+ }
260
269
  return renderErrorPage(404, `Action "${actionName}" not found`, url, request);
261
270
  }
262
271
 
@@ -266,12 +275,21 @@ async function resolve(event: RequestEvent): Promise<Response> {
266
275
  result = await action(event);
267
276
  } catch (err) {
268
277
  if (err instanceof Redirect) {
278
+ if (isEnhanced) {
279
+ return Response.json({ type: "redirect", status: 303, location: err.location });
280
+ }
269
281
  return new Response(null, {
270
282
  status: 303,
271
283
  headers: { Location: err.location },
272
284
  });
273
285
  }
274
286
  if (err instanceof HttpError) {
287
+ if (isEnhanced) {
288
+ return Response.json(
289
+ { type: "error", status: err.status, message: err.message },
290
+ { status: err.status },
291
+ );
292
+ }
275
293
  return renderErrorPage(err.status, err.message, url, request);
276
294
  }
277
295
  throw err;
@@ -279,6 +297,9 @@ async function resolve(event: RequestEvent): Promise<Response> {
279
297
 
280
298
  // Redirect returned (not thrown)
281
299
  if (result instanceof Redirect) {
300
+ if (isEnhanced) {
301
+ return Response.json({ type: "redirect", status: 303, location: result.location });
302
+ }
282
303
  return new Response(null, {
283
304
  status: 303,
284
305
  headers: { Location: result.location },
@@ -287,24 +308,48 @@ async function resolve(event: RequestEvent): Promise<Response> {
287
308
 
288
309
  // ActionFailure — re-render with failure status
289
310
  if (result instanceof ActionFailure) {
311
+ if (isEnhanced) {
312
+ return Response.json(
313
+ { type: "failure", status: result.status, data: result.data },
314
+ { status: result.status },
315
+ );
316
+ }
290
317
  return renderPageWithFormData(url, locals, request, cookies, result.data, result.status);
291
318
  }
292
319
 
293
320
  // Success — re-render page with action return data
321
+ if (isEnhanced) {
322
+ return Response.json({ type: "success", status: 200, data: result ?? null });
323
+ }
294
324
  return renderPageWithFormData(url, locals, request, cookies, result ?? null, 200);
295
325
  }
296
326
  } catch (err) {
297
327
  if (err instanceof Redirect) {
328
+ if (isEnhanced) {
329
+ return Response.json({ type: "redirect", status: 303, location: err.location });
330
+ }
298
331
  return new Response(null, {
299
332
  status: 303,
300
333
  headers: { Location: err.location },
301
334
  });
302
335
  }
303
336
  if (err instanceof HttpError) {
337
+ if (isEnhanced) {
338
+ return Response.json(
339
+ { type: "error", status: err.status, message: err.message },
340
+ { status: err.status },
341
+ );
342
+ }
304
343
  return renderErrorPage(err.status, err.message, url, request);
305
344
  }
306
345
  if (isDev) console.error("Form action error:", err);
307
346
  else console.error("Form action error:", (err as Error).message ?? err);
347
+ if (isEnhanced) {
348
+ return Response.json(
349
+ { type: "error", status: 500, message: "Internal Server Error" },
350
+ { status: 500 },
351
+ );
352
+ }
308
353
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
309
354
  }
310
355
  }
@@ -377,6 +422,20 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
377
422
  }
378
423
  }
379
424
 
425
+ // ─── CORS Max Age ─────────────────────────────────────────
426
+
427
+ function parseCorsMaxAge(value?: string): number | undefined {
428
+ if (!value) return undefined;
429
+ if (!/^\d+$/.test(value)) {
430
+ throw new Error(`Invalid CORS_MAX_AGE: "${value}" — must be a non-negative integer (seconds)`);
431
+ }
432
+ const n = parseInt(value, 10);
433
+ if (!Number.isFinite(n) || n > Number.MAX_SAFE_INTEGER) {
434
+ throw new Error(`Invalid CORS_MAX_AGE: "${value}" — must be a non-negative integer (seconds)`);
435
+ }
436
+ return n;
437
+ }
438
+
380
439
  // ─── Body Size Limit ──────────────────────────────────────
381
440
  // Parsed once at startup from BODY_SIZE_LIMIT env var.
382
441
  // Format: "512K", "1M", "1G", plain bytes, or "Infinity".
@@ -0,0 +1,12 @@
1
+ // ─── Bosia Client API ─────────────────────────────────────
2
+ // Client-only helpers — import from "bosia/client".
3
+ // Kept separate from "bosia" because these modules transitively
4
+ // reference the bundler-virtual `bosia:routes` module and runtime
5
+ // `window`, which break server-side imports (e.g. `+page.server.ts`
6
+ // loaded directly by the prerenderer).
7
+ //
8
+ // Usage in user apps:
9
+ // import { enhance } from "bosia/client";
10
+
11
+ export { enhance } from "../core/client/enhance.ts";
12
+ export type { SubmitFunction, ActionResult } from "../core/client/enhance.ts";