bosia 0.2.2 → 0.3.0

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.
Files changed (87) hide show
  1. package/README.md +39 -39
  2. package/package.json +56 -53
  3. package/src/ambient.d.ts +31 -0
  4. package/src/cli/add.ts +120 -114
  5. package/src/cli/build.ts +10 -10
  6. package/src/cli/create.ts +142 -137
  7. package/src/cli/dev.ts +8 -8
  8. package/src/cli/feat.ts +291 -132
  9. package/src/cli/index.ts +51 -42
  10. package/src/cli/registry.ts +136 -115
  11. package/src/cli/start.ts +17 -17
  12. package/src/cli/test.ts +25 -0
  13. package/src/core/build.ts +72 -56
  14. package/src/core/client/App.svelte +177 -153
  15. package/src/core/client/appState.svelte.ts +57 -0
  16. package/src/core/client/enhance.ts +112 -0
  17. package/src/core/client/hydrate.ts +97 -65
  18. package/src/core/client/prefetch.ts +101 -94
  19. package/src/core/client/router.svelte.ts +64 -51
  20. package/src/core/cookies.ts +70 -66
  21. package/src/core/cors.ts +44 -35
  22. package/src/core/csrf.ts +38 -38
  23. package/src/core/dedup.ts +17 -17
  24. package/src/core/dev.ts +165 -168
  25. package/src/core/env.ts +155 -128
  26. package/src/core/envCodegen.ts +73 -73
  27. package/src/core/errors.ts +48 -49
  28. package/src/core/hooks.ts +50 -50
  29. package/src/core/html.ts +192 -139
  30. package/src/core/matcher.ts +130 -121
  31. package/src/core/paths.ts +8 -10
  32. package/src/core/plugin.ts +113 -107
  33. package/src/core/prerender.ts +191 -118
  34. package/src/core/renderer.ts +359 -265
  35. package/src/core/routeFile.ts +140 -127
  36. package/src/core/routeTypes.ts +144 -83
  37. package/src/core/scanner.ts +125 -95
  38. package/src/core/server.ts +543 -370
  39. package/src/core/types.ts +25 -20
  40. package/src/lib/client.ts +12 -0
  41. package/src/lib/index.ts +8 -8
  42. package/src/lib/utils.ts +44 -30
  43. package/templates/default/.prettierignore +5 -0
  44. package/templates/default/.prettierrc.json +9 -0
  45. package/templates/default/README.md +5 -5
  46. package/templates/default/package.json +22 -18
  47. package/templates/default/src/app.css +80 -80
  48. package/templates/default/src/app.d.ts +3 -3
  49. package/templates/default/src/routes/+error.svelte +7 -10
  50. package/templates/default/src/routes/+layout.svelte +2 -2
  51. package/templates/default/src/routes/+page.svelte +31 -29
  52. package/templates/default/src/routes/about/+page.svelte +3 -3
  53. package/templates/default/tsconfig.json +20 -20
  54. package/templates/demo/.prettierignore +5 -0
  55. package/templates/demo/.prettierrc.json +9 -0
  56. package/templates/demo/README.md +9 -9
  57. package/templates/demo/package.json +22 -17
  58. package/templates/demo/src/app.css +80 -80
  59. package/templates/demo/src/app.d.ts +3 -3
  60. package/templates/demo/src/hooks.server.ts +9 -9
  61. package/templates/demo/src/routes/(public)/+layout.svelte +45 -23
  62. package/templates/demo/src/routes/(public)/+page.svelte +96 -67
  63. package/templates/demo/src/routes/(public)/about/+page.svelte +13 -25
  64. package/templates/demo/src/routes/(public)/all/[...catchall]/+page.svelte +24 -28
  65. package/templates/demo/src/routes/(public)/blog/+page.svelte +55 -46
  66. package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +36 -38
  67. package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +60 -42
  68. package/templates/demo/src/routes/+error.svelte +10 -7
  69. package/templates/demo/src/routes/+layout.server.ts +4 -4
  70. package/templates/demo/src/routes/+layout.svelte +2 -2
  71. package/templates/demo/src/routes/actions-test/+page.server.ts +16 -16
  72. package/templates/demo/src/routes/actions-test/+page.svelte +49 -49
  73. package/templates/demo/src/routes/api/hello/+server.ts +25 -25
  74. package/templates/demo/tsconfig.json +20 -20
  75. package/templates/todo/.prettierignore +5 -0
  76. package/templates/todo/.prettierrc.json +9 -0
  77. package/templates/todo/README.md +9 -9
  78. package/templates/todo/package.json +22 -17
  79. package/templates/todo/src/app.css +80 -80
  80. package/templates/todo/src/app.d.ts +7 -7
  81. package/templates/todo/src/hooks.server.ts +9 -9
  82. package/templates/todo/src/routes/+error.svelte +10 -7
  83. package/templates/todo/src/routes/+layout.server.ts +4 -4
  84. package/templates/todo/src/routes/+layout.svelte +2 -2
  85. package/templates/todo/src/routes/+page.svelte +44 -44
  86. package/templates/todo/template.json +1 -1
  87. package/templates/todo/tsconfig.json +20 -20
@@ -3,7 +3,7 @@ import { Elysia } from "elysia";
3
3
  import { existsSync } from "fs";
4
4
  import { join, resolve as resolvePath } from "path";
5
5
 
6
- import { findMatch, compileRoutes } from "./matcher.ts";
6
+ import { findMatch, compileRoutes, canonicalPathname } from "./matcher.ts";
7
7
  import { apiRoutes, serverRoutes } from "bosia:routes";
