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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/LICENSE +21 -0
- package/dist/README.md +229 -0
- package/dist/base/controller.ts +324 -0
- package/dist/base/index.ts +5 -0
- package/dist/base/service.ts +21 -0
- package/dist/cli/index.ts +318 -0
- package/dist/cli/list-routes.ts +72 -0
- package/dist/cli/repl.ts +461 -0
- package/dist/cli/templates.ts +283 -0
- package/dist/client/index.ts +159 -0
- package/dist/db/drizzle.ts +550 -0
- package/dist/db/validators.ts +229 -0
- package/dist/edge-builder.ts +120 -0
- package/dist/edge.ts +69 -0
- package/dist/helpers/cache.ts +173 -0
- package/dist/helpers/cors.ts +103 -0
- package/dist/helpers/csrf.ts +155 -0
- package/dist/helpers/debug.ts +158 -0
- package/dist/helpers/env.ts +147 -0
- package/dist/helpers/handler.ts +158 -0
- package/dist/helpers/http.ts +194 -0
- package/dist/helpers/image.ts +217 -0
- package/dist/helpers/jwt.ts +147 -0
- package/dist/helpers/logger.ts +96 -0
- package/dist/helpers/mail.ts +272 -0
- package/dist/helpers/middleware-loader.ts +116 -0
- package/dist/helpers/middleware.ts +57 -0
- package/dist/helpers/modules.ts +115 -0
- package/dist/helpers/openapi.ts +140 -0
- package/dist/helpers/pagination.ts +159 -0
- package/dist/helpers/queue.ts +186 -0
- package/dist/helpers/request-context.ts +13 -0
- package/dist/helpers/request.ts +376 -0
- package/dist/helpers/schedule.ts +173 -0
- package/dist/helpers/session-middleware.ts +89 -0
- package/dist/helpers/session.ts +286 -0
- package/dist/helpers/sse.ts +90 -0
- package/dist/helpers/throttle.ts +156 -0
- package/dist/helpers/upload.ts +417 -0
- package/dist/helpers/validator.ts +287 -0
- package/dist/helpers/ws.ts +123 -0
- package/dist/index.ts +221 -0
- package/dist/package.json +70 -0
- package/dist/router/file-router.ts +541 -0
- package/dist/router/server-router.ts +103 -0
- package/dist/view/page.ts +96 -0
- package/dist/view/renderer.tsx +390 -0
- package/dist/view/view-response.ts +10 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
102
|
+
valEscaped: (v: any) => String(typeof v === 'object' ? JSON.stringify(v) : v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>'),
|
|
115
|
+
paramsEscaped: q.params ? String(JSON.stringify(q.params)).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') : '',
|
|
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
|
+
}
|