bunigniter 0.2.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/LICENSE +21 -0
  4. package/dist/README.md +229 -0
  5. package/dist/base/controller.ts +324 -0
  6. package/dist/base/index.ts +5 -0
  7. package/dist/base/service.ts +21 -0
  8. package/dist/cli/index.ts +318 -0
  9. package/dist/cli/list-routes.ts +72 -0
  10. package/dist/cli/repl.ts +461 -0
  11. package/dist/cli/templates.ts +283 -0
  12. package/dist/client/index.ts +159 -0
  13. package/dist/db/drizzle.ts +550 -0
  14. package/dist/db/validators.ts +229 -0
  15. package/dist/edge-builder.ts +120 -0
  16. package/dist/edge.ts +69 -0
  17. package/dist/helpers/cache.ts +173 -0
  18. package/dist/helpers/cors.ts +103 -0
  19. package/dist/helpers/csrf.ts +155 -0
  20. package/dist/helpers/debug.ts +158 -0
  21. package/dist/helpers/env.ts +147 -0
  22. package/dist/helpers/handler.ts +158 -0
  23. package/dist/helpers/http.ts +194 -0
  24. package/dist/helpers/image.ts +217 -0
  25. package/dist/helpers/jwt.ts +147 -0
  26. package/dist/helpers/logger.ts +96 -0
  27. package/dist/helpers/mail.ts +272 -0
  28. package/dist/helpers/middleware-loader.ts +116 -0
  29. package/dist/helpers/middleware.ts +57 -0
  30. package/dist/helpers/modules.ts +115 -0
  31. package/dist/helpers/openapi.ts +140 -0
  32. package/dist/helpers/pagination.ts +159 -0
  33. package/dist/helpers/queue.ts +186 -0
  34. package/dist/helpers/request-context.ts +13 -0
  35. package/dist/helpers/request.ts +376 -0
  36. package/dist/helpers/schedule.ts +173 -0
  37. package/dist/helpers/session-middleware.ts +89 -0
  38. package/dist/helpers/session.ts +286 -0
  39. package/dist/helpers/sse.ts +90 -0
  40. package/dist/helpers/throttle.ts +156 -0
  41. package/dist/helpers/upload.ts +417 -0
  42. package/dist/helpers/validator.ts +287 -0
  43. package/dist/helpers/ws.ts +123 -0
  44. package/dist/index.ts +221 -0
  45. package/dist/package.json +70 -0
  46. package/dist/router/file-router.ts +541 -0
  47. package/dist/router/server-router.ts +103 -0
  48. package/dist/view/page.ts +96 -0
  49. package/dist/view/renderer.tsx +390 -0
  50. package/dist/view/view-response.ts +10 -0
  51. package/package.json +70 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * CSRF middleware — Cross-Site Request Forgery protection.
