bosia 0.2.2 → 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 +3 -2
- package/src/cli/feat.ts +174 -23
- package/src/core/client/App.svelte +12 -9
- package/src/core/client/appState.svelte.ts +55 -0
- package/src/core/client/enhance.ts +107 -0
- package/src/core/client/hydrate.ts +30 -12
- package/src/core/env.ts +20 -0
- package/src/core/html.ts +21 -7
- package/src/core/prerender.ts +4 -0
- package/src/core/renderer.ts +22 -1
- package/src/core/server.ts +60 -1
- package/src/lib/client.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.2.
|
|
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:
|
|
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
|
-
//
|
|
98
|
+
// Apply each file entry per its strategy
|
|
86
99
|
const createdDirs = new Set<string>();
|
|
87
|
-
for (
|
|
88
|
-
const
|
|
89
|
-
const
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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();
|
package/src/core/env.ts
CHANGED
|
@@ -61,6 +61,26 @@ function parseEnvFile(content: string, filename?: string): Record<string, string
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
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
|
+
}
|
|
64
84
|
// Double-quoted: process escape sequences
|
|
65
85
|
if (value.startsWith('"') && value.endsWith('"')) {
|
|
66
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
212
|
-
return new Response(Bun.gzipSync(
|
|
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(
|
|
228
|
+
return new Response(bytes, { status, headers });
|
|
215
229
|
}
|
|
216
230
|
|
|
217
231
|
// ─── Static File Detection ────────────────────────────────
|
package/src/core/prerender.ts
CHANGED
|
@@ -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
|
package/src/core/renderer.ts
CHANGED
|
@@ -136,10 +136,12 @@ export async function loadRouteData(
|
|
|
136
136
|
// Run page server loader
|
|
137
137
|
let pageData: Record<string, any> = {};
|
|
138
138
|
let csr = true;
|
|
139
|
+
let ssr = true;
|
|
139
140
|
if (route.pageServer) {
|
|
140
141
|
try {
|
|
141
142
|
const mod = await route.pageServer();
|
|
142
143
|
if (mod.csr === false) csr = false;
|
|
144
|
+
if (mod.ssr === false) ssr = false;
|
|
143
145
|
if (typeof mod.load === "function") {
|
|
144
146
|
const parent = async () => {
|
|
145
147
|
const merged: Record<string, any> = {};
|
|
@@ -156,7 +158,7 @@ export async function loadRouteData(
|
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
return { pageData: { ...pageData, params }, layoutData, csr };
|
|
161
|
+
return { pageData: { ...pageData, params }, layoutData, csr, ssr };
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
// ─── Metadata Loader ─────────────────────────────────────
|
|
@@ -247,6 +249,17 @@ export async function renderSSRStream(
|
|
|
247
249
|
controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
|
|
248
250
|
|
|
249
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
|
+
|
|
250
263
|
const { body, head } = render(App, {
|
|
251
264
|
props: {
|
|
252
265
|
ssrMode: true,
|
|
@@ -301,6 +314,14 @@ export async function renderPageWithFormData(
|
|
|
301
314
|
|
|
302
315
|
if (!data) return renderErrorPage(404, "Not Found", url, req);
|
|
303
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
|
+
|
|
304
325
|
const { body, head } = render(App, {
|
|
305
326
|
props: {
|
|
306
327
|
ssrMode: true,
|
package/src/core/server.ts
CHANGED
|
@@ -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:
|
|
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";
|