8
8
 
9
9
  // Pre-compile route patterns into RegExp at startup (shared by renderer.ts via module reference)
@@ -17,7 +17,13 @@ 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 { loadRouteData, loadMetadata, renderSSRStream, renderErrorPage, renderPageWithFormData } from "./renderer.ts";
20
+ import {
21
+ loadRouteData,
22
+ loadMetadata,
23
+ renderSSRStream,
24
+ renderErrorPage,
25
+ renderPageWithFormData,
26
+ } from "./renderer.ts";
21
27
  import { getServerTime } from "../lib/utils.ts";
22
28
  import { dedup, dedupKey } from "./dedup.ts";
23
29
 
@@ -29,21 +35,26 @@ let userHandle: Handle | null = null;
29
35
 
30
36
  const hooksPath = join(process.cwd(), "src", "hooks.server.ts");
31
37
  if (existsSync(hooksPath)) {
32
- try {
33
- const mod = await import(hooksPath);
34
- if (typeof mod.handle === "function") {
35
- userHandle = mod.handle as Handle;
36
- console.log("🪝 Loaded hooks.server.ts");
37
- }
38
- } catch (err) {
39
- console.warn("⚠️ Failed to load hooks.server.ts:", err);
40
- }
38
+ try {
39
+ const mod = await import(hooksPath);
40
+ if (typeof mod.handle === "function") {
41
+ userHandle = mod.handle as Handle;
42
+ console.log("🪝 Loaded hooks.server.ts");
43
+ }
44
+ } catch (err) {
45
+ console.warn("⚠️ Failed to load hooks.server.ts:", err);
46
+ }
41
47
  }
42
48
 
43
49
  // ─── Env Helpers ─────────────────────────────────────────
44
50
 
45
51
  function splitCsvEnv(key: string): string[] | undefined {
46
- return process.env[key]?.split(",").map(s => s.trim()).filter(Boolean) || undefined;
52
+ return (
53
+ process.env[key]
54
+ ?.split(",")
55
+ .map((s) => s.trim())
56
+ .filter(Boolean) || undefined
57
+ );
47
58
  }
48
59
 
49
60
  // ─── CSRF Config ─────────────────────────────────────────
