bosia 0.3.1 → 0.3.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 +1 -1
- package/src/ambient.d.ts +1 -0
- package/src/cli/add.ts +4 -4
- package/src/cli/create.ts +4 -2
- package/src/cli/feat.ts +3 -3
- package/src/cli/index.ts +17 -17
- package/src/core/dedup.ts +16 -19
- package/src/core/dev.ts +33 -13
- package/src/core/plugin.ts +11 -3
- package/src/core/prerender.ts +100 -65
- package/src/core/renderer.ts +62 -54
- package/src/core/routeFile.ts +2 -0
- package/src/core/routeTypes.ts +11 -5
- package/src/core/scanner.ts +7 -1
- package/src/core/server.ts +15 -9
- package/src/core/types.ts +6 -0
- package/templates/default/src/routes/+error.svelte +2 -1
- package/templates/demo/src/routes/(public)/+page.svelte +1 -1
- package/templates/demo/src/routes/(public)/about/+page.svelte +1 -1
- package/templates/demo/src/routes/+error.svelte +2 -1
- package/templates/todo/src/routes/+error.svelte +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
|
|
6
6
|
"keywords": [
|
package/src/ambient.d.ts
CHANGED
package/src/cli/add.ts
CHANGED
|
@@ -11,13 +11,13 @@ import {
|
|
|
11
11
|
bunAdd,
|
|
12
12
|
} from "./registry.ts";
|
|
13
13
|
|
|
14
|
-
// ─── bosia add <component>
|
|
14
|
+
// ─── bun x bosia@latest add <component> ──────────────────
|
|
15
15
|
// Fetches a component from the GitHub registry (or local registry
|
|
16
16
|
// with --local) and copies it into src/lib/components/<path>/.
|
|
17
17
|
//
|
|
18
18
|
// Path-based names:
|
|
19
|
-
// bosia add button → src/lib/components/ui/button/
|
|
20
|
-
// bosia add shop/cart → src/lib/components/shop/cart/
|
|
19
|
+
// bun x bosia@latest add button → src/lib/components/ui/button/
|
|
20
|
+
// bun x bosia@latest add shop/cart → src/lib/components/shop/cart/
|
|
21
21
|
|
|
22
22
|
interface ComponentMeta {
|
|
23
23
|
name: string;
|
|
@@ -48,7 +48,7 @@ export async function initAddRegistry(root: string | null) {
|
|
|
48
48
|
export async function runAdd(name: string | undefined, flags: string[] = []) {
|
|
49
49
|
if (!name) {
|
|
50
50
|
console.error(
|
|
51
|
-
"❌ Please provide a component name.\n Usage: bosia add <component> [--local]",
|
|
51
|
+
"❌ Please provide a component name.\n Usage: bun x bosia@latest add <component> [--local]",
|
|
52
52
|
);
|
|
53
53
|
process.exit(1);
|
|
54
54
|
}
|
package/src/cli/create.ts
CHANGED
|
@@ -5,7 +5,7 @@ import * as p from "@clack/prompts";
|
|
|
5
5
|
import { installFeature, initFeatRegistry, resolveLocalRegistry } from "./feat.ts";
|
|
6
6
|
import { initAddRegistry } from "./add.ts";
|
|
7
7
|
|
|
8
|
-
// ─── bosia create <name> [--template <name>]
|
|
8
|
+
// ─── bun x bosia@latest create <name> [--template <name>] ─
|
|
9
9
|
|
|
10
10
|
const TEMPLATES_DIR = resolve(import.meta.dir, "../../templates");
|
|
11
11
|
const BOSIA_PKG = JSON.parse(readFileSync(resolve(import.meta.dir, "../../package.json"), "utf-8"));
|
|
@@ -19,7 +19,9 @@ const TEMPLATE_DESCRIPTIONS: Record<string, string> = {
|
|
|
19
19
|
|
|
20
20
|
export async function runCreate(name: string | undefined, args: string[] = []) {
|
|
21
21
|
if (!name) {
|
|
22
|
-
console.error(
|
|
22
|
+
console.error(
|
|
23
|
+
"❌ Please provide a project name.\n Usage: bun x bosia@latest create my-app",
|
|
24
|
+
);
|
|
23
25
|
process.exit(1);
|
|
24
26
|
}
|
|
25
27
|
|
package/src/cli/feat.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
bunAdd,
|
|
12
12
|
} from "./registry.ts";
|
|
13
13
|
|
|
14
|
-
// ─── bosia feat <feature> [--local]
|
|
14
|
+
// ─── bun x bosia@latest feat <feature> [--local] ─────────
|
|
15
15
|
// Fetches a feature scaffold from the GitHub registry (or local
|
|
16
16
|
// registry with --local) and copies route/lib files, installs npm deps.
|
|
17
17
|
// Supports nested feature dependencies (e.g. todo → drizzle).
|
|
@@ -34,7 +34,7 @@ interface FeatureMeta {
|
|
|
34
34
|
name: string;
|
|
35
35
|
description: string;
|
|
36
36
|
features?: string[]; // other bosia features required
|
|
37
|
-
components: string[]; // bosia components to install via `bosia add`
|
|
37
|
+
components: string[]; // bosia components to install via `bun x bosia@latest add`
|
|
38
38
|
files: FileEntry[]; // file entries with per-file strategy
|
|
39
39
|
npmDeps: Record<string, string>;
|
|
40
40
|
npmDevDeps?: Record<string, string>;
|
|
@@ -50,7 +50,7 @@ const installedFeats = new Set<string>();
|
|
|
50
50
|
export async function runFeat(name: string | undefined, flags: string[] = []) {
|
|
51
51
|
if (!name) {
|
|
52
52
|
console.error(
|
|
53
|
-
"❌ Please provide a feature name.\n Usage: bosia feat <feature> [--local]",
|
|
53
|
+
"❌ Please provide a feature name.\n Usage: bun x bosia@latest feat <feature> [--local]",
|
|
54
54
|
);
|
|
55
55
|
process.exit(1);
|
|
56
56
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// ─── Bosia CLI ────────────────────────────────────────────
|
|
3
|
-
// bosia create <name> scaffold a new project
|
|
4
|
-
// bosia dev
|
|
5
|
-
// bosia build
|
|
6
|
-
// bosia start
|
|
7
|
-
// bosia add <name>
|
|
8
|
-
// bosia feat <name>
|
|
3
|
+
// bun x bosia@latest create <name> scaffold a new project
|
|
4
|
+
// bun x bosia dev start the development server
|
|
5
|
+
// bun x bosia build build for production
|
|
6
|
+
// bun x bosia start run the production server
|
|
7
|
+
// bun x bosia@latest add <name> add a UI component from the registry
|
|
8
|
+
// bun x bosia@latest feat <name> add a feature scaffold from the registry
|
|
9
9
|
|
|
10
10
|
const [, , command, ...args] = process.argv;
|
|
11
11
|
|
|
@@ -67,17 +67,17 @@ Commands:
|
|
|
67
67
|
feat <feature> Add a feature scaffold from the registry [--local]
|
|
68
68
|
|
|
69
69
|
Examples:
|
|
70
|
-
bosia create my-app
|
|
71
|
-
bosia create my-app --template todo
|
|
72
|
-
bosia dev
|
|
73
|
-
bosia build
|
|
74
|
-
bosia start
|
|
75
|
-
bosia test
|
|
76
|
-
bosia test --watch
|
|
77
|
-
bosia test --coverage
|
|
78
|
-
bosia add button → src/lib/components/ui/button/
|
|
79
|
-
bosia add shop/cart → src/lib/components/shop/cart/
|
|
80
|
-
bosia feat login
|
|
70
|
+
bun x bosia@latest create my-app
|
|
71
|
+
bun x bosia@latest create my-app --template todo
|
|
72
|
+
bun x bosia dev
|
|
73
|
+
bun x bosia build
|
|
74
|
+
bun x bosia start
|
|
75
|
+
bun x bosia test
|
|
76
|
+
bun x bosia test --watch
|
|
77
|
+
bun x bosia test --coverage
|
|
78
|
+
bun x bosia@latest add button → src/lib/components/ui/button/
|
|
79
|
+
bun x bosia@latest add shop/cart → src/lib/components/shop/cart/
|
|
80
|
+
bun x bosia@latest feat login
|
|
81
81
|
`);
|
|
82
82
|
break;
|
|
83
83
|
}
|
package/src/core/dedup.ts
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// ─── Request Deduplication ──────────────────────────────
|
|
2
|
+
// Concurrent in-flight coalescing for public routes. When N parallel requests
|
|
3
|
+
// hit the same URL, run the loader once and share the resolved value across
|
|
4
|
+
// all N waiters. Settled responses are NOT cached — once the promise resolves,
|
|
5
|
+
// the entry is dropped, so the next request runs the loader again.
|
|
6
|
+
//
|
|
7
|
+
// Scope decision lives in the scanner: routes under a `(private)` group skip
|
|
8
|
+
// dedup entirely. Per-user routes MUST be private — sharing a loader result
|
|
9
|
+
// across users would leak data. See docs/guides/request-deduplication.md.
|
|
3
10
|
|
|
4
|
-
const inflight = new Map<string, Promise<
|
|
11
|
+
const inflight = new Map<string, Promise<unknown>>();
|
|
5
12
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
/** Build dedup key from route URL + request identity. Sort search params for consistency. */
|
|
9
|
-
export function dedupKey(url: URL, request: Request): string {
|
|
13
|
+
/** Build a stable dedup key from a URL: normalized path + sorted query. */
|
|
14
|
+
export function dedupKey(url: URL): string {
|
|
10
15
|
let path = url.pathname;
|
|
11
16
|
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
12
17
|
const sorted = new URLSearchParams([...url.searchParams.entries()].sort());
|
|
13
18
|
const search = sorted.toString();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const authHeader = request.headers.get("authorization") ?? "";
|
|
17
|
-
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
18
|
-
const match = cookieHeader.match(AUTH_COOKIE_RE);
|
|
19
|
-
const authCookie = match?.[1] ?? "";
|
|
20
|
-
const identity = authHeader || authCookie;
|
|
21
|
-
if (!identity) return base;
|
|
22
|
-
return `${base}|${Bun.hash(identity).toString(36)}`;
|
|
19
|
+
return search ? `${path}?${search}` : path;
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
/**
|
|
22
|
+
/** Coalesce concurrent calls under `key`. `fn` runs at most once per inflight window. */
|
|
26
23
|
export function dedup<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
27
24
|
const existing = inflight.get(key);
|
|
28
|
-
if (existing) return existing
|
|
25
|
+
if (existing) return existing as Promise<T>;
|
|
29
26
|
const promise = fn().finally(() => inflight.delete(key));
|
|
30
27
|
inflight.set(key, promise);
|
|
31
|
-
return promise
|
|
28
|
+
return promise as Promise<T>;
|
|
32
29
|
}
|
package/src/core/dev.ts
CHANGED
|
@@ -137,22 +137,21 @@ async function buildAndRestart() {
|
|
|
137
137
|
}
|
|
138
138
|
building = true;
|
|
139
139
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
140
|
+
do {
|
|
141
|
+
buildPending = false;
|
|
142
|
+
const ok = await runBuild();
|
|
143
|
+
if (!ok) {
|
|
144
|
+
console.error("❌ Build failed — fix errors and save again");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await startAppServer();
|
|
148
|
+
// Give the app server a moment to bind its port
|
|
149
|
+
await Bun.sleep(200);
|
|
150
|
+
broadcastReload();
|
|
151
|
+
} while (buildPending);
|
|
149
152
|
} finally {
|
|
150
153
|
building = false;
|
|
151
154
|
}
|
|
152
|
-
if (buildPending) {
|
|
153
|
-
buildPending = false;
|
|
154
|
-
buildAndRestart();
|
|
155
|
-
}
|
|
156
155
|
}
|
|
157
156
|
|
|
158
157
|
function scheduleBuild() {
|
|
@@ -266,3 +265,24 @@ watch(process.cwd(), { recursive: false }, (_event, filename) => {
|
|
|
266
265
|
});
|
|
267
266
|
|
|
268
267
|
console.log("👀 Watching src/ for changes...\n");
|
|
268
|
+
|
|
269
|
+
// ─── Shutdown ─────────────────────────────────────────────
|
|
270
|
+
// Own SIGINT/SIGTERM so we can cleanly stop the child app server.
|
|
271
|
+
// Without this, the terminal's ^C reaches both processes; the parent
|
|
272
|
+
// exits instantly while the child blocks in `app.stop()`, requiring
|
|
273
|
+
// a second ^C.
|
|
274
|
+
|
|
275
|
+
let shuttingDown = false;
|
|
276
|
+
async function shutdown() {
|
|
277
|
+
if (shuttingDown) process.exit(130);
|
|
278
|
+
shuttingDown = true;
|
|
279
|
+
intentionalKill = true;
|
|
280
|
+
if (appProcess) {
|
|
281
|
+
appProcess.kill("SIGTERM");
|
|
282
|
+
await Promise.race([appProcess.exited, Bun.sleep(2_500)]);
|
|
283
|
+
}
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
process.on("SIGINT", shutdown);
|
|
288
|
+
process.on("SIGTERM", shutdown);
|
package/src/core/plugin.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join, dirname } from "path";
|
|
2
|
+
import { existsSync } from "fs";
|
|
2
3
|
|
|
3
4
|
// ─── Bun Build Plugin ─────────────────────────────────────
|
|
4
5
|
// Resolves:
|
|
@@ -9,11 +10,18 @@ import { join, dirname } from "path";
|
|
|
9
10
|
let cachedTsconfigPaths: Record<string, string[]> | null = null;
|
|
10
11
|
async function getTsconfigPaths() {
|
|
11
12
|
if (cachedTsconfigPaths !== null) return cachedTsconfigPaths;
|
|
13
|
+
const tsconfigPath = join(process.cwd(), "tsconfig.json");
|
|
14
|
+
if (!existsSync(tsconfigPath)) {
|
|
15
|
+
cachedTsconfigPaths = {};
|
|
16
|
+
return cachedTsconfigPaths;
|
|
17
|
+
}
|
|
12
18
|
try {
|
|
13
|
-
const tsconfig = await Bun.file(
|
|
19
|
+
const tsconfig = await Bun.file(tsconfigPath).json();
|
|
14
20
|
cachedTsconfigPaths = tsconfig?.compilerOptions?.paths || {};
|
|
15
|
-
} catch {
|
|
16
|
-
|
|
21
|
+
} catch (err) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`tsconfig.json at ${tsconfigPath} is invalid JSON: ${(err as Error).message}. Fix the file and re-run.`,
|
|
24
|
+
);
|
|
17
25
|
}
|
|
18
26
|
return cachedTsconfigPaths!;
|
|
19
27
|
}
|
package/src/core/prerender.ts
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, cpSync, existsSync } from "fs";
|
|
2
|
+
import { createServer } from "net";
|
|
2
3
|
import { join } from "path";
|
|
3
4
|
import type { RouteManifest, TrailingSlash } from "./types.ts";
|
|
4
5
|
|
|
5
6
|
import { BOSIA_NODE_PATH } from "./paths.ts";
|
|
6
7
|
|
|
8
|
+
/** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
|
|
9
|
+
export function getEphemeralPort(): Promise<number> {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const srv = createServer();
|
|
12
|
+
srv.unref();
|
|
13
|
+
srv.on("error", reject);
|
|
14
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
15
|
+
const addr = srv.address();
|
|
16
|
+
if (!addr || typeof addr === "string") {
|
|
17
|
+
srv.close();
|
|
18
|
+
reject(new Error("Failed to acquire ephemeral port"));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const port = addr.port;
|
|
22
|
+
srv.close(() => resolve(port));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
const CORE_DIR = import.meta.dir;
|
|
8
28
|
|
|
9
29
|
const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
|
|
@@ -104,7 +124,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
104
124
|
|
|
105
125
|
console.log(`\n🖨️ Prerendering ${targets.length} route(s)...`);
|
|
106
126
|
|
|
107
|
-
const port =
|
|
127
|
+
const port = await getEphemeralPort();
|
|
108
128
|
const child = Bun.spawn(["bun", "run", "./dist/server/index.js"], {
|
|
109
129
|
env: {
|
|
110
130
|
...process.env,
|
|
@@ -116,78 +136,93 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
116
136
|
stderr: "ignore",
|
|
117
137
|
});
|
|
118
138
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
139
|
+
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
|
140
|
+
let receivedSignal: NodeJS.Signals | null = null;
|
|
141
|
+
const onSignal = (sig: NodeJS.Signals) => {
|
|
142
|
+
receivedSignal = sig;
|
|
143
|
+
};
|
|
144
|
+
for (const sig of signals) process.once(sig, onSignal);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// Poll /_health until ready (max 10s)
|
|
148
|
+
const base = `http://localhost:${port}`;
|
|
149
|
+
let ready = false;
|
|
150
|
+
for (let i = 0; i < 50; i++) {
|
|
151
|
+
await Bun.sleep(200);
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${base}/_health`);
|
|
154
|
+
if (res.ok) {
|
|
155
|
+
ready = true;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
/* not ready yet */
|
|
129
160
|
}
|
|
130
|
-
} catch {
|
|
131
|
-
/* not ready yet */
|
|
132
161
|
}
|
|
133
|
-
}
|
|
134
162
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
if (!ready) {
|
|
164
|
+
console.error("❌ Prerender server failed to start");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
mkdirSync("./dist/prerendered", { recursive: true });
|
|
140
169
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const res = await fetch(`${base}${canonicalRoute}`, {
|
|
149
|
-
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
150
|
-
});
|
|
151
|
-
const html = await res.text();
|
|
152
|
-
|
|
153
|
-
// Filename strategy:
|
|
154
|
-
// never → about.html (canonical /about, served by static host as /about → about.html)
|
|
155
|
-
// always → about/index.html (canonical /about/, static host serves /about/ → about/index.html)
|
|
156
|
-
// ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
|
|
157
|
-
// root → index.html
|
|
158
|
-
const outPath = prerenderOutPath(routePath, ts);
|
|
159
|
-
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
160
|
-
writeFileSync(outPath, html);
|
|
161
|
-
|
|
162
|
-
// Also prerender the data payload (filename matches dataUrl() — strips trailing slash)
|
|
163
|
-
const dataPath = prerenderDataPath(routePath);
|
|
164
|
-
const dataRes = await fetch(`${base}/__bosia/data${dataPath}`, {
|
|
165
|
-
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
166
|
-
});
|
|
167
|
-
if (dataRes.ok) {
|
|
168
|
-
const dataJson = await dataRes.text();
|
|
169
|
-
const dataOutPath = `./dist/prerendered/__bosia/data${dataPath}`;
|
|
170
|
-
mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
|
|
171
|
-
recursive: true,
|
|
170
|
+
for (const { path: routePath, trailingSlash: ts } of targets) {
|
|
171
|
+
try {
|
|
172
|
+
// Hit the canonical URL so the server doesn't 308 us mid-prerender
|
|
173
|
+
const canonicalRoute = canonicalRouteFor(routePath, ts);
|
|
174
|
+
|
|
175
|
+
const res = await fetch(`${base}${canonicalRoute}`, {
|
|
176
|
+
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
172
177
|
});
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
|
|
178
|
+
const html = await res.text();
|
|
179
|
+
|
|
180
|
+
// Filename strategy:
|
|
181
|
+
// never → about.html (canonical /about, served by static host as /about → about.html)
|
|
182
|
+
// always → about/index.html (canonical /about/, static host serves /about/ → about/index.html)
|
|
183
|
+
// ignore → about/index.html (single emit; both URLs resolve via server canonicalize=off)
|
|
184
|
+
// root → index.html
|
|
185
|
+
const outPath = prerenderOutPath(routePath, ts);
|
|
186
|
+
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
187
|
+
writeFileSync(outPath, html);
|
|
188
|
+
|
|
189
|
+
// Also prerender the data payload (filename matches dataUrl() — strips trailing slash)
|
|
190
|
+
const dataPath = prerenderDataPath(routePath);
|
|
191
|
+
const dataRes = await fetch(`${base}/__bosia/data${dataPath}`, {
|
|
192
|
+
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
193
|
+
});
|
|
194
|
+
if (dataRes.ok) {
|
|
195
|
+
const dataJson = await dataRes.text();
|
|
196
|
+
const dataOutPath = `./dist/prerendered/__bosia/data${dataPath}`;
|
|
197
|
+
mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
|
|
198
|
+
recursive: true,
|
|
199
|
+
});
|
|
200
|
+
writeFileSync(dataOutPath, dataJson);
|
|
201
|
+
console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
|
|
202
|
+
} else {
|
|
203
|
+
console.log(` ✅ ${routePath} → ${outPath}`);
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
207
|
+
console.error(
|
|
208
|
+
` ❌ Prerender timed out for ${routePath} after ${PRERENDER_TIMEOUT / 1000}s — increase PRERENDER_TIMEOUT to raise the limit`,
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
console.error(` ❌ Failed to prerender ${routePath}:`, err);
|
|
212
|
+
}
|
|
185
213
|
}
|
|
186
214
|
}
|
|
187
|
-
}
|
|
188
215
|
|
|
189
|
-
|
|
190
|
-
|
|
216
|
+
console.log("✅ Prerendering complete");
|
|
217
|
+
} finally {
|
|
218
|
+
for (const sig of signals) process.off(sig, onSignal);
|
|
219
|
+
child.kill();
|
|
220
|
+
await child.exited;
|
|
221
|
+
if (receivedSignal) {
|
|
222
|
+
// Re-raise so parent exit code is 128+signum (standard Unix convention)
|
|
223
|
+
process.kill(process.pid, receivedSignal);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
191
226
|
}
|
|
192
227
|
|
|
193
228
|
// ─── Static Site Output ──────────────────────────────────
|
package/src/core/renderer.ts
CHANGED
|
@@ -276,65 +276,73 @@ export async function renderSSRStream(
|
|
|
276
276
|
|
|
277
277
|
const enc = new TextEncoder();
|
|
278
278
|
|
|
279
|
+
// ssr=false → no render() needed; ship shell + hydration as a single response.
|
|
280
|
+
// ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
|
|
281
|
+
if (!data.ssr) {
|
|
282
|
+
if (!data.csr && isDev) {
|
|
283
|
+
console.warn(
|
|
284
|
+
`⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const html =
|
|
288
|
+
buildHtmlShellOpen(metadata?.lang) +
|
|
289
|
+
buildMetadataChunk(metadata) +
|
|
290
|
+
buildHtmlTail("", "", data.pageData, data.layoutData, true, null, false);
|
|
291
|
+
return new Response(html, {
|
|
292
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Render-first: run render() before committing to a 200. Failure → proper error page
|
|
297
|
+
// with correct status code, instead of a bare <p> mixed into an already-flushed shell.
|
|
298
|
+
let body: string, head: string;
|
|
299
|
+
try {
|
|
300
|
+
({ body, head } = render(App, {
|
|
301
|
+
props: {
|
|
302
|
+
ssrMode: true,
|
|
303
|
+
ssrPageComponent: pageMod.default,
|
|
304
|
+
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
305
|
+
ssrPageData: data.pageData,
|
|
306
|
+
ssrLayoutData: data.layoutData,
|
|
307
|
+
},
|
|
308
|
+
}));
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (isDev) console.error("SSR render error:", err);
|
|
311
|
+
else console.error("SSR render error:", (err as Error).message ?? err);
|
|
312
|
+
return renderErrorPage(500, "Internal Server Error", url, req);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Pre-compute all chunks; pull-based stream gives Bun native backpressure.
|
|
316
|
+
const chunks: Uint8Array[] = [
|
|
317
|
+
enc.encode(buildHtmlShellOpen(metadata?.lang)),
|
|
318
|
+
enc.encode(buildMetadataChunk(metadata)),
|
|
319
|
+
enc.encode(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)),
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
let i = 0;
|
|
323
|
+
let cancelled = false;
|
|
324
|
+
const onAbort = () => {
|
|
325
|
+
cancelled = true;
|
|
326
|
+
};
|
|
327
|
+
req.signal.addEventListener("abort", onAbort, { once: true });
|
|
328
|
+
|
|
279
329
|
const stream = new ReadableStream<Uint8Array>({
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
controller.enqueue(enc.encode(buildHtmlShellOpen(metadata?.lang)));
|
|
283
|
-
|
|
284
|
-
// Chunk 2: metadata tags, close </head>, open <body> + spinner
|
|
285
|
-
controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
|
|
286
|
-
|
|
287
|
-
try {
|
|
288
|
-
if (!data!.ssr) {
|
|
289
|
-
// ssr=false → skip render(); ship empty shell + hydration scripts.
|
|
290
|
-
// ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
|
|
291
|
-
if (!data!.csr && isDev) {
|
|
292
|
-
console.warn(
|
|
293
|
-
`⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
controller.enqueue(
|
|
297
|
-
enc.encode(
|
|
298
|
-
buildHtmlTail(
|
|
299
|
-
"",
|
|
300
|
-
"",
|
|
301
|
-
data!.pageData,
|
|
302
|
-
data!.layoutData,
|
|
303
|
-
true,
|
|
304
|
-
null,
|
|
305
|
-
false,
|
|
306
|
-
),
|
|
307
|
-
),
|
|
308
|
-
);
|
|
309
|
-
controller.close();
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const { body, head } = render(App, {
|
|
314
|
-
props: {
|
|
315
|
-
ssrMode: true,
|
|
316
|
-
ssrPageComponent: pageMod.default,
|
|
317
|
-
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
318
|
-
ssrPageData: data!.pageData,
|
|
319
|
-
ssrLayoutData: data!.layoutData,
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// Chunk 3: rendered content
|
|
324
|
-
controller.enqueue(
|
|
325
|
-
enc.encode(
|
|
326
|
-
buildHtmlTail(body, head, data!.pageData, data!.layoutData, data!.csr),
|
|
327
|
-
),
|
|
328
|
-
);
|
|
330
|
+
pull(controller) {
|
|
331
|
+
if (cancelled || i >= chunks.length) {
|
|
329
332
|
controller.close();
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
333
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
controller.enqueue(chunks[i++]);
|
|
337
|
+
if (i >= chunks.length) {
|
|
335
338
|
controller.close();
|
|
339
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
336
340
|
}
|
|
337
341
|
},
|
|
342
|
+
cancel() {
|
|
343
|
+
cancelled = true;
|
|
344
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
345
|
+
},
|
|
338
346
|
});
|
|
339
347
|
|
|
340
348
|
return new Response(stream, {
|
package/src/core/routeFile.ts
CHANGED
|
@@ -63,6 +63,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
63
63
|
lines.push(" pageServer: (() => Promise<any>) | null;");
|
|
64
64
|
lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
|
|
65
65
|
lines.push(' trailingSlash: "never" | "always" | "ignore";');
|
|
66
|
+
lines.push(' scope: "public" | "private";');
|
|
66
67
|
lines.push("}> = [");
|
|
67
68
|
for (const r of pages) {
|
|
68
69
|
const layoutImports = r.layouts
|
|
@@ -83,6 +84,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
83
84
|
);
|
|
84
85
|
lines.push(` layoutServers: [${layoutServerImports}],`);
|
|
85
86
|
lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
|
|
87
|
+
lines.push(` scope: ${JSON.stringify(r.scope)},`);
|
|
86
88
|
lines.push(" },");
|
|
87
89
|
}
|
|
88
90
|
lines.push("];\n");
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -50,6 +50,8 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
if (manifest.errorPage && !dirs.has(".")) dirs.set(".", {});
|
|
54
|
+
|
|
53
55
|
for (const [dir, info] of dirs) {
|
|
54
56
|
// Path segments of the route dir (empty array for root ".")
|
|
55
57
|
const segments = dir === "." ? [] : dir.split("/").filter(Boolean);
|
|
@@ -96,6 +98,12 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
96
98
|
}
|
|
97
99
|
lines.push(`export type PageProps = { data: PageData };`);
|
|
98
100
|
|
|
101
|
+
if (dir === "." && manifest.errorPage) {
|
|
102
|
+
lines.push(``);
|
|
103
|
+
lines.push(`export type PageError = { status: number; message: string };`);
|
|
104
|
+
lines.push(`export type ErrorProps = { error: PageError };`);
|
|
105
|
+
}
|
|
106
|
+
|
|
99
107
|
// ActionData — union of all action return types, unwrapping ActionFailure
|
|
100
108
|
if (info.pageServer) {
|
|
101
109
|
lines.push(``);
|
|
@@ -144,12 +152,10 @@ export function ensureRootDirs(): void {
|
|
|
144
152
|
let tsconfig: any;
|
|
145
153
|
try {
|
|
146
154
|
tsconfig = JSON.parse(readFileSync(tsconfigPath, "utf-8"));
|
|
147
|
-
} catch {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
' "rootDirs": [".", ".bosia/types"]',
|
|
155
|
+
} catch (err) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`tsconfig.json at ${tsconfigPath} is invalid JSON: ${(err as Error).message}. Fix the file and re-run.`,
|
|
151
158
|
);
|
|
152
|
-
return;
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
const rootDirs: string[] = tsconfig.compilerOptions?.rootDirs ?? [];
|
package/src/core/scanner.ts
CHANGED
|
@@ -44,6 +44,7 @@ export function scanRoutes(): RouteManifest {
|
|
|
44
44
|
layoutChain: string[],
|
|
45
45
|
layoutServerChain: { path: string; depth: number }[],
|
|
46
46
|
inheritedTrailingSlash: TrailingSlash,
|
|
47
|
+
inheritedScope: "public" | "private",
|
|
47
48
|
) {
|
|
48
49
|
const fullDir = join(ROUTES_DIR, dir);
|
|
49
50
|
if (!existsSync(fullDir)) return;
|
|
@@ -94,6 +95,7 @@ export function scanRoutes(): RouteManifest {
|
|
|
94
95
|
pageServer: pageServerFile,
|
|
95
96
|
layoutServers: [...currentLayoutServers],
|
|
96
97
|
trailingSlash: effectiveTs,
|
|
98
|
+
scope: inheritedScope,
|
|
97
99
|
});
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -105,6 +107,9 @@ export function scanRoutes(): RouteManifest {
|
|
|
105
107
|
const dirName = entry.name;
|
|
106
108
|
// Route groups like (public), (auth) are invisible in URLs
|
|
107
109
|
const isGroup = /^\(.*\)$/.test(dirName);
|
|
110
|
+
// `(private)` anywhere in the chain marks descendants as per-user (dedup off)
|
|
111
|
+
const childScope: "public" | "private" =
|
|
112
|
+
inheritedScope === "private" || dirName === "(private)" ? "private" : "public";
|
|
108
113
|
|
|
109
114
|
walk(
|
|
110
115
|
dir ? join(dir, dirName) : dirName,
|
|
@@ -112,11 +117,12 @@ export function scanRoutes(): RouteManifest {
|
|
|
112
117
|
currentLayouts,
|
|
113
118
|
currentLayoutServers,
|
|
114
119
|
currentTrailingSlash,
|
|
120
|
+
childScope,
|
|
115
121
|
);
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
walk("", [], [], [], "never");
|
|
125
|
+
walk("", [], [], [], "never", "public");
|
|
120
126
|
|
|
121
127
|
// Warn when a catch-all exists but no exact route covers its prefix.
|
|
122
128
|
// e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
|
package/src/core/server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { CsrfConfig } from "./csrf.ts";
|
|
|
17
17
|
import { getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
18
18
|
import type { CorsConfig } from "./cors.ts";
|
|
19
19
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
20
|
+
import { dedup, dedupKey } from "./dedup.ts";
|
|
20
21
|
import {
|
|
21
22
|
loadRouteData,
|
|
22
23
|
loadMetadata,
|
|
@@ -25,7 +26,6 @@ import {
|
|
|
25
26
|
renderPageWithFormData,
|
|
26
27
|
} from "./renderer.ts";
|
|
27
28
|
import { getServerTime } from "../lib/utils.ts";
|
|
28
|
-
import { dedup, dedupKey } from "./dedup.ts";
|
|
29
29
|
|
|
30
30
|
// ─── User Hooks ──────────────────────────────────────────
|
|
31
31
|
// Load src/hooks.server.ts if present. Uses process.cwd() so
|
|
@@ -148,10 +148,9 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
148
148
|
}
|
|
149
149
|
// Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
|
|
150
150
|
event.url = routeUrl;
|
|
151
|
-
const dedupKeyStr = dedupKey(routeUrl, request);
|
|
152
151
|
try {
|
|
153
|
-
const
|
|
154
|
-
|
|
152
|
+
const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
|
|
153
|
+
const runLoad = async () => {
|
|
155
154
|
const data = await loadRouteData(routeUrl, locals, request, cookies);
|
|
156
155
|
|
|
157
156
|
let metadata = null;
|
|
@@ -172,7 +171,14 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
172
171
|
}
|
|
173
172
|
|
|
174
173
|
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
175
|
-
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Dedup public routes by URL only. `(private)` scope routes (per-user content)
|
|
177
|
+
// skip the cache to prevent cross-user data leaks. See dedup.ts.
|
|
178
|
+
const result =
|
|
179
|
+
pageMatch?.route.scope === "private"
|
|
180
|
+
? await runLoad()
|
|
181
|
+
: await dedup(dedupKey(routeUrl), runLoad);
|
|
176
182
|
|
|
177
183
|
const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
|
|
178
184
|
const cc = cookiesWereAccessed
|
|
@@ -398,7 +404,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
398
404
|
{ status: result.status },
|
|
399
405
|
);
|
|
400
406
|
}
|
|
401
|
-
return renderPageWithFormData(
|
|
407
|
+
return await renderPageWithFormData(
|
|
402
408
|
url,
|
|
403
409
|
locals,
|
|
404
410
|
request,
|
|
@@ -416,7 +422,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
416
422
|
data: result ?? null,
|
|
417
423
|
});
|
|
418
424
|
}
|
|
419
|
-
return renderPageWithFormData(
|
|
425
|
+
return await renderPageWithFormData(
|
|
420
426
|
url,
|
|
421
427
|
locals,
|
|
422
428
|
request,
|
|
@@ -649,8 +655,8 @@ async function shutdown() {
|
|
|
649
655
|
console.log("✅ All requests drained");
|
|
650
656
|
}
|
|
651
657
|
|
|
652
|
-
app.stop().then(() => process.exit(0));
|
|
653
|
-
setTimeout(() => process.exit(1),
|
|
658
|
+
app.stop(true).then(() => process.exit(0));
|
|
659
|
+
setTimeout(() => process.exit(1), 2_000);
|
|
654
660
|
}
|
|
655
661
|
|
|
656
662
|
process.on("SIGTERM", shutdown);
|
package/src/core/types.ts
CHANGED
|
@@ -17,6 +17,12 @@ export interface PageRoute {
|
|
|
17
17
|
layoutServers: { path: string; depth: number }[];
|
|
18
18
|
/** Effective trailing-slash mode (page wins over layout chain). Defaults to "never". */
|
|
19
19
|
trailingSlash: TrailingSlash;
|
|
20
|
+
/**
|
|
21
|
+
* Dedup scope. `"public"` (default) → loader runs once for concurrent identical
|
|
22
|
+
* URLs. `"private"` → loader runs per-request (use for per-user routes).
|
|
23
|
+
* Set by placing the route under a `(private)` group folder anywhere in the chain.
|
|
24
|
+
*/
|
|
25
|
+
scope: "public" | "private";
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/** An API route discovered from the file system */
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<ul class="space-y-2 text-foreground">
|
|
12
|
-
{#each ["Bun runtime — fast builds, native TypeScript", "ElysiaJS — HTTP server with type-safe routing", "Svelte 5 Runes — fine-grained reactivity", "Isomorphic SSR with client hydration", "File-based routing (SvelteKit-compatible conventions)", "Nested layouts and route groups (public), (auth), (admin)", "Dynamic params [slug] and catch-all [...rest]", "Server loaders with parent() data threading", "Hooks — sequence() middleware for auth, logging, etc.", "Component registry — bosia add button", "Feature registry — bosia feat login"] as item}
|
|
12
|
+
{#each ["Bun runtime — fast builds, native TypeScript", "ElysiaJS — HTTP server with type-safe routing", "Svelte 5 Runes — fine-grained reactivity", "Isomorphic SSR with client hydration", "File-based routing (SvelteKit-compatible conventions)", "Nested layouts and route groups (public), (auth), (admin)", "Dynamic params [slug] and catch-all [...rest]", "Server loaders with parent() data threading", "Hooks — sequence() middleware for auth, logging, etc.", "Component registry — bun x bosia@latest add button", "Feature registry — bosia feat login"] as item}
|
|
13
13
|
<li class="flex items-start gap-2">
|
|
14
14
|
<span class="text-primary mt-0.5">✓</span>
|
|
15
15
|
<span>{item}</span>
|