3
+ *
4
+ * Generates and validates CSRF tokens for state-changing requests (POST, PUT, DELETE, PATCH).
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * app.use(csrfMiddleware())
9
+ * ```
10
+ *
11
+ * Then in forms:
12
+ * ```html
13
+ * <input type="hidden" name="_token" value="{{csrfToken}}">
14
+ * ```
15
+ *
16
+ * Or in fetch:
17
+ * ```ts
18
+ * fetch('/api/users', {
19
+ * method: 'POST',
20
+ * headers: { 'X-CSRF-Token': csrfToken }
21
+ * })
22
+ * ```
23
+ */
24
+ import { Elysia } from 'elysia'
25
+ import { env } from './env'
26
+
27
+ export interface CSRFOptions {
28
+ /** Secret key for token signing. Default: APP_KEY */
29
+ secret?: string
30
+
31
+ /** Cookie name for storing the token. Default: 'XSRF-TOKEN' */
32
+ cookieName?: string
33
+
34
+ /** Header name for token verification. Default: 'X-CSRF-Token' */
35
+ headerName?: string
36
+
37
+ /** Form field name for token verification. Default: '_token' */
38
+ formField?: string
39
+
40
+ /** Methods that require CSRF protection. Default: ['POST', 'PUT', 'PATCH', 'DELETE'] */
41
+ protectedMethods?: string[]
42
+
43
+ /** Paths to exclude from CSRF protection. */
44
+ exclude?: string[]
45
+ }
46
+
47
+ /**
48
+ * Create a CSRF protection middleware.
49
+ */
50
+ export function csrfMiddleware(options: CSRFOptions = {}) {
51
+ const {
52
+ secret = env('APP_KEY', 'dev-csrf-secret'),
53
+ cookieName = 'XSRF-TOKEN',
54
+ headerName = 'X-CSRF-Token',
55
+ formField = '_token',
56
+ protectedMethods = ['POST', 'PUT', 'PATCH', 'DELETE'],
57
+ exclude = [],
58
+ } = options
59
+
60
+ const app = new Elysia({ name: 'nexus-csrf' })
61
+
62
+ app.derive(async ({ request, cookie: cookieJar }: any) => {
63
+ const url = new URL(request.url)
64
+ const path = url.pathname
65
+
66
+ // Generate CSRF token
67
+ const token = generateToken(secret)
68
+ const previousToken = cookieJar?.[cookieName]?.value
69
+
70
+ return {
71
+ csrfToken: token,
72
+ csrf: {
73
+ /**
74
+ * Get the current CSRF token value.
75
+ */
76
+ token: () => token,
77
+
78
+ /**
79
+ * Validate the request's CSRF token.
80
+ * Call this in your handler before mutations.
81
+ */
82
+ validate: (request: Request) => {
83
+ // Skip excluded paths
84
+ if (exclude.some((s) => path.startsWith(s))) return true
85
+
86
+ // Only check protected methods
87
+ if (!protectedMethods.includes(request.method)) return true
88
+
89
+ const tokenHeader = request.headers.get(headerName)
90
+ const formData = request.headers.get('content-type')?.includes('urlencoded')
91
+ let tokenValue = tokenHeader
92
+
93
+ // Check form field
94
+ if (!tokenValue && formData) {
95
+ // _token is handled differently for form data
96
+ }
97
+
98
+ // Check cookie-based token (XSRF-TOKEN)
99
+ const cookieToken = previousToken
100
+
101
+ // If header token matches cookie token, it's valid
102
+ if (tokenValue && cookieToken && constantTimeCompare(tokenValue, cookieToken)) {
103
+ return true
104
+ }
105
+
106
+ return false
107
+ },
108
+ },
109
+ }
110
+ })
111
+
112
+ // Set CSRF cookie on every response
113
+ app.afterResponse(({ csrfToken, cookie: cookieJar }: any) => {
114
+ if (csrfToken && cookieJar?.[cookieName]) {
115
+ cookieJar[cookieName].value = csrfToken
116
+ cookieJar[cookieName].path = '/'
117
+ cookieJar[cookieName].httpOnly = false // Must be readable by JS
118
+ cookieJar[cookieName].sameSite = 'Lax'
119
+ }
120
+ })
121
+
122
+ return app
123
+ }
124
+
125
+ /**
126
+ * Generate a CSRF token.
127
+ * Combines a random value with an HMAC signature.
128
+ */
129
+ function generateToken(secret: string): string {
130
+ const random = crypto.randomUUID().replace(/-/g, '')
131
+ const timestamp = Math.floor(Date.now() / 1000).toString(16)
132
+ const payload = `${timestamp}.${random}`
133
+ const sig = sign(payload, secret)
134
+ return `${payload}.${sig}`
135
+ }
136
+
137
+ /**
138
+ * Sign a value with HMAC-SHA256.
139
+ */
140
+ function sign(value: string, secret: string): string {
141
+ const { createHmac } = require('node:crypto')
142
+ return createHmac('sha256', secret).update(value).digest('hex').slice(0, 16)
143
+ }
144
+
145
+ /**
146
+ * Constant-time string comparison.
147
+ */
148
+ function constantTimeCompare(a: string, b: string): boolean {
149
+ if (a.length !== b.length) return false
150
+ let result = 0
151
+ for (let i = 0; i < a.length; i++) {
152
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i)
153
+ }
154
+ return result === 0
155
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Debug Toolbar — CodeIgniter-style profiler.
3
+ *
4
+ * Collects SQL queries, timing, session data and injects a
5
+ * collapsible toolbar into HTML responses.
6
+ *
7
+ * Enable: `?debug=1` or `DEBUG=true` env var.
8
+ *
9
+ * To add SQL profiling, wrap your DB queries:
10
+ * ```ts
11
+ * import { debugQuery } from './debug'
12
+ * const start = performance.now()
13
+ * // ... execute query ...
14
+ * debugQuery(ctx, 'SELECT * FROM users', duration, rowCount)
15
+ * ```
16
+ */
17
+
18
+ // ─── Types ─────────────────────────────────────────────────────
19
+
20
+ export interface DebugQuery {
21
+ id: number
22
+ sql: string
23
+ duration: number
24
+ rows: number
25
+ params?: unknown[]
26
+ time: string
27
+ }
28
+
29
+ export interface DebugData {
30
+ method: string
31
+ path: string
32
+ status: number
33
+ duration: number
34
+ queries: DebugQuery[]
35
+ session: Record<string, any>
36
+ memory: string
37
+ headers: Record<string, string>
38
+ timestamp: string
39
+ }
40
+
41
+ // ─── Store ─────────────────────────────────────────────────────
42
+
43
+ const store = new WeakMap<any, DebugData>()
44
+
45
+ /** Store a debug data object for the current request context. */
46
+ export function getStore(ctx: any): DebugData {
47
+ let d = store.get(ctx)
48
+ if (!d) {
49
+ d = {
50
+ method: ctx.request?.method ?? 'GET',
51
+ path: new URL(ctx.request?.url ?? 'http://localhost').pathname,
52
+ status: 200,
53
+ duration: 0,
54
+ queries: [],
55
+ session: {},
56
+ memory: '0 MB',
57
+ headers: {},
58
+ timestamp: new Date().toLocaleString(),
59
+ }
60
+ store.set(ctx, d)
61
+ }
62
+ return d
63
+ }
64
+
65
+ /** Log a database query for profiling. */
66
+ export function debugQuery(ctx: any, sql: string, duration: number, rows: number, params?: unknown[]): void {
67
+ const data = getStore(ctx)
68
+ data.queries.push({
69
+ id: data.queries.length + 1,
70
+ sql,
71
+ duration: Math.round(duration * 100) / 100,
72
+ rows,
73
+ params,
74
+ time: new Date().toLocaleTimeString(),
75
+ })
76
+ }
77
+
78
+ // ─── Toolbar HTML (rendered via Rendu template) ──────────────
79
+
80
+ import { readFileSync, existsSync } from 'node:fs'
81
+ import { join } from 'node:path'
82
+
83
+ /**
84
+ * Render the debug toolbar using the Rendu template at views/_debug.html.
85
+ * Falls back to inline generation if template not found.
86
+ */
87
+ export async function generateToolbar(data: DebugData): Promise<string> {
88
+ // Try to use the Rendu template
89
+ const templatePath = join(process.cwd(), 'views', '_debug.html')
90
+ if (existsSync(templatePath)) {
91
+ try {
92
+ const { compileTemplate } = await import('rendu')
93
+ const source = readFileSync(templatePath, 'utf-8')
94
+ const fn = compileTemplate(source)
95
+
96
+ const maxDur = Math.max(...data.queries.map(q => q.duration), 1)
97
+ const slowCount = data.queries.filter(q => q.duration > 100).length
98
+ const totalTime = data.queries.reduce((s, q) => s + q.duration, 0)
99
+
100
+ const stream = await fn({
101
+ htmlspecialchars: (s: unknown) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
102
+ valEscaped: (v: any) => String(typeof v === 'object' ? JSON.stringify(v) : v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
103
+ method: data.method,
104
+ path: data.path,
105
+ status: data.status,
106
+ duration: data.duration,
107
+ memory: data.memory,
108
+ timestamp: data.timestamp,
109
+ queries: data.queries.map(q => ({
110
+ ...q,
111
+ barWidth: Math.max(3, (q.duration / maxDur) * 80),
112
+ color: q.duration > 100 ? '#f94860' : '#7bed9f',
113
+ pillClass: q.duration > 100 ? 'pill-slow' : 'pill-ok',
114
+ sqlEscaped: String(q.sql ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
115
+ paramsEscaped: q.params ? String(JSON.stringify(q.params)).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') : '',
116
+ })),
117
+ session: data.session,
118
+ sessionKeys: String(Object.keys(data.session).length),
119
+ slowCount,
120
+ totalTime: Math.round(totalTime * 100) / 100,
121
+ runtime: typeof Bun !== 'undefined' ? 'Bun ' + Bun.version : 'Node.js',
122
+ })
123
+ const reader = stream.getReader()
124
+ const chunks: Uint8Array[] = []
125
+ while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value) }
126
+ return new TextDecoder().decode(concatUint8Arrays(chunks))
127
+ } catch { /* fall through to inline */ }
128
+ }
129
+
130
+ // Inline fallback
131
+ return renderInline(data)
132
+ }
133
+
134
+ function renderInline(data: DebugData): string {
135
+ const slow = data.queries.filter(q => q.duration > 100)
136
+ const warn = slow.length > 0 ? ' ⚠️' : ''
137
+ const qtext = data.queries.length === 1 ? 'query' : 'queries'
138
+
139
+ return `<!-- Debug -->
140
+ <div class="nexdb" id="__nexdb" style="all:initial;position:fixed;bottom:0;left:0;right:0;z-index:99999;font-family:system-ui,sans-serif;font-size:13px;color:#e0e0e0;background:#13131f;border-top:2px solid #e94560">
141
+ <div style="display:flex;align-items:center;gap:8px;padding:8px 16px;cursor:pointer" onclick="document.getElementById('__nexdb').classList.toggle('open')">
142
+ <span style="font-weight:700;color:#e94560">▣ Bunigniter</span>
143
+ <span style="background:#e94560;color:#fff;padding:1px 7px;border-radius:3px;font-size:11px;font-weight:600">${data.method}</span>
144
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#aaa;font-size:12px">${data.path}</span>
145
+ <span style="background:#2d2d5e;color:#fff;padding:1px 7px;border-radius:3px;font-size:11px"><b>${data.status}</b></span>
146
+ <span style="background:rgba(248,165,194,0.15);color:#f8a5c2;padding:1px 7px;border-radius:3px;font-size:11px">${data.duration}ms</span>
147
+ <span style="background:rgba(123,237,159,0.15);color:#7bed9f;padding:1px 7px;border-radius:3px;font-size:11px">📊 <b>${data.queries.length}</b> ${qtext}${warn}</span>
148
+ <span style="background:rgba(112,161,255,0.15);color:#70a1ff;padding:1px 7px;border-radius:3px;font-size:11px">💾 ${data.memory}</span>
149
+ </div></div>`
150
+ }
151
+
152
+ function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
153
+ const total = arrays.reduce((s, a) => s + a.length, 0)
154
+ const result = new Uint8Array(total)
155
+ let offset = 0
156
+ for (const a of arrays) { result.set(a, offset); offset += a.length }
157
+ return result
158
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Environment variable helper — CodeIgniter-style env().
3
+ *
4
+ * Reads from `.env` file and `process.env`. Values in `.env` override
5
+ * system environment variables for development convenience.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const port = env('PORT', 3000)
10
+ * const dbUrl = env('DATABASE_URL')
11
+ * const debug = env('DEBUG', false)
12
+ * ```
13
+ */
14
+ import { readFileSync, existsSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+
17
+ /** Parsed `.env` cache. */
18
+ let envCache: Record<string, string> | null = null
19
+
20
+ /** CWD for `.env` resolution. */
21
+ let envDir: string = process.cwd()
22
+
23
+ /**
24
+ * Set the working directory for `.env` file lookup.
25
+ * Called automatically; override for testing.
26
+ */
27
+ export function setEnvDir(dir: string): void {
28
+ envDir = dir
29
+ envCache = null
30
+ }
31
+
32
+ /**
33
+ * Parse `.env` file content string.
34
+ * Supports:
35
+ * KEY=value
36
+ * KEY="quoted value"
37
+ * # comments
38
+ * export KEY=value
39
+ */
40
+ function parseEnv(content: string): Record<string, string> {
41
+ const result: Record<string, string> = {}
42
+ for (const line of content.split('\n')) {
43
+ const trimmed = line.trim()
44
+ if (!trimmed || trimmed.startsWith('#')) continue
45
+ const match = trimmed.match(/^(?:export\s+)?([\w._-]+)\s*=\s*(.*)$/)
46
+ if (!match) continue
47
+ const key = match[1]
48
+ let value = match[2].trim()
49
+
50
+ // Strip quotes
51
+ if ((value.startsWith('"') && value.endsWith('"')) ||
52
+ (value.startsWith("'") && value.endsWith("'"))) {
53
+ value = value.slice(1, -1)
54
+ }
55
+ result[key] = value
56
+ }
57
+ return result
58
+ }
59
+
60
+ /**
61
+ * Load `.env` file from CWD (or configured envDir).
62
+ * Supports `.env`, `.env.local`, `.env.{NODE_ENV}`, `.env.{NODE_ENV}.local`
63
+ * — loaded in order, later files override earlier ones.
64
+ */
65
+ export function loadEnv(): Record<string, string> {
66
+ if (envCache) return envCache
67
+
68
+ const env: Record<string, string> = { ...process.env } as Record<string, string>
69
+ const nodeEnv = process.env.NODE_ENV ?? 'development'
70
+
71
+ // Load priority: .env.<environment>.local > .env.local > .env.<environment> > .env
72
+ const files = [
73
+ '.env',
74
+ `.env.${nodeEnv}`,
75
+ '.env.local',
76
+ `.env.${nodeEnv}.local`,
77
+ ]
78
+
79
+ for (const file of files) {
80
+ const path = join(envDir, file)
81
+ if (existsSync(path)) {
82
+ const parsed = parseEnv(readFileSync(path, 'utf-8'))
83
+ Object.assign(env, parsed)
84
+ }
85
+ }
86
+
87
+ envCache = env
88
+ return env
89
+ }
90
+
91
+ /**
92
+ * Read an environment variable with optional default.
93
+ *
94
+ * @param key - Environment variable name
95
+ * @param defaultValue - Value to return if not set
96
+ * @returns The value cast to the type of defaultValue, or string
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * const port = env('PORT', 3000) // number
101
+ * const name = env('APP_NAME', 'MyApp') // string
102
+ * const debug = env('DEBUG', false) // boolean
103
+ * const required = env('DATABASE_URL') // string | undefined
104
+ * ```
105
+ */
106
+ function castValue<T>(value: string, defaultValue?: T): T {
107
+ if (typeof defaultValue === 'boolean') {
108
+ return (value === 'true' || value === '1' || value === 'yes') as unknown as T
109
+ }
110
+ if (typeof defaultValue === 'number') {
111
+ return Number(value) as unknown as T
112
+ }
113
+ return value as unknown as T
114
+ }
115
+
116
+ export function env<T extends string | number | boolean>(
117
+ key: string,
118
+ defaultValue?: T
119
+ ): T {
120
+ // Check actual process.env FIRST (it takes priority over .env files)
121
+ const processValue = (process.env as Record<string, string>)[key]
122
+ if (processValue !== undefined && processValue !== '') {
123
+ return castValue(processValue, defaultValue)
124
+ }
125
+
126
+ // Then check .env file values
127
+ const all = loadEnv()
128
+ const value = all[key]
129
+
130
+ if (value === undefined || value === '') {
131
+ return defaultValue as T
132
+ }
133
+
134
+ return castValue(value, defaultValue)
135
+ }
136
+
137
+ /**
138
+ * Require an environment variable. Throws if not set.
139
+ */
140
+ export function envOrFail(key: string): string {
141
+ const all = loadEnv()
142
+ const value = all[key]
143
+ if (value === undefined || value === '') {
144
+ throw new Error(`Required environment variable "${key}" is not set.`)
145
+ }
146
+ return value
147
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * defineHandler — Void-style route handler with optional validation.
3
+ *
4
+ * Provides the same API as Void's `defineHandler` + `withValidator`.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * // routes/api/users.ts
9
+ * import { defineHandler } from 'nexusts'
10
+ * import { db } from 'nexusts/db'
11
+ *
12
+ * export const GET = defineHandler(async (c) => {
13
+ * return db.select().from(users)
14
+ * })
15
+ *
16
+ * export const POST = defineHandler.withValidator({
17
+ * body: z.object({ name: z.string(), email: z.string().email() })
18
+ * })(async (c, { body }) => {
19
+ * return db.insert(users).values(body).returning()
20
+ * })
21
+ * ```
22
+ */
23
+ import type { Context } from 'elysia'
24
+ import type { z } from 'zod'
25
+ import { validateZod, type ValidationErrors } from './validator'
26
+
27
+ /** Handler function type. */
28
+ export type HandlerFn<T = any> = (c: Context, args?: T) => any
29
+
30
+ /** Validator config matching defineHandler.withValidator({ body, query, params }). */
31
+ export interface HandlerValidatorConfig {
32
+ body?: z.ZodSchema
33
+ query?: z.ZodSchema
34
+ params?: z.ZodSchema
35
+ }
36
+
37
+ /** Wrapped handler with validated args. */
38
+ export type ValidatedHandler<T> = (c: Context, args: T) => any
39
+
40
+ /**
41
+ * createHandler — wraps a function as a route handler.
42
+ * Auto-converts return values to Response objects.
43
+ */
44
+ function toResponse(result: any, c?: Context): Response {
45
+ if (result instanceof Response) return result
46
+ if (result === null || result === undefined) return new Response(null, { status: 204 })
47
+ if (typeof result === 'string') {
48
+ return new Response(result, {
49
+ headers: { 'content-type': 'text/html; charset=utf-8' },
50
+ })
51
+ }
52
+ // object / array / number / boolean → JSON
53
+ return new Response(JSON.stringify(result), {
54
+ headers: { 'content-type': 'application/json' },
55
+ ...(c ? { status: (c as any).set?.status ?? 200 } : {}),
56
+ })
57
+ }
58
+
59
+ /**
60
+ * defineHandler — Void-style route handler factory.
61
+ *
62
+ * Usage:
63
+ * ```ts
64
+ * export const GET = defineHandler(async (c) => {
65
+ * return { users: await db.select().from(users) }
66
+ * })
67
+ * ```
68
+ */
69
+ export function defineHandler<T extends HandlerFn>(fn: T): T {
70
+ return ((c: Context) => {
71
+ const result = fn(c)
72
+ if (result instanceof Promise) {
73
+ return result.then((r: any) => toResponse(r, c))
74
+ }
75
+ return toResponse(result, c)
76
+ }) as unknown as T
77
+ }
78
+
79
+ /**
80
+ * defineHandler.withValidator — handler with input validation.
81
+ *
82
+ * Usage:
83
+ * ```ts
84
+ * export const POST = defineHandler.withValidator({
85
+ * body: z.object({ name: z.string(), email: z.string().email() })
86
+ * })(async (c, { body }) => {
87
+ * return db.insert(users).values(body).returning()
88
+ * })
89
+ * ```
90
+ */
91
+ defineHandler.withValidator = function <T extends ValidatedHandler<any>>(
92
+ validators: HandlerValidatorConfig
93
+ ) {
94
+ return function (fn: T): HandlerFn {
95
+ return async (c: Context) => {
96
+ const errors: Record<string, ValidationErrors> = {}
97
+
98
+ // Validate body (Elysia v2 puts parsed body on c.body)
99
+ if (validators.body) {
100
+ const body = (c as any).body ?? {}
101
+ const result = validators.body.safeParse(body)
102
+ if (!result.success) {
103
+ errors.body = mapZodErrors(result.error)
104
+ } else {
105
+ (c as any)._validatedBody = result.data
106
+ }
107
+ }
108
+
109
+ // Validate query
110
+ if (validators.query) {
111
+ const query = Object.fromEntries(
112
+ new URL(c.request.url).searchParams.entries()
113
+ )
114
+ const result = validators.query.safeParse(query)
115
+ if (!result.success) {
116
+ errors.query = mapZodErrors(result.error)
117
+ } else {
118
+ (c as any)._validatedQuery = result.data
119
+ }
120
+ }
121
+
122
+ // Validate params
123
+ if (validators.params) {
124
+ const result = validators.params.safeParse((c as any).params ?? {})
125
+ if (!result.success) {
126
+ errors.params = mapZodErrors(result.error)
127
+ }
128
+ }
129
+
130
+ if (Object.keys(errors).length > 0) {
131
+ return new Response(JSON.stringify({
132
+ error: 'Validation failed',
133
+ issues: errors,
134
+ }), {
135
+ status: 400,
136
+ headers: { 'content-type': 'application/json' },
137
+ })
138
+ }
139
+
140
+ const result = await fn(c, {
141
+ body: (c as any)._validatedBody,
142
+ query: (c as any)._validatedQuery,
143
+ params: (c as any).params,
144
+ })
145
+ return toResponse(result, c)
146
+ }
147
+ }
148
+ }
149
+
150
+ /** Map Zod errors to our format. */
151
+ function mapZodErrors(error: any): ValidationErrors {
152
+ const errors: ValidationErrors = {}
153
+ for (const issue of error.issues ?? []) {
154
+ const path = issue.path.join('.')
155
+ ;(errors[path] ??= []).push(issue.message)
156
+ }
157
+ return errors
158
+ }