@@ -51,14 +62,14 @@ function splitCsvEnv(key: string): string[] | undefined {
51
62
  const _csrfAllowedOrigins = splitCsvEnv("CSRF_ALLOWED_ORIGINS");
52
63
 
53
64
  const CSRF_CONFIG: CsrfConfig = {
54
- checkOrigin: true,
55
- allowedOrigins: _csrfAllowedOrigins,
65
+ checkOrigin: true,
66
+ allowedOrigins: _csrfAllowedOrigins,
56
67
  };
57
68
 
58
69
  if (_csrfAllowedOrigins?.length) {
59
- console.log(`🛡️ CSRF allowed origins: ${_csrfAllowedOrigins.join(", ")}`);
70
+ console.log(`🛡️ CSRF allowed origins: ${_csrfAllowedOrigins.join(", ")}`);
60
71
  } else {
61
- console.log("🛡️ CSRF: same-origin only");
72
+ console.log("🛡️ CSRF: same-origin only");
62
73
  }
63
74
 
64
75
  // ─── CORS Config ──────────────────────────────────────────
@@ -66,315 +77,475 @@ if (_csrfAllowedOrigins?.length) {
66
77
  const _corsAllowedOrigins = splitCsvEnv("CORS_ALLOWED_ORIGINS");
67
78
 
68
79
  const CORS_CONFIG: CorsConfig | null = _corsAllowedOrigins?.length
69
- ? {
70
- allowedOrigins: _corsAllowedOrigins,
71
- allowedMethods: splitCsvEnv("CORS_ALLOWED_METHODS"),
72
- allowedHeaders: splitCsvEnv("CORS_ALLOWED_HEADERS"),
73
- exposedHeaders: splitCsvEnv("CORS_EXPOSED_HEADERS"),
74
- credentials: process.env.CORS_CREDENTIALS === "true" || undefined,
75
- maxAge: process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : undefined,
76
- }
77
- : null;
80
+ ? {
81
+ allowedOrigins: _corsAllowedOrigins,
82
+ allowedMethods: splitCsvEnv("CORS_ALLOWED_METHODS"),
83
+ allowedHeaders: splitCsvEnv("CORS_ALLOWED_HEADERS"),
84
+ exposedHeaders: splitCsvEnv("CORS_EXPOSED_HEADERS"),
85
+ credentials: process.env.CORS_CREDENTIALS === "true" || undefined,
86
+ maxAge: parseCorsMaxAge(process.env.CORS_MAX_AGE),
87
+ }
88
+ : null;
78
89
 
79
90
  if (_corsAllowedOrigins?.length) {
80
- console.log(`🌐 CORS allowed origins: ${_corsAllowedOrigins.join(", ")}`);
91
+ console.log(`🌐 CORS allowed origins: ${_corsAllowedOrigins.join(", ")}`);
81
92
  }
82
93
 
83
94
  // ─── Core Request Resolver ────────────────────────────────
84
95
  // This is the inner handler that hooks wrap around.
85
96
 
86
97
  function isValidRoutePath(path: string, origin: string): boolean {
87
- try {
88
- return new URL(path, origin).origin === origin;
89
- } catch {
90
- return false;
91
- }
98
+ try {
99
+ return new URL(path, origin).origin === origin;
100
+ } catch {
101
+ return false;
102
+ }
92
103
  }
93
104
 
94
105
  /** Resolve a file path and verify it stays within the allowed base directory. Returns null if traversal detected. */
95
106
  function safePath(base: string, untrusted: string): string | null {
96
- const root = resolvePath(base);
97
- const full = resolvePath(join(base, untrusted));
98
- return full.startsWith(root + "/") || full === root ? full : null;
107
+ const root = resolvePath(base);
108
+ const full = resolvePath(join(base, untrusted));
109
+ return full.startsWith(root + "/") || full === root ? full : null;
99
110
  }
100
111
 
101
112
  /** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
102
113
  function parseActionName(url: URL): string {
103
- for (const key of url.searchParams.keys()) {
104
- if (key.startsWith("/")) return key.slice(1) || "default";
105
- }
106
- return "default";
114
+ for (const key of url.searchParams.keys()) {
115
+ if (key.startsWith("/")) return key.slice(1) || "default";
116
+ }
117
+ return "default";
107
118
  }
108
119
 
109
120
  async function resolve(event: RequestEvent): Promise<Response> {
110
- const { request, url, locals, cookies } = event;
111
- const path = url.pathname;
112
- const method = request.method.toUpperCase();
113
-
114
- // Health check endpoint — for load balancers and orchestrators
115
- if (path === "/_health") {
116
- if (shuttingDown) {
117
- return Response.json({ status: "shutting_down" }, { status: 503 });
118
- }
119
- const { timestamp, timezone } = getServerTime();
120
- return Response.json({ status: "ok", timestamp, timezone });
121
- }
122
-
123
- // Data endpoint — returns server loader data as JSON for client-side navigation
124
- if (path.startsWith("/__bosia/data/")) {
125
- const routePathStr = path.slice("/__bosia/data".length).replace(/\.json$/, "").replace(/^\/index$/, "/") || "/";
126
-
127
- if (!isValidRoutePath(routePathStr, url.origin)) {
128
- return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
129
- }
130
- const routeUrl = new URL(routePathStr, url.origin);
131
- for (const [key, val] of url.searchParams.entries()) {
132
- routeUrl.searchParams.append(key, val);
133
- }
134
- // Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
135
- event.url = routeUrl;
136
- const dedupKeyStr = dedupKey(routeUrl, request);
137
- try {
138
- const result = await dedup(dedupKeyStr, async () => {
139
- const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
140
- const data = await loadRouteData(routeUrl, locals, request, cookies);
141
-
142
- let metadata = null;
143
- if (pageMatch) {
144
- try {
145
- const meta = await loadMetadata(pageMatch.route, pageMatch.params, routeUrl, locals, cookies, request);
146
- if (meta) metadata = { title: meta.title, description: meta.description };
147
- } catch { /* non-fatal */ }
148
- }
149
-
150
- return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
151
- });
152
-
153
- const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
154
- const cc = cookiesWereAccessed ? "private, no-cache" : "public, max-age=0, must-revalidate";
155
-
156
- if (!result.data) {
157
- return compress(JSON.stringify({ pageData: {}, layoutData: [] }), "application/json", request, 200, { "Cache-Control": cc });
158
- }
159
- return compress(JSON.stringify({ ...result.data, metadata: result.metadata }), "application/json", request, 200, { "Cache-Control": cc });
160
- } catch (err) {
161
- if (err instanceof Redirect) {
162
- return compress(JSON.stringify({ redirect: err.location, status: err.status }), "application/json", request);
163
- }
164
- if (err instanceof HttpError) {
165
- return compress(JSON.stringify({ error: err.message, status: err.status }), "application/json", request, err.status);
166
- }
167
- if (isDev) console.error("Data endpoint error:", err);
168
- else console.error("Data endpoint error:", (err as Error).message ?? err);
169
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
170
- }
171
- }
172
-
173
- // Static files
174
- if (isStaticPath(path)) {
175
- // dist/client: serve with cache headers based on whether filename is hashed
176
- if (path.startsWith("/dist/client/")) {
177
- const resolved = safePath("./dist/client", path.split("?")[0].slice("/dist/client".length));
178
- if (resolved) {
179
- const file = Bun.file(resolved);
180
- if (await file.exists()) {
181
- const filename = path.split("/").pop() ?? "";
182
- const isHashed = /\-[a-z0-9]{8,}\.[a-z]+$/.test(filename);
183
- const cacheControl = !isDev && isHashed
184
- ? "public, max-age=31536000, immutable"
185
- : "no-cache";
186
- return new Response(file, { headers: { "Cache-Control": cacheControl } });
187
- }
188
- }
189
- return new Response("Not Found", { status: 404 });
190
- }
191
- const pubPath = safePath("./public", path);
192
- if (pubPath) {
193
- const pub = Bun.file(pubPath);
194
- if (await pub.exists()) return new Response(pub);
195
- }
196
- const distPath = safePath("./dist", path);
197
- if (distPath) {
198
- const dist = Bun.file(distPath);
199
- if (await dist.exists()) return new Response(dist);
200
- }
201
- const staticPath = safePath("./dist/static", path);
202
- if (staticPath) {
203
- const staticFile = Bun.file(staticPath);
204
- if (await staticFile.exists()) return new Response(staticFile);
205
- }
206
- return new Response("Not Found", { status: 404 });
207
- }
208
-
209
- // Prerendered pages — serve static HTML built at build time
210
- const prerenderPath = safePath(
211
- "./dist/prerendered",
212
- path === "/" ? "index.html" : `${path}/index.html`,
213
- );
214
- if (prerenderPath) {
215
- const prerenderFile = Bun.file(prerenderPath);
216
- if (await prerenderFile.exists()) {
217
- return new Response(prerenderFile, {
218
- headers: {
219
- "Content-Type": "text/html; charset=utf-8",
220
- "Cache-Control": "public, max-age=3600",
221
- },
222
- });
223
- }
224
- }
225
-
226
- // API routes (+server.ts)
227
- const apiMatch = findMatch(apiRoutes, path);
228
- if (apiMatch) {
229
- try {
230
- const mod = await apiMatch.route.module();
231
- const handler = mod[method];
232
-
233
- if (!handler) {
234
- const allowed = Object.keys(mod).filter(k => /^[A-Z]+$/.test(k)).join(", ");
235
- return Response.json(
236
- { error: `Method ${method} not allowed` },
237
- { status: 405, headers: { Allow: allowed } },
238
- );
239
- }
240
-
241
- event.params = apiMatch.params;
242
- return await handler({ request, params: apiMatch.params, url, locals, cookies });
243
- } catch (err) {
244
- if (isDev) console.error("API route error:", err);
245
- else console.error("API route error:", (err as Error).message ?? err);
246
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
247
- }
248
- }
249
-
250
- // Form actions POST to page routes with `actions` export
251
- if (method === "POST") {
252
- const pageMatch = findMatch(serverRoutes, path);
253
- if (pageMatch?.route.pageServer) {
254
- try {
255
- const mod = await pageMatch.route.pageServer();
256
- if (mod.actions && typeof mod.actions === "object") {
257
- const actionName = parseActionName(url);
258
- const action = mod.actions[actionName];
259
- if (!action) {
260
- return renderErrorPage(404, `Action "${actionName}" not found`, url, request);
261
- }
262
-
263
- event.params = pageMatch.params;
264
- let result: any;
265
- try {
266
- result = await action(event);
267
- } catch (err) {
268
- if (err instanceof Redirect) {
269
- return new Response(null, {
270
- status: 303,
271
- headers: { Location: err.location },
272
- });
273
- }
274
- if (err instanceof HttpError) {
275
- return renderErrorPage(err.status, err.message, url, request);
276
- }
277
- throw err;
278
- }
279
-
280
- // Redirect returned (not thrown)
281
- if (result instanceof Redirect) {
282
- return new Response(null, {
283
- status: 303,
284
- headers: { Location: result.location },
285
- });
286
- }
287
-
288
- // ActionFailure — re-render with failure status
289
- if (result instanceof ActionFailure) {
290
- return renderPageWithFormData(url, locals, request, cookies, result.data, result.status);
291
- }
292
-
293
- // Success — re-render page with action return data
294
- return renderPageWithFormData(url, locals, request, cookies, result ?? null, 200);
295
- }
296
- } catch (err) {
297
- if (err instanceof Redirect) {
298
- return new Response(null, {
299
- status: 303,
300
- headers: { Location: err.location },
301
- });
302
- }
303
- if (err instanceof HttpError) {
304
- return renderErrorPage(err.status, err.message, url, request);
305
- }
306
- if (isDev) console.error("Form action error:", err);
307
- else console.error("Form action error:", (err as Error).message ?? err);
308
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
309
- }
310
- }
311
- }
312
-
313
- // SSR pages (+page.svelte) — streaming by default
314
- const streamResponse = await renderSSRStream(url, locals, request, cookies);
315
- if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
316
- return streamResponse;
121
+ const { request, url, locals, cookies } = event;
122
+ const path = url.pathname;
123
+ const method = request.method.toUpperCase();
124
+
125
+ // Health check endpoint — for load balancers and orchestrators
126
+ if (path === "/_health") {
127
+ if (shuttingDown) {
128
+ return Response.json({ status: "shutting_down" }, { status: 503 });
129
+ }
130
+ const { timestamp, timezone } = getServerTime();
131
+ return Response.json({ status: "ok", timestamp, timezone });
132
+ }
133
+
134
+ // Data endpoint — returns server loader data as JSON for client-side navigation
135
+ if (path.startsWith("/__bosia/data/")) {
136
+ const routePathStr =
137
+ path
138
+ .slice("/__bosia/data".length)
139
+ .replace(/\.json$/, "")
140
+ .replace(/^\/index$/, "/") || "/";
141
+
142
+ if (!isValidRoutePath(routePathStr, url.origin)) {
143
+ return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
144
+ }
145
+ const routeUrl = new URL(routePathStr, url.origin);
146
+ for (const [key, val] of url.searchParams.entries()) {
147
+ routeUrl.searchParams.append(key, val);
148
+ }
149
+ // Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
150
+ event.url = routeUrl;
151
+ const dedupKeyStr = dedupKey(routeUrl, request);
152
+ try {
153
+ const result = await dedup(dedupKeyStr, async () => {
154
+ const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
155
+ const data = await loadRouteData(routeUrl, locals, request, cookies);
156
+
157
+ let metadata = null;
158
+ if (pageMatch) {
159
+ try {
160
+ const meta = await loadMetadata(
161
+ pageMatch.route,
162
+ pageMatch.params,
163
+ routeUrl,
164
+ locals,
165
+ cookies,
166
+ request,
167
+ );
168
+ if (meta) metadata = { title: meta.title, description: meta.description };
169
+ } catch {
170
+ /* non-fatal */
171
+ }
172
+ }
173
+
174
+ return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
175
+ });
176
+
177
+ const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
178
+ const cc = cookiesWereAccessed
179
+ ? "private, no-cache"
180
+ : "public, max-age=0, must-revalidate";
181
+
182
+ if (!result.data) {
183
+ return compress(
184
+ JSON.stringify({ pageData: {}, layoutData: [] }),
185
+ "application/json",
186
+ request,
187
+ 200,
188
+ { "Cache-Control": cc },
189
+ );
190
+ }
191
+ return compress(
192
+ JSON.stringify({ ...result.data, metadata: result.metadata }),
193
+ "application/json",
194
+ request,
195
+ 200,
196
+ { "Cache-Control": cc },
197
+ );
198
+ } catch (err) {
199
+ if (err instanceof Redirect) {
200
+ return compress(
201
+ JSON.stringify({ redirect: err.location, status: err.status }),
202
+ "application/json",
203
+ request,
204
+ );
205
+ }
206
+ if (err instanceof HttpError) {
207
+ return compress(
208
+ JSON.stringify({ error: err.message, status: err.status }),
209
+ "application/json",
210
+ request,
211
+ err.status,
212
+ );
213
+ }
214
+ if (isDev) console.error("Data endpoint error:", err);
215
+ else console.error("Data endpoint error:", (err as Error).message ?? err);
216
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
217
+ }
218
+ }
219
+
220
+ // Static files
221
+ if (isStaticPath(path)) {
222
+ // dist/client: serve with cache headers based on whether filename is hashed
223
+ if (path.startsWith("/dist/client/")) {
224
+ const resolved = safePath(
225
+ "./dist/client",
226
+ path.split("?")[0].slice("/dist/client".length),
227
+ );
228
+ if (resolved) {
229
+ const file = Bun.file(resolved);
230
+ if (await file.exists()) {
231
+ const filename = path.split("/").pop() ?? "";
232
+ const isHashed = /\-[a-z0-9]{8,}\.[a-z]+$/.test(filename);
233
+ const cacheControl =
234
+ !isDev && isHashed ? "public, max-age=31536000, immutable" : "no-cache";
235
+ return new Response(file, { headers: { "Cache-Control": cacheControl } });
236
+ }
237
+ }
238
+ return new Response("Not Found", { status: 404 });
239
+ }
240
+ const pubPath = safePath("./public", path);
241
+ if (pubPath) {
242
+ const pub = Bun.file(pubPath);
243
+ if (await pub.exists()) return new Response(pub);
244
+ }
245
+ const distPath = safePath("./dist", path);
246
+ if (distPath) {
247
+ const dist = Bun.file(distPath);
248
+ if (await dist.exists()) return new Response(dist);
249
+ }
250
+ const staticPath = safePath("./dist/static", path);
251
+ if (staticPath) {
252
+ const staticFile = Bun.file(staticPath);
253
+ if (await staticFile.exists()) return new Response(staticFile);
254
+ }
255
+ return new Response("Not Found", { status: 404 });
256
+ }
257
+
258
+ // Prerendered pages — serve static HTML built at build time
259
+ // Try both `<path>/index.html` (always/ignore mode) and `<path>.html` (never mode)
260
+ const prerenderCandidates =
261
+ path === "/" ? ["index.html"] : [`${path}/index.html`, `${path.replace(/\/$/, "")}.html`];
262
+ for (const candidate of prerenderCandidates) {
263
+ const prerenderPath = safePath("./dist/prerendered", candidate);
264
+ if (!prerenderPath) continue;
265
+ const prerenderFile = Bun.file(prerenderPath);
266
+ if (await prerenderFile.exists()) {
267
+ return new Response(prerenderFile, {
268
+ headers: {
269
+ "Content-Type": "text/html; charset=utf-8",
270
+ "Cache-Control": "public, max-age=3600",
271
+ },
272
+ });
273
+ }
274
+ }
275
+
276
+ // API routes (+server.ts)
277
+ const apiMatch = findMatch(apiRoutes, path);
278
+ if (apiMatch) {
279
+ try {
280
+ const mod = await apiMatch.route.module();
281
+ const handler = mod[method];
282
+
283
+ if (!handler) {
284
+ const allowed = Object.keys(mod)
285
+ .filter((k) => /^[A-Z]+$/.test(k))
286
+ .join(", ");
287
+ return Response.json(
288
+ { error: `Method ${method} not allowed` },
289
+ { status: 405, headers: { Allow: allowed } },
290
+ );
291
+ }
292
+
293
+ event.params = apiMatch.params;
294
+ return await handler({ request, params: apiMatch.params, url, locals, cookies });
295
+ } catch (err) {
296
+ if (isDev) console.error("API route error:", err);
297
+ else console.error("API route error:", (err as Error).message ?? err);
298
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
299
+ }
300
+ }
301
+
302
+ // Trailing-slash canonicalization — 308 preserves method (form POSTs included)
303
+ const canonicalMatch = findMatch(serverRoutes, path);
304
+ if (canonicalMatch) {
305
+ const canonical = canonicalPathname(
306
+ path,
307
+ (canonicalMatch.route as any).trailingSlash ?? "never",
308
+ );
309
+ if (canonical !== null) {
310
+ return new Response(null, {
311
+ status: 308,
312
+ headers: { Location: canonical + url.search + url.hash },
313
+ });
314
+ }
315
+ }
316
+
317
+ // Form actions POST to page routes with `actions` export
318
+ if (method === "POST") {
319
+ const pageMatch = findMatch(serverRoutes, path);
320
+ if (pageMatch?.route.pageServer) {
321
+ // `use:enhance` sets this header — return JSON instead of re-rendering HTML
322
+ const isEnhanced = request.headers.get("x-bosia-action") === "1";
323
+
324
+ try {
325
+ const mod = await pageMatch.route.pageServer();
326
+ if (mod.actions && typeof mod.actions === "object") {
327
+ const actionName = parseActionName(url);
328
+ const action = mod.actions[actionName];
329
+ if (!action) {
330
+ if (isEnhanced) {
331
+ return Response.json(
332
+ {
333
+ type: "error",
334
+ status: 404,
335
+ message: `Action "${actionName}" not found`,
336
+ },
337
+ { status: 404 },
338
+ );
339
+ }
340
+ return renderErrorPage(
341
+ 404,
342
+ `Action "${actionName}" not found`,
343
+ url,
344
+ request,
345
+ );
346
+ }
347
+
348
+ event.params = pageMatch.params;
349
+ let result: any;
350
+ try {
351
+ result = await action(event);
352
+ } catch (err) {
353
+ if (err instanceof Redirect) {
354
+ if (isEnhanced) {
355
+ return Response.json({
356
+ type: "redirect",
357
+ status: 303,
358
+ location: err.location,
359
+ });
360
+ }
361
+ return new Response(null, {
362
+ status: 303,
363
+ headers: { Location: err.location },
364
+ });
365
+ }
366
+ if (err instanceof HttpError) {
367
+ if (isEnhanced) {
368
+ return Response.json(
369
+ { type: "error", status: err.status, message: err.message },
370
+ { status: err.status },
371
+ );
372
+ }
373
+ return renderErrorPage(err.status, err.message, url, request);
374
+ }
375
+ throw err;
376
+ }
377
+
378
+ // Redirect returned (not thrown)
379
+ if (result instanceof Redirect) {
380
+ if (isEnhanced) {
381
+ return Response.json({
382
+ type: "redirect",
383
+ status: 303,
384
+ location: result.location,
385
+ });
386
+ }
387
+ return new Response(null, {
388
+ status: 303,
389
+ headers: { Location: result.location },
390
+ });
391
+ }
392
+
393
+ // ActionFailure — re-render with failure status
394
+ if (result instanceof ActionFailure) {
395
+ if (isEnhanced) {
396
+ return Response.json(
397
+ { type: "failure", status: result.status, data: result.data },
398
+ { status: result.status },
399
+ );
400
+ }
401
+ return renderPageWithFormData(
402
+ url,
403
+ locals,
404
+ request,
405
+ cookies,
406
+ result.data,
407
+ result.status,
408
+ );
409
+ }
410
+
411
+ // Success — re-render page with action return data
412
+ if (isEnhanced) {
413
+ return Response.json({
414
+ type: "success",
415
+ status: 200,
416
+ data: result ?? null,
417
+ });
418
+ }
419
+ return renderPageWithFormData(
420
+ url,
421
+ locals,
422
+ request,
423
+ cookies,
424
+ result ?? null,
425
+ 200,
426
+ );
427
+ }
428
+ } catch (err) {
429
+ if (err instanceof Redirect) {
430
+ if (isEnhanced) {
431
+ return Response.json({
432
+ type: "redirect",
433
+ status: 303,
434
+ location: err.location,
435
+ });
436
+ }
437
+ return new Response(null, {
438
+ status: 303,
439
+ headers: { Location: err.location },
440
+ });
441
+ }
442
+ if (err instanceof HttpError) {
443
+ if (isEnhanced) {
444
+ return Response.json(
445
+ { type: "error", status: err.status, message: err.message },
446
+ { status: err.status },
447
+ );
448
+ }
449
+ return renderErrorPage(err.status, err.message, url, request);
450
+ }
451
+ if (isDev) console.error("Form action error:", err);
452
+ else console.error("Form action error:", (err as Error).message ?? err);
453
+ if (isEnhanced) {
454
+ return Response.json(
455
+ { type: "error", status: 500, message: "Internal Server Error" },
456
+ { status: 500 },
457
+ );
458
+ }
459
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
460
+ }
461
+ }
462
+ }
463
+
464
+ // SSR pages (+page.svelte) — streaming by default
465
+ const streamResponse = await renderSSRStream(url, locals, request, cookies);
466
+ if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
467
+ return streamResponse;
317
468
  }
