bosia 0.2.3 → 0.3.1

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 (86) hide show
  1. package/README.md +39 -39
  2. package/package.json +56 -54
  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 +7 -9
  8. package/src/cli/feat.ts +266 -258
  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 -156
  15. package/src/core/client/appState.svelte.ts +33 -31
  16. package/src/core/client/enhance.ts +83 -78
  17. package/src/core/client/hydrate.ts +95 -81
  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 +196 -168
  25. package/src/core/env.ts +160 -148
  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 +184 -145
  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 -122
  34. package/src/core/renderer.ts +359 -286
  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 +538 -424
  39. package/src/core/types.ts +25 -20
  40. package/src/lib/index.ts +8 -8
  41. package/src/lib/utils.ts +44 -30
  42. package/templates/default/.prettierignore +5 -0
  43. package/templates/default/.prettierrc.json +9 -0
  44. package/templates/default/README.md +5 -5
  45. package/templates/default/package.json +22 -18
  46. package/templates/default/src/app.css +80 -80
  47. package/templates/default/src/app.d.ts +3 -3
  48. package/templates/default/src/routes/+error.svelte +7 -10
  49. package/templates/default/src/routes/+layout.svelte +2 -2
  50. package/templates/default/src/routes/+page.svelte +30 -32
  51. package/templates/default/src/routes/about/+page.svelte +3 -3
  52. package/templates/default/tsconfig.json +20 -20
  53. package/templates/demo/.prettierignore +5 -0
  54. package/templates/demo/.prettierrc.json +9 -0
  55. package/templates/demo/README.md +9 -9
  56. package/templates/demo/package.json +22 -17
  57. package/templates/demo/src/app.css +80 -80
  58. package/templates/demo/src/app.d.ts +3 -3
  59. package/templates/demo/src/hooks.server.ts +9 -9
  60. package/templates/demo/src/routes/(public)/+layout.svelte +45 -23
  61. package/templates/demo/src/routes/(public)/+page.svelte +96 -67
  62. package/templates/demo/src/routes/(public)/about/+page.svelte +13 -25
  63. package/templates/demo/src/routes/(public)/all/[...catchall]/+page.svelte +24 -28
  64. package/templates/demo/src/routes/(public)/blog/+page.svelte +55 -46
  65. package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +36 -38
  66. package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +60 -42
  67. package/templates/demo/src/routes/+error.svelte +10 -7
  68. package/templates/demo/src/routes/+layout.server.ts +4 -4
  69. package/templates/demo/src/routes/+layout.svelte +2 -2
  70. package/templates/demo/src/routes/actions-test/+page.server.ts +16 -16
  71. package/templates/demo/src/routes/actions-test/+page.svelte +49 -49
  72. package/templates/demo/src/routes/api/hello/+server.ts +25 -25
  73. package/templates/demo/tsconfig.json +20 -20
  74. package/templates/todo/.prettierignore +5 -0
  75. package/templates/todo/.prettierrc.json +9 -0
  76. package/templates/todo/README.md +9 -9
  77. package/templates/todo/package.json +22 -17
  78. package/templates/todo/src/app.css +80 -80
  79. package/templates/todo/src/app.d.ts +7 -7
  80. package/templates/todo/src/hooks.server.ts +9 -9
  81. package/templates/todo/src/routes/+error.svelte +10 -7
  82. package/templates/todo/src/routes/+layout.server.ts +4 -4
  83. package/templates/todo/src/routes/+layout.svelte +2 -2
  84. package/templates/todo/src/routes/+page.svelte +44 -44
  85. package/templates/todo/template.json +1 -1
  86. 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,374 +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: parseCorsMaxAge(process.env.CORS_MAX_AGE),
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
- // `use:enhance` sets this header — return JSON instead of re-rendering HTML
255
- const isEnhanced = request.headers.get("x-bosia-action") === "1";
256
-
257
- try {
258
- const mod = await pageMatch.route.pageServer();
259
- if (mod.actions && typeof mod.actions === "object") {
260
- const actionName = parseActionName(url);
261
- const action = mod.actions[actionName];
262
- if (!action) {
263
- if (isEnhanced) {
264
- return Response.json(
265
- { type: "error", status: 404, message: `Action "${actionName}" not found` },
266
- { status: 404 },
267
- );
268
- }
269
- return renderErrorPage(404, `Action "${actionName}" not found`, url, request);
270
- }
271
-
272
- event.params = pageMatch.params;
273
- let result: any;
274
- try {
275
- result = await action(event);
276
- } catch (err) {
277
- if (err instanceof Redirect) {
278
- if (isEnhanced) {
279
- return Response.json({ type: "redirect", status: 303, location: err.location });
280
- }
281
- return new Response(null, {
282
- status: 303,
283
- headers: { Location: err.location },
284
- });
285
- }
286
- if (err instanceof HttpError) {
287
- if (isEnhanced) {
288
- return Response.json(
289
- { type: "error", status: err.status, message: err.message },
290
- { status: err.status },
291
- );
292
- }
293
- return renderErrorPage(err.status, err.message, url, request);
294
- }
295
- throw err;
296
- }
297
-
298
- // Redirect returned (not thrown)
299
- if (result instanceof Redirect) {
300
- if (isEnhanced) {
301
- return Response.json({ type: "redirect", status: 303, location: result.location });
302
- }
303
- return new Response(null, {
304
- status: 303,
305
- headers: { Location: result.location },
306
- });
307
- }
308
-
309
- // ActionFailure — re-render with failure status
310
- if (result instanceof ActionFailure) {
311
- if (isEnhanced) {
312
- return Response.json(
313
- { type: "failure", status: result.status, data: result.data },
314
- { status: result.status },
315
- );
316
- }
317
- return renderPageWithFormData(url, locals, request, cookies, result.data, result.status);
318
- }
319
-
320
- // Success — re-render page with action return data
321
- if (isEnhanced) {
322
- return Response.json({ type: "success", status: 200, data: result ?? null });
323
- }
324
- return renderPageWithFormData(url, locals, request, cookies, result ?? null, 200);
325
- }
326
- } catch (err) {
327
- if (err instanceof Redirect) {
328
- if (isEnhanced) {
329
- return Response.json({ type: "redirect", status: 303, location: err.location });
330
- }
331
- return new Response(null, {
332
- status: 303,
333
- headers: { Location: err.location },
334
- });
335
- }
336
- if (err instanceof HttpError) {
337
- if (isEnhanced) {
338
- return Response.json(
339
- { type: "error", status: err.status, message: err.message },
340
- { status: err.status },
341
- );
342
- }
343
- return renderErrorPage(err.status, err.message, url, request);
344
- }
345
- if (isDev) console.error("Form action error:", err);
346
- else console.error("Form action error:", (err as Error).message ?? err);
347
- if (isEnhanced) {
348
- return Response.json(
349
- { type: "error", status: 500, message: "Internal Server Error" },
350
- { status: 500 },
351
- );
352
- }
353
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
354
- }
355
- }
356
- }
357
-
358
- // SSR pages (+page.svelte) streaming by default
359
- const streamResponse = await renderSSRStream(url, locals, request, cookies);
360
- if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
361
- 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;
362
468
  }
