bosia 0.8.14 → 0.8.16
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/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/start.ts +3 -1
- package/src/core/dev.ts +10 -19
- package/src/core/env.ts +25 -6
- package/src/core/port.ts +16 -0
- package/src/core/server.ts +37 -18
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ File-based routing inspired by SvelteKit, built on top of the Bun runtime and El
|
|
|
18
18
|
- **Dev server with HMR** — file watcher + SSE browser reload, no page blink
|
|
19
19
|
- **Tailwind CSS v4** — compiled at build time, shadcn-inspired design tokens out of the box
|
|
20
20
|
- **CLI** — `bosia create`, `bosia dev`, `bosia build`, `bosia add`, `bosia feat`
|
|
21
|
-
- **UI registry** — shadcn-style, copy-paste source you own: **
|
|
21
|
+
- **UI registry** — shadcn-style, copy-paste source you own: **61 components**, **162 blocks**, **23 pages**, **19 themes**
|
|
22
22
|
|
|
23
23
|
## UI Registry
|
|
24
24
|
|
package/package.json
CHANGED
package/src/cli/start.ts
CHANGED
|
@@ -26,5 +26,7 @@ export async function runStart() {
|
|
|
26
26
|
// mid-drain. The terminal delivers SIGINT to the whole process group,
|
|
27
27
|
// so the child already gets the signal — no forwarding needed.
|
|
28
28
|
for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => {});
|
|
29
|
-
|
|
29
|
+
// Propagate the child's code — a refused boot must not report success to CI,
|
|
30
|
+
// Docker restart policies or orchestrators.
|
|
31
|
+
process.exit(await proc.exited);
|
|
30
32
|
}
|
package/src/core/dev.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readdirSync, statSync, watch, type Dirent } from "fs";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { loadEnv, resetDeclaredKeys } from "./env.ts";
|
|
5
5
|
import { BOSIA_NODE_PATH } from "./paths.ts";
|
|
6
|
+
import { pidsOnPort } from "./port.ts";
|
|
6
7
|
import { affectsRouteManifest, shouldIgnoreForRebuild } from "./devWatch.ts";
|
|
7
8
|
|
|
8
9
|
// Dev always writes to .bosia/dev so a parallel `bun run build` (writing to ./dist)
|
|
@@ -159,26 +160,16 @@ const APP_PORT = DEV_PORT + 1; // internal, hidden from user
|
|
|
159
160
|
// disk → "ENOENT reading +page-<hash>.js" 500s. Reap whatever is listening on
|
|
160
161
|
// APP_PORT before we spawn, so a stale child can never shadow the fresh build.
|
|
161
162
|
async function reapStaleAppServer(port: number) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.map(Number)
|
|
171
|
-
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
|
172
|
-
for (const pid of pids) {
|
|
173
|
-
try {
|
|
174
|
-
process.kill(pid, "SIGKILL");
|
|
175
|
-
console.log(`🧹 Reaped stale app server holding port ${port} (pid ${pid})`);
|
|
176
|
-
} catch {}
|
|
177
|
-
}
|
|
178
|
-
if (pids.length) await Bun.sleep(200); // let the port free before we bind
|
|
179
|
-
} catch {
|
|
180
|
-
// lsof unavailable or errored — fail open; a real conflict surfaces on bind.
|
|
163
|
+
// pidsOnPort fails open when lsof is unavailable — a real conflict then
|
|
164
|
+
// surfaces on bind instead.
|
|
165
|
+
const pids = (await pidsOnPort(port)).filter((pid) => pid !== process.pid);
|
|
166
|
+
for (const pid of pids) {
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, "SIGKILL");
|
|
169
|
+
console.log(`🧹 Reaped stale app server holding port ${port} (pid ${pid})`);
|
|
170
|
+
} catch {}
|
|
181
171
|
}
|
|
172
|
+
if (pids.length) await Bun.sleep(200); // let the port free before we bind
|
|
182
173
|
}
|
|
183
174
|
|
|
184
175
|
async function startAppServer() {
|
package/src/core/env.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
4
|
// ─── Framework-reserved vars ─────────────────────────────
|
|
@@ -26,6 +26,9 @@ const FRAMEWORK_VARS = new Set([
|
|
|
26
26
|
/** Valid JS/TS identifier: starts with letter/underscore, then alphanumeric/underscore. */
|
|
27
27
|
const VALID_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
28
28
|
|
|
29
|
+
/** `.env`, `.env.production`, `.env.example`, … — but not `.envrc` or other `.env`-prefixed files. */
|
|
30
|
+
const ENV_FILE_RE = /^\.env(\..+)?$/;
|
|
31
|
+
|
|
29
32
|
/** Process escape sequences in double-quoted values. */
|
|
30
33
|
function processEscapes(raw: string): string {
|
|
31
34
|
return raw.replace(/\\(.)/g, (_, ch) => {
|
|
@@ -119,9 +122,13 @@ export function parseEnvFile(content: string, filename?: string): Record<string,
|
|
|
119
122
|
* 3. .env.[mode]
|
|
120
123
|
* 4. .env.[mode].local
|
|
121
124
|
*
|
|
125
|
+
* Variable *names* additionally come from every other `.env*` file present
|
|
126
|
+
* (`.env.production`, `.env.example`, …) with an empty value, so the `$env`
|
|
127
|
+
* export surface is the same in dev, prod and CI.
|
|
128
|
+
*
|
|
122
129
|
* @param mode "development" | "production"
|
|
123
130
|
* @param dir directory to look in (defaults to cwd)
|
|
124
|
-
* @returns merged env record (only vars
|
|
131
|
+
* @returns merged env record (only vars declared in .env files, excluding framework vars)
|
|
125
132
|
*/
|
|
126
133
|
export function loadEnv(mode: string, dir?: string): Record<string, string> {
|
|
127
134
|
const root = dir ?? process.cwd();
|
|
@@ -143,12 +150,24 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
|
|
|
143
150
|
console.log(`✓ Loaded ${loaded.join(", ")}`);
|
|
144
151
|
}
|
|
145
152
|
|
|
153
|
+
// Names come from every `.env*` present, values only from the mode's files.
|
|
154
|
+
// Keeps the $env export surface identical in dev, prod and CI — mode decides
|
|
155
|
+
// only which names carry a value. A malformed off-mode file throws here
|
|
156
|
+
// rather than at deploy time.
|
|
157
|
+
const declaredNames = new Set(Object.keys(merged));
|
|
158
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
159
|
+
if (!entry.isFile() || !ENV_FILE_RE.test(entry.name) || files.includes(entry.name)) continue;
|
|
160
|
+
const parsed = parseEnvFile(readFileSync(join(root, entry.name), "utf-8"), entry.name);
|
|
161
|
+
for (const key of Object.keys(parsed)) declaredNames.add(key);
|
|
162
|
+
}
|
|
163
|
+
|
|
146
164
|
// Track declared keys so html.ts only exposes .env-declared PUBLIC_* vars
|
|
147
|
-
for (const key of
|
|
165
|
+
for (const key of declaredNames) {
|
|
148
166
|
_declaredKeys.add(key);
|
|
149
167
|
}
|
|
150
168
|
|
|
151
|
-
// Apply to process.env — system env wins (don't overwrite existing)
|
|
169
|
+
// Apply to process.env — system env wins (don't overwrite existing).
|
|
170
|
+
// Name-only keys stay absent so `process.env.X ?? default` still falls back.
|
|
152
171
|
for (const [key, value] of Object.entries(merged)) {
|
|
153
172
|
if (!(key in process.env)) {
|
|
154
173
|
process.env[key] = value;
|
|
@@ -157,9 +176,9 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
|
|
|
157
176
|
|
|
158
177
|
// Return only non-framework vars
|
|
159
178
|
const result: Record<string, string> = {};
|
|
160
|
-
for (const
|
|
179
|
+
for (const key of declaredNames) {
|
|
161
180
|
if (!FRAMEWORK_VARS.has(key)) {
|
|
162
|
-
result[key] = process.env[key] ??
|
|
181
|
+
result[key] = process.env[key] ?? merged[key] ?? "";
|
|
163
182
|
}
|
|
164
183
|
}
|
|
165
184
|
|
package/src/core/port.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** PIDs listening on `port`. Fails open to [] — lsof may be absent. */
|
|
2
|
+
export async function pidsOnPort(port: number): Promise<number[]> {
|
|
3
|
+
try {
|
|
4
|
+
const proc = Bun.spawn(["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"], {
|
|
5
|
+
stdout: "pipe",
|
|
6
|
+
stderr: "ignore",
|
|
7
|
+
});
|
|
8
|
+
const out = await new Response(proc.stdout).text();
|
|
9
|
+
await proc.exited;
|
|
10
|
+
return [...new Set(out.split("\n").map((s) => s.trim()))]
|
|
11
|
+
.map(Number)
|
|
12
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
13
|
+
} catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/core/server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } f
|
|
|
24
24
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
25
25
|
import { dev500WithPlugins } from "./dev-500.ts";
|
|
26
26
|
import { OUT_DIR } from "./paths.ts";
|
|
27
|
+
import { pidsOnPort } from "./port.ts";
|
|
27
28
|
import { buildPrerenderManifest, buildStaticManifest, lookupStatic } from "./staticManifest.ts";
|
|
28
29
|
import { dedup } from "./dedup.ts";
|
|
29
30
|
import {
|
|
@@ -1092,7 +1093,14 @@ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 :
|
|
|
1092
1093
|
// Elysia's chained generics drift when plugins add routes — track the app as a
|
|
1093
1094
|
// loose `Elysia` so plugin-extended types stay assignable.
|
|
1094
1095
|
let app: Elysia = new Elysia({
|
|
1095
|
-
serve: {
|
|
1096
|
+
serve: {
|
|
1097
|
+
maxRequestBodySize: BODY_SIZE_LIMIT,
|
|
1098
|
+
idleTimeout: IDLE_TIMEOUT,
|
|
1099
|
+
// Elysia's Bun adapter defaults reusePort:true — SO_REUSEPORT lets a second
|
|
1100
|
+
// server silently join the port and the kernel splits traffic between two
|
|
1101
|
+
// different builds. Opt in only for deliberate N-worker clustering.
|
|
1102
|
+
reusePort: process.env.BOSIA_REUSE_PORT === "1",
|
|
1103
|
+
},
|
|
1096
1104
|
}) as unknown as Elysia;
|
|
1097
1105
|
|
|
1098
1106
|
// Plugins.backend.before — runs before framework middleware/routes.
|
|
@@ -1160,23 +1168,34 @@ for (const plugin of plugins) {
|
|
|
1160
1168
|
}
|
|
1161
1169
|
}
|
|
1162
1170
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
}
|
|
1171
|
+
try {
|
|
1172
|
+
app.listen(PORT, () => {
|
|
1173
|
+
// In dev mode the proxy owns the user-facing port — don't print the internal port
|
|
1174
|
+
if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
|
|
1175
|
+
// Last line of startup on purpose — the cache identity contract is the one
|
|
1176
|
+
// config mistake that leaks one user's page to another, so it stays visible.
|
|
1177
|
+
if (CACHE_ENABLED) {
|
|
1178
|
+
console.log(
|
|
1179
|
+
`\n🔑 Response cache tells users apart ONLY by these cookies/headers: [${CACHE_KEYS.join(", ")}]\n` +
|
|
1180
|
+
` Using a different session cookie or auth header? Add its name to CACHE_KEYS,\n` +
|
|
1181
|
+
` or one user's personalised page can be served to another. Routes personalised\n` +
|
|
1182
|
+
` by anything else should set \`export const cache = false\`.\n` +
|
|
1183
|
+
` Note: the runtime auto-warns only on uncovered *cookie* reads — it CANNOT\n` +
|
|
1184
|
+
` detect header-based personalisation, so custom auth headers (X-Api-Key,\n` +
|
|
1185
|
+
` X-Auth-Token, …) must be added to CACHE_KEYS by hand.\n`,
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
// Bun.serve runs inline inside .listen(), so a failed bind lands here.
|
|
1191
|
+
if ((err as { code?: string })?.code !== "EADDRINUSE") throw err;
|
|
1192
|
+
const [pid] = await pidsOnPort(PORT);
|
|
1193
|
+
console.error(
|
|
1194
|
+
`\n❌ Port ${PORT} is already serving${pid ? ` (pid ${pid})` : ""}.\n` +
|
|
1195
|
+
` Stop it or set PORT to a free port.\n`,
|
|
1196
|
+
);
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1180
1199
|
|
|
1181
1200
|
async function shutdown() {
|
|
1182
1201
|
if (shuttingDown) {
|