318
469
 
319
470
  // ─── Request Entry ────────────────────────────────────────
320
471
 
321
472
  const SECURITY_HEADERS: Record<string, string> = {
322
- "X-Content-Type-Options": "nosniff",
323
- "X-Frame-Options": "SAMEORIGIN",
324
- "Referrer-Policy": "strict-origin-when-cross-origin",
473
+ "X-Content-Type-Options": "nosniff",
474
+ "X-Frame-Options": "SAMEORIGIN",
475
+ "Referrer-Policy": "strict-origin-when-cross-origin",
325
476
  };
326
477
 
327
478
  async function handleRequest(request: Request, url: URL): Promise<Response> {
328
- // Reject new non-health requests during shutdown
329
- if (shuttingDown && url.pathname !== "/_health") {
330
- return new Response("Service Unavailable", {
331
- status: 503,
332
- headers: { "Retry-After": "5" },
333
- });
334
- }
335
-
336
- inFlight++;
337
- try {
338
- // Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
339
- if (CORS_CONFIG && request.method === "OPTIONS") {
340
- const preflight = handlePreflight(request, CORS_CONFIG);
341
- if (preflight) return preflight;
342
- }
343
-
344
- const csrfError = checkCsrf(request, url, CSRF_CONFIG);
345
- if (csrfError) {
346
- console.warn(`[CSRF] Blocked request: ${csrfError}`);
347
- return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
348
- }
349
-
350
- const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
351
- const event: RequestEvent = { request, url, locals: {}, params: {}, cookies: cookieJar };
352
- const response = userHandle
353
- ? await userHandle({ event, resolve })
354
- : await resolve(event);
355
-
356
- const headers = new Headers(response.headers);
357
- for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
358
- // Apply CORS headers for allowed origins
359
- if (CORS_CONFIG) {
360
- const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
361
- if (corsHeaders) {
362
- for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
363
- }
364
- }
365
- // Apply any Set-Cookie headers accumulated during the request
366
- for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
367
- return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
368
- } catch (err) {
369
- if (isDev) console.error("Unhandled request error:", err);
370
- else console.error("Unhandled request error:", (err as Error).message ?? err);
371
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
372
- } finally {
373
- inFlight--;
374
- if (shuttingDown && inFlight === 0 && drainResolve) {
375
- drainResolve();
376
- }
377
- }
479
+ // Reject new non-health requests during shutdown
480
+ if (shuttingDown && url.pathname !== "/_health") {
481
+ return new Response("Service Unavailable", {
482
+ status: 503,
483
+ headers: { "Retry-After": "5" },
484
+ });
485
+ }
486
+
487
+ inFlight++;
488
+ try {
489
+ // Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
490
+ if (CORS_CONFIG && request.method === "OPTIONS") {
491
+ const preflight = handlePreflight(request, CORS_CONFIG);
492
+ if (preflight) return preflight;
493
+ }
494
+
495
+ const csrfError = checkCsrf(request, url, CSRF_CONFIG);
496
+ if (csrfError) {
497
+ console.warn(`[CSRF] Blocked request: ${csrfError}`);
498
+ return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
499
+ }
500
+
501
+ const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
502
+ const event: RequestEvent = { request, url, locals: {}, params: {}, cookies: cookieJar };
503
+ const response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
504
+
505
+ const headers = new Headers(response.headers);
506
+ for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
507
+ // Apply CORS headers for allowed origins
508
+ if (CORS_CONFIG) {
509
+ const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
510
+ if (corsHeaders) {
511
+ for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
512
+ }
513
+ }
514
+ // Apply any Set-Cookie headers accumulated during the request
515
+ for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
516
+ return new Response(response.body, {
517
+ status: response.status,
518
+ statusText: response.statusText,
519
+ headers,
520
+ });
521
+ } catch (err) {
522
+ if (isDev) console.error("Unhandled request error:", err);
523
+ else console.error("Unhandled request error:", (err as Error).message ?? err);
524
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
525
+ } finally {
526
+ inFlight--;
527
+ if (shuttingDown && inFlight === 0 && drainResolve) {
528
+ drainResolve();
529
+ }
530
+ }
531
+ }
532
+
533
+ // ─── CORS Max Age ─────────────────────────────────────────
534
+
535
+ function parseCorsMaxAge(value?: string): number | undefined {
536
+ if (!value) return undefined;
537
+ if (!/^\d+$/.test(value)) {
538
+ throw new Error(
539
+ `Invalid CORS_MAX_AGE: "${value}" — must be a non-negative integer (seconds)`,
540
+ );
541
+ }
542
+ const n = parseInt(value, 10);
543
+ if (!Number.isFinite(n) || n > Number.MAX_SAFE_INTEGER) {
544
+ throw new Error(
545
+ `Invalid CORS_MAX_AGE: "${value}" — must be a non-negative integer (seconds)`,
546
+ );
547
+ }
548
+ return n;
378
549
  }