363
469
 
364
470
  // ─── Request Entry ────────────────────────────────────────
365
471
 
366
472
  const SECURITY_HEADERS: Record<string, string> = {
367
- "X-Content-Type-Options": "nosniff",
368
- "X-Frame-Options": "SAMEORIGIN",
369
- "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",
370
476
  };
371
477
 
372
478
  async function handleRequest(request: Request, url: URL): Promise<Response> {
373
- // Reject new non-health requests during shutdown
374
- if (shuttingDown && url.pathname !== "/_health") {
375
- return new Response("Service Unavailable", {
376
- status: 503,
377
- headers: { "Retry-After": "5" },
378
- });
379
- }
380
-
381
- inFlight++;
382
- try {
383
- // Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
384
- if (CORS_CONFIG && request.method === "OPTIONS") {
385
- const preflight = handlePreflight(request, CORS_CONFIG);
386
- if (preflight) return preflight;
387
- }
388
-
389
- const csrfError = checkCsrf(request, url, CSRF_CONFIG);
390
- if (csrfError) {
391
- console.warn(`[CSRF] Blocked request: ${csrfError}`);
392
- return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
393
- }
394
-
395
- const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
396
- const event: RequestEvent = { request, url, locals: {}, params: {}, cookies: cookieJar };
397
- const response = userHandle
398
- ? await userHandle({ event, resolve })
399
- : await resolve(event);
400
-
401
- const headers = new Headers(response.headers);
402
- for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
403
- // Apply CORS headers for allowed origins
404
- if (CORS_CONFIG) {
405
- const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
406
- if (corsHeaders) {
407
- for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
408
- }
409
- }
410
- // Apply any Set-Cookie headers accumulated during the request
411
- for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
412
- return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
413
- } catch (err) {
414
- if (isDev) console.error("Unhandled request error:", err);
415
- else console.error("Unhandled request error:", (err as Error).message ?? err);
416
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
417
- } finally {
418
- inFlight--;
419
- if (shuttingDown && inFlight === 0 && drainResolve) {
420
- drainResolve();
421
- }
422
- }
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
+ }
423
531
  }
424
532
 
425
533
  // ─── CORS Max Age ─────────────────────────────────────────
426
534
 
