bosia 0.1.4 → 0.1.6
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/core/client/App.svelte +3 -0
- package/src/core/client/router.svelte.ts +4 -0
- package/src/core/cookies.ts +9 -2
- package/src/core/dev.ts +22 -7
- package/src/core/env.ts +40 -10
- package/src/core/html.ts +1 -1
- package/src/core/server.ts +42 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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": [
|
|
@@ -86,6 +86,9 @@
|
|
|
86
86
|
layoutData = result?.layoutData ?? [];
|
|
87
87
|
routeParams = result?.pageData?.params ?? match.params;
|
|
88
88
|
|
|
89
|
+
// Scroll to top on forward navigation (not on popstate/back-forward)
|
|
90
|
+
if (router.isPush) window.scrollTo(0, 0);
|
|
91
|
+
|
|
89
92
|
// Update document title and meta description from server metadata
|
|
90
93
|
if (result?.metadata) {
|
|
91
94
|
if (result.metadata.title) document.title = result.metadata.title;
|
|
@@ -8,6 +8,8 @@ import { clientRoutes } from "bosia:routes";
|
|
|
8
8
|
export const router = new class Router {
|
|
9
9
|
currentRoute = $state(typeof window !== "undefined" ? window.location.pathname + window.location.search + window.location.hash : "/");
|
|
10
10
|
params = $state<Record<string, string>>({});
|
|
11
|
+
/** True when navigation was triggered by a link click / navigate() call, false on popstate (back/forward). */
|
|
12
|
+
isPush = $state(true);
|
|
11
13
|
|
|
12
14
|
navigate(path: string) {
|
|
13
15
|
if (this.currentRoute === path) return;
|
|
@@ -17,6 +19,7 @@ export const router = new class Router {
|
|
|
17
19
|
window.location.href = path;
|
|
18
20
|
return;
|
|
19
21
|
}
|
|
22
|
+
this.isPush = true;
|
|
20
23
|
this.currentRoute = path;
|
|
21
24
|
if (typeof history !== "undefined") {
|
|
22
25
|
history.pushState({}, "", path);
|
|
@@ -41,6 +44,7 @@ export const router = new class Router {
|
|
|
41
44
|
|
|
42
45
|
// Browser back/forward
|
|
43
46
|
window.addEventListener("popstate", () => {
|
|
47
|
+
this.isPush = false;
|
|
44
48
|
this.currentRoute = window.location.pathname + window.location.search + window.location.hash;
|
|
45
49
|
});
|
|
46
50
|
}
|
package/src/core/cookies.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { Cookies, CookieOptions } from "./hooks.ts";
|
|
2
2
|
|
|
3
|
-
// ─── Cookie Validation
|
|
3
|
+
// ─── Cookie Validation (RFC 6265) ────────────────────────
|
|
4
4
|
/** Rejects characters that could inject into Set-Cookie headers. */
|
|
5
5
|
const UNSAFE_COOKIE_VALUE = /[;\r\n]/;
|
|
6
6
|
const VALID_SAMESITE = new Set(["Strict", "Lax", "None"]);
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* RFC 6265 §4.1.1: cookie-name is an HTTP token (RFC 2616 §2.2).
|
|
10
|
+
* Must be 1+ chars of ASCII 33-126, excluding separators: ( ) < > @ , ; : \ " / [ ] ? = { }
|
|
11
|
+
*/
|
|
12
|
+
const VALID_COOKIE_NAME = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;
|
|
13
|
+
|
|
8
14
|
// ─── Cookie Helpers ──────────────────────────────────────
|
|
9
15
|
|
|
10
16
|
function parseCookies(header: string): Record<string, string> {
|
|
@@ -39,7 +45,8 @@ export class CookieJar implements Cookies {
|
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
set(name: string, value: string, options?: CookieOptions): void {
|
|
42
|
-
|
|
48
|
+
if (!VALID_COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
|
|
49
|
+
let header = `${name}=${encodeURIComponent(value)}`;
|
|
43
50
|
const path = options?.path ?? "/";
|
|
44
51
|
if (UNSAFE_COOKIE_VALUE.test(path)) throw new Error(`Invalid cookie path: ${path}`);
|
|
45
52
|
header += `; Path=${path}`;
|
package/src/core/dev.ts
CHANGED
|
@@ -108,17 +108,32 @@ async function startAppServer() {
|
|
|
108
108
|
// ─── Build & Restart ──────────────────────────────────────
|
|
109
109
|
|
|
110
110
|
let buildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
|
+
let building = false;
|
|
112
|
+
let buildPending = false;
|
|
111
113
|
|
|
112
114
|
async function buildAndRestart() {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
console.error("❌ Build failed — fix errors and save again");
|
|
115
|
+
if (building) {
|
|
116
|
+
buildPending = true;
|
|
116
117
|
return;
|
|
117
118
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
building = true;
|
|
120
|
+
try {
|
|
121
|
+
const ok = await runBuild();
|
|
122
|
+
if (!ok) {
|
|
123
|
+
console.error("❌ Build failed — fix errors and save again");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await startAppServer();
|
|
127
|
+
// Give the app server a moment to bind its port
|
|
128
|
+
await Bun.sleep(200);
|
|
129
|
+
broadcastReload();
|
|
130
|
+
} finally {
|
|
131
|
+
building = false;
|
|
132
|
+
}
|
|
133
|
+
if (buildPending) {
|
|
134
|
+
buildPending = false;
|
|
135
|
+
buildAndRestart();
|
|
136
|
+
}
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
function scheduleBuild() {
|
package/src/core/env.ts
CHANGED
|
@@ -21,24 +21,54 @@ const FRAMEWORK_VARS = new Set([
|
|
|
21
21
|
|
|
22
22
|
// ─── .env File Parser ────────────────────────────────────
|
|
23
23
|
|
|
24
|
+
/** Valid JS/TS identifier: starts with letter/underscore, then alphanumeric/underscore. */
|
|
25
|
+
const VALID_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
26
|
+
|
|
27
|
+
/** Process escape sequences in double-quoted values. */
|
|
28
|
+
function processEscapes(raw: string): string {
|
|
29
|
+
return raw.replace(/\\(.)/g, (_, ch) => {
|
|
30
|
+
switch (ch) {
|
|
31
|
+
case "n": return "\n";
|
|
32
|
+
case "r": return "\r";
|
|
33
|
+
case "t": return "\t";
|
|
34
|
+
case "\\": return "\\";
|
|
35
|
+
case '"': return '"';
|
|
36
|
+
default: return `\\${ch}`; // preserve unknown escapes
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
/** Parse a .env file content into key/value pairs. Skips comments and empty lines. */
|
|
25
|
-
function parseEnvFile(content: string): Record<string, string> {
|
|
42
|
+
function parseEnvFile(content: string, filename?: string): Record<string, string> {
|
|
26
43
|
const result: Record<string, string> = {};
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
const lines = content.split("\n");
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
const trimmed = lines[i].trim();
|
|
29
47
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
30
48
|
const eqIdx = trimmed.indexOf("=");
|
|
31
49
|
if (eqIdx === -1) continue;
|
|
32
50
|
const key = trimmed.slice(0, eqIdx).trim();
|
|
51
|
+
if (!key) continue;
|
|
52
|
+
|
|
53
|
+
// Validate key is a valid identifier (required for codegen)
|
|
54
|
+
if (!VALID_ENV_NAME.test(key)) {
|
|
55
|
+
const loc = filename ? ` in ${filename}` : "";
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Invalid env variable name "${key}"${loc} (line ${i + 1}). ` +
|
|
58
|
+
`Names must start with a letter or underscore and contain only [A-Za-z0-9_].`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
let value = trimmed.slice(eqIdx + 1).trim();
|
|
34
|
-
//
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
63
|
+
// Double-quoted: process escape sequences
|
|
64
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
65
|
+
value = processEscapes(value.slice(1, -1));
|
|
66
|
+
}
|
|
67
|
+
// Single-quoted: literal (no escape processing)
|
|
68
|
+
else if (value.startsWith("'") && value.endsWith("'")) {
|
|
39
69
|
value = value.slice(1, -1);
|
|
40
70
|
}
|
|
41
|
-
|
|
71
|
+
result[key] = value;
|
|
42
72
|
}
|
|
43
73
|
return result;
|
|
44
74
|
}
|
|
@@ -75,7 +105,7 @@ export function loadEnv(mode: string, dir?: string): Record<string, string> {
|
|
|
75
105
|
const filepath = join(root, filename);
|
|
76
106
|
if (!existsSync(filepath)) continue;
|
|
77
107
|
const content = readFileSync(filepath, "utf-8");
|
|
78
|
-
const parsed = parseEnvFile(content);
|
|
108
|
+
const parsed = parseEnvFile(content, filename);
|
|
79
109
|
merged = { ...merged, ...parsed };
|
|
80
110
|
loaded.push(filename);
|
|
81
111
|
}
|
package/src/core/html.ts
CHANGED
|
@@ -173,7 +173,7 @@ export function buildHtmlTail(
|
|
|
173
173
|
): string {
|
|
174
174
|
let out = `<script>document.getElementById('__bs__').remove()</script>`;
|
|
175
175
|
out += `\n<div id="app">${body}</div>`;
|
|
176
|
-
if (head) out += `\n<script>document.head.
|
|
176
|
+
if (head) out += `\n<script>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
|
|
177
177
|
if (csr) {
|
|
178
178
|
const publicEnv = getPublicDynamicEnv();
|
|
179
179
|
if (Object.keys(publicEnv).length > 0) {
|
package/src/core/server.ts
CHANGED
|
@@ -108,6 +108,9 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
108
108
|
|
|
109
109
|
// Health check endpoint — for load balancers and orchestrators
|
|
110
110
|
if (path === "/_health") {
|
|
111
|
+
if (shuttingDown) {
|
|
112
|
+
return Response.json({ status: "shutting_down" }, { status: 503 });
|
|
113
|
+
}
|
|
111
114
|
const { timestamp, timezone } = getServerTime();
|
|
112
115
|
return Response.json({ status: "ok", timestamp, timezone });
|
|
113
116
|
}
|
|
@@ -303,6 +306,15 @@ const SECURITY_HEADERS: Record<string, string> = {
|
|
|
303
306
|
};
|
|
304
307
|
|
|
305
308
|
async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
309
|
+
// Reject new non-health requests during shutdown
|
|
310
|
+
if (shuttingDown && url.pathname !== "/_health") {
|
|
311
|
+
return new Response("Service Unavailable", {
|
|
312
|
+
status: 503,
|
|
313
|
+
headers: { "Retry-After": "5" },
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
inFlight++;
|
|
306
318
|
try {
|
|
307
319
|
// Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
|
|
308
320
|
if (CORS_CONFIG && request.method === "OPTIONS") {
|
|
@@ -338,6 +350,11 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
338
350
|
if (isDev) console.error("Unhandled request error:", err);
|
|
339
351
|
else console.error("Unhandled request error:", (err as Error).message ?? err);
|
|
340
352
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
353
|
+
} finally {
|
|
354
|
+
inFlight--;
|
|
355
|
+
if (shuttingDown && inFlight === 0 && drainResolve) {
|
|
356
|
+
drainResolve();
|
|
357
|
+
}
|
|
341
358
|
}
|
|
342
359
|
}
|
|
343
360
|
|
|
@@ -367,6 +384,12 @@ if (BODY_SIZE_LIMIT === 0) {
|
|
|
367
384
|
console.log(`📦 Body size limit: ${BODY_SIZE_LIMIT} bytes`);
|
|
368
385
|
}
|
|
369
386
|
|
|
387
|
+
// ─── Graceful Shutdown State ──────────────────────────────
|
|
388
|
+
|
|
389
|
+
let shuttingDown = false;
|
|
390
|
+
let inFlight = 0;
|
|
391
|
+
let drainResolve: (() => void) | null = null;
|
|
392
|
+
|
|
370
393
|
// ─── Elysia App ───────────────────────────────────────────
|
|
371
394
|
|
|
372
395
|
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : (isDev ? 9001 : 9000);
|
|
@@ -416,11 +439,26 @@ app.listen(PORT, () => {
|
|
|
416
439
|
if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
|
|
417
440
|
});
|
|
418
441
|
|
|
419
|
-
function shutdown() {
|
|
420
|
-
|
|
442
|
+
async function shutdown() {
|
|
443
|
+
if (shuttingDown) return;
|
|
444
|
+
shuttingDown = true;
|
|
445
|
+
console.log("⏳ Shutting down — draining in-flight requests...");
|
|
446
|
+
|
|
447
|
+
if (inFlight > 0) {
|
|
448
|
+
await Promise.race([
|
|
449
|
+
new Promise<void>(r => { drainResolve = r; }),
|
|
450
|
+
Bun.sleep(10_000),
|
|
451
|
+
]);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (inFlight > 0) {
|
|
455
|
+
console.warn(`⚠️ Force shutdown with ${inFlight} request(s) still in flight`);
|
|
456
|
+
} else {
|
|
457
|
+
console.log("✅ All requests drained");
|
|
458
|
+
}
|
|
459
|
+
|
|
421
460
|
app.stop().then(() => process.exit(0));
|
|
422
|
-
|
|
423
|
-
setTimeout(() => process.exit(1), 10_000);
|
|
461
|
+
setTimeout(() => process.exit(1), 5_000);
|
|
424
462
|
}
|
|
425
463
|
|
|
426
464
|
process.on("SIGTERM", shutdown);
|