379
550
 
380
551
  // ─── Body Size Limit ──────────────────────────────────────
@@ -383,24 +554,24 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
383
554
  // Default: 512K (matches SvelteKit).
384
555
 
385
556
  function parseBodySizeLimit(value?: string): number {
386
- if (!value) return 512 * 1024;
387
- if (value === "Infinity") return 0; // Bun: 0 = no limit
388
- const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
389
- if (!match) throw new Error(`Invalid BODY_SIZE_LIMIT: "${value}"`);
390
- const num = parseFloat(match[1]);
391
- const unit = match[2].toUpperCase();
392
- if (unit === "K") return Math.floor(num * 1024);
393
- if (unit === "M") return Math.floor(num * 1024 * 1024);
394
- if (unit === "G") return Math.floor(num * 1024 * 1024 * 1024);
395
- return Math.floor(num);
557
+ if (!value) return 512 * 1024;
558
+ if (value === "Infinity") return 0; // Bun: 0 = no limit
559
+ const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
560
+ if (!match) throw new Error(`Invalid BODY_SIZE_LIMIT: "${value}"`);
561
+ const num = parseFloat(match[1]);
562
+ const unit = match[2].toUpperCase();
563
+ if (unit === "K") return Math.floor(num * 1024);
564
+ if (unit === "M") return Math.floor(num * 1024 * 1024);
565
+ if (unit === "G") return Math.floor(num * 1024 * 1024 * 1024);
566
+ return Math.floor(num);
396
567
  }
397
568
 
398
569
  const BODY_SIZE_LIMIT = parseBodySizeLimit(process.env.BODY_SIZE_LIMIT);
399
570
 
400
571
  if (BODY_SIZE_LIMIT === 0) {
401
- console.log("📦 Body size limit: none");
572
+ console.log("📦 Body size limit: none");
402
573
  } else {
403
- console.log(`📦 Body size limit: ${BODY_SIZE_LIMIT} bytes`);
574
+ console.log(`📦 Body size limit: ${BODY_SIZE_LIMIT} bytes`);
404
575
  }
405
576
 
406
577
  // ─── Graceful Shutdown State ──────────────────────────────
@@ -411,73 +582,75 @@ let drainResolve: (() => void) | null = null;
411
582
 
412
583
  // ─── Elysia App ───────────────────────────────────────────
413
584
 
414
- const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : (isDev ? 9001 : 9000);
585
+ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 : 9000;
415
586
 
416
587
  const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
417
- .onError(({ error }) => {
418
- if (isDev) console.error("Uncaught server error:", error);
419
- else console.error("Uncaught server error:", (error as Error)?.message ?? error);
420
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
421
- })
422
- // Static files are served by resolve() with path traversal protection and security headers
423
- // API routes must intercept all HTTP methods before the GET catch-all
424
- .onBeforeHandle(async ({ request }) => {
425
- const url = new URL(request.url);
426
- if (!findMatch(apiRoutes, url.pathname)) return; // not an API route
427
- return handleRequest(request, url);
428
- })
429
- // SSR pages
430
- .get("*", ({ request }) => {
431
- const url = new URL(request.url);
432
- return handleRequest(request, url);
433
- })
434
- // Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
435
- .post("*", ({ request }) => {
436
- const url = new URL(request.url);
437
- return handleRequest(request, url);
438
- })
439
- .put("*", ({ request }) => {
440
- const url = new URL(request.url);
441
- return handleRequest(request, url);
442
- })
443
- .patch("*", ({ request }) => {
444
- const url = new URL(request.url);
445
- return handleRequest(request, url);
446
- })
447
- .delete("*", ({ request }) => {
448
- const url = new URL(request.url);
449
- return handleRequest(request, url);
450
- })
451
- .options("*", ({ request }) => {
452
- const url = new URL(request.url);
453
- return handleRequest(request, url);
454
- });
588
+ .onError(({ error }) => {
589
+ if (isDev) console.error("Uncaught server error:", error);
590
+ else console.error("Uncaught server error:", (error as Error)?.message ?? error);
591
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
592
+ })
593
+ // Static files are served by resolve() with path traversal protection and security headers
594
+ // API routes must intercept all HTTP methods before the GET catch-all
595
+ .onBeforeHandle(async ({ request }) => {
596
+ const url = new URL(request.url);
597
+ if (!findMatch(apiRoutes, url.pathname)) return; // not an API route
598
+ return handleRequest(request, url);
599
+ })
600
+ // SSR pages
601
+ .get("*", ({ request }) => {
602
+ const url = new URL(request.url);
603
+ return handleRequest(request, url);
604
+ })
605
+ // Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
606
+ .post("*", ({ request }) => {
607
+ const url = new URL(request.url);
608
+ return handleRequest(request, url);
609
+ })
610
+ .put("*", ({ request }) => {
611
+ const url = new URL(request.url);
612
+ return handleRequest(request, url);
613
+ })
614
+ .patch("*", ({ request }) => {
615
+ const url = new URL(request.url);
616
+ return handleRequest(request, url);
617
+ })
618
+ .delete("*", ({ request }) => {
619
+ const url = new URL(request.url);
620
+ return handleRequest(request, url);
621
+ })
622
+ .options("*", ({ request }) => {
623
+ const url = new URL(request.url);
624
+ return handleRequest(request, url);
625
+ });
455
626
 