427
535
  function parseCorsMaxAge(value?: string): number | undefined {
428
- if (!value) return undefined;
429
- if (!/^\d+$/.test(value)) {
430
- throw new Error(`Invalid CORS_MAX_AGE: "${value}" — must be a non-negative integer (seconds)`);
431
- }
432
- const n = parseInt(value, 10);
433
- if (!Number.isFinite(n) || n > Number.MAX_SAFE_INTEGER) {
434
- throw new Error(`Invalid CORS_MAX_AGE: "${value}" — must be a non-negative integer (seconds)`);
435
- }
436
- return n;
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;
437
549
  }
438
550
 
439
551
  // ─── Body Size Limit ──────────────────────────────────────
@@ -442,24 +554,24 @@ function parseCorsMaxAge(value?: string): number | undefined {
442
554
  // Default: 512K (matches SvelteKit).
443
555
 
444
556
  function parseBodySizeLimit(value?: string): number {
445
- if (!value) return 512 * 1024;
446
- if (value === "Infinity") return 0; // Bun: 0 = no limit
447
- const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
448
- if (!match) throw new Error(`Invalid BODY_SIZE_LIMIT: "${value}"`);
449
- const num = parseFloat(match[1]);
450
- const unit = match[2].toUpperCase();
451
- if (unit === "K") return Math.floor(num * 1024);
452
- if (unit === "M") return Math.floor(num * 1024 * 1024);
453
- if (unit === "G") return Math.floor(num * 1024 * 1024 * 1024);
454
- 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);
455
567
  }
456
568
 
457
569
  const BODY_SIZE_LIMIT = parseBodySizeLimit(process.env.BODY_SIZE_LIMIT);
458
570
 
459
571
  if (BODY_SIZE_LIMIT === 0) {
460
- console.log("📦 Body size limit: none");
572
+ console.log("📦 Body size limit: none");
461
573
  } else {
462
- console.log(`📦 Body size limit: ${BODY_SIZE_LIMIT} bytes`);
574
+ console.log(`📦 Body size limit: ${BODY_SIZE_LIMIT} bytes`);
463
575
  }
464
576
 
465
577
  // ─── Graceful Shutdown State ──────────────────────────────
@@ -470,73 +582,75 @@ let drainResolve: (() => void) | null = null;
470
582
 
471
583
  // ─── Elysia App ───────────────────────────────────────────
472
584
 
473
- 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;
474
586
 
475
587
  const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
476
- .onError(({ error }) => {
477
- if (isDev) console.error("Uncaught server error:", error);
478
- else console.error("Uncaught server error:", (error as Error)?.message ?? error);
479
- return Response.json({ error: "Internal Server Error" }, { status: 500 });
480
- })
481
- // Static files are served by resolve() with path traversal protection and security headers
482
- // API routes must intercept all HTTP methods before the GET catch-all
483
- .onBeforeHandle(async ({ request }) => {
484
- const url = new URL(request.url);
485
- if (!findMatch(apiRoutes, url.pathname)) return; // not an API route
486
- return handleRequest(request, url);
487
- })
488
- // SSR pages
489
- .get("*", ({ request }) => {
490
- const url = new URL(request.url);
491
- return handleRequest(request, url);
492
- })
493
- // Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
494
- .post("*", ({ request }) => {
495
- const url = new URL(request.url);
496
- return handleRequest(request, url);
497
- })
498
- .put("*", ({ request }) => {
499
- const url = new URL(request.url);
500
- return handleRequest(request, url);
501
- })
502
- .patch("*", ({ request }) => {
503
- const url = new URL(request.url);
504
- return handleRequest(request, url);
505
- })
506
- .delete("*", ({ request }) => {
507
- const url = new URL(request.url);
508
- return handleRequest(request, url);
509
- })
510
- .options("*", ({ request }) => {
511
- const url = new URL(request.url);
512
- return handleRequest(request, url);
513
- });
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
+ });
514
626
 
515
627
  app.listen(PORT, () => {
516
- // In dev mode the proxy owns the user-facing port — don't print the internal port
517
- 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}`);
518
630
  });
519
631
 
520
632
  async function shutdown() {
521
- if (shuttingDown) return;
522
- shuttingDown = true;
523
- console.log("⏳ Shutting down — draining in-flight requests...");
524
-
525
- if (inFlight > 0) {
526
- await Promise.race([
527
- new Promise<void>(r => { drainResolve = r; }),
528
- Bun.sleep(10_000),
529
- ]);
530
- }
531
-
532
- if (inFlight > 0) {
533
- console.warn(`⚠️ Force shutdown with ${inFlight} request(s) still in flight`);
534
- } else {
535
- console.log("✅ All requests drained");
536
- }
537
-
538
- app.stop().then(() => process.exit(0));
539
- 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);
540
654
  }
541
655
 
542
656
  process.on("SIGTERM", shutdown);