456
627
  app.listen(PORT, () => {
457
- // In dev mode the proxy owns the user-facing port — don't print the internal port
458
- if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
628
+ // In dev mode the proxy owns the user-facing port — don't print the internal port
629
+ if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
459
630
  });
460
631
 
461
632
  async function shutdown() {
462
- if (shuttingDown) return;
463
- shuttingDown = true;
464
- console.log("⏳ Shutting down — draining in-flight requests...");
465
-
466
- if (inFlight > 0) {
467
- await Promise.race([
468
- new Promise<void>(r => { drainResolve = r; }),
469
- Bun.sleep(10_000),
470
- ]);
471
- }
472
-
473
- if (inFlight > 0) {
474
- console.warn(`⚠️ Force shutdown with ${inFlight} request(s) still in flight`);
475
- } else {
476
- console.log("✅ All requests drained");
477
- }
478
-
479
- app.stop().then(() => process.exit(0));
480
- setTimeout(() => process.exit(1), 5_000);
633
+ if (shuttingDown) return;
634
+ shuttingDown = true;
635
+ console.log("⏳ Shutting down — draining in-flight requests...");
636
+
637
+ if (inFlight > 0) {
638
+ await Promise.race([
639
+ new Promise<void>((r) => {
640
+ drainResolve = r;
641
+ }),
642
+ Bun.sleep(10_000),
643
+ ]);
644
+ }
645
+
646
+ if (inFlight > 0) {
647
+ console.warn(`⚠️ Force shutdown with ${inFlight} request(s) still in flight`);
648
+ } else {
649
+ console.log("✅ All requests drained");
650
+ }
651
+
652
+ app.stop().then(() => process.exit(0));
653
+ setTimeout(() => process.exit(1), 5_000);
481
654
  }
482
655
 
483
656
  process.on("SIGTERM", shutdown);