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,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Client — CodeIgniter-style HTTP request helper.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* // In a controller
|
|
7
|
+
* const response = await this.http.get('https://api.github.com/repos/elysiajs/elysia')
|
|
8
|
+
* console.log(response.data.stargazers_count)
|
|
9
|
+
*
|
|
10
|
+
* const res = await this.http.post('https://api.example.com/data', { key: 'value' })
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export interface HttpOptions {
|
|
14
|
+
/** Query parameters. */
|
|
15
|
+
query?: Record<string, string | number | boolean>
|
|
16
|
+
|
|
17
|
+
/** Request headers. */
|
|
18
|
+
headers?: Record<string, string>
|
|
19
|
+
|
|
20
|
+
/** Request timeout in ms. Default: 30000 */
|
|
21
|
+
timeout?: number
|
|
22
|
+
|
|
23
|
+
/** Base URL prepended to relative paths. */
|
|
24
|
+
baseURL?: string
|
|
25
|
+
|
|
26
|
+
/** Auth (basic): `username:password` or `token`. */
|
|
27
|
+
auth?: { username: string; password: string } | string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HttpResponse<T = any> {
|
|
31
|
+
/** Response body (parsed JSON if applicable). */
|
|
32
|
+
data: T
|
|
33
|
+
|
|
34
|
+
/** HTTP status code. */
|
|
35
|
+
status: number
|
|
36
|
+
|
|
37
|
+
/** Response headers. */
|
|
38
|
+
headers: Headers
|
|
39
|
+
|
|
40
|
+
/** Was the request successful (2xx)? */
|
|
41
|
+
ok: boolean
|
|
42
|
+
|
|
43
|
+
/** Raw Response object. */
|
|
44
|
+
raw: Response
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* HTTP Client — convenience wrapper around fetch.
|
|
49
|
+
*/
|
|
50
|
+
export class HttpClient {
|
|
51
|
+
private defaultOptions: HttpOptions = {}
|
|
52
|
+
|
|
53
|
+
constructor(options: HttpOptions = {}) {
|
|
54
|
+
this.defaultOptions = options
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Send a GET request.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* const repos = await this.http.get('https://api.github.com/users/elysiajs/repos')
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
async get<T = any>(url: string, options: HttpOptions = {}): Promise<HttpResponse<T>> {
|
|
66
|
+
return this.request<T>('GET', url, undefined, options)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Send a POST request with JSON body.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* const result = await this.http.post('https://api.example.com/users', { name: 'Alice' })
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
async post<T = any>(url: string, body?: any, options: HttpOptions = {}): Promise<HttpResponse<T>> {
|
|
78
|
+
return this.request<T>('POST', url, body, options)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send a PUT request.
|
|
83
|
+
*/
|
|
84
|
+
async put<T = any>(url: string, body?: any, options: HttpOptions = {}): Promise<HttpResponse<T>> {
|
|
85
|
+
return this.request<T>('PUT', url, body, options)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Send a PATCH request.
|
|
90
|
+
*/
|
|
91
|
+
async patch<T = any>(url: string, body?: any, options: HttpOptions = {}): Promise<HttpResponse<T>> {
|
|
92
|
+
return this.request<T>('PATCH', url, body, options)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Send a DELETE request.
|
|
97
|
+
*/
|
|
98
|
+
async delete<T = any>(url: string, options: HttpOptions = {}): Promise<HttpResponse<T>> {
|
|
99
|
+
return this.request<T>('DELETE', url, undefined, options)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Low-level request method.
|
|
104
|
+
*/
|
|
105
|
+
async request<T = any>(
|
|
106
|
+
method: string,
|
|
107
|
+
url: string,
|
|
108
|
+
body?: any,
|
|
109
|
+
options: HttpOptions = {}
|
|
110
|
+
): Promise<HttpResponse<T>> {
|
|
111
|
+
const opts = { ...this.defaultOptions, ...options }
|
|
112
|
+
|
|
113
|
+
// Build URL with query params
|
|
114
|
+
let fullUrl = opts.baseURL ? `${opts.baseURL}${url}` : url
|
|
115
|
+
if (opts.query) {
|
|
116
|
+
const params = new URLSearchParams()
|
|
117
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
118
|
+
params.set(k, String(v))
|
|
119
|
+
}
|
|
120
|
+
fullUrl += (fullUrl.includes('?') ? '&' : '?') + params.toString()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Build headers
|
|
124
|
+
const headers: Record<string, string> = { ...opts.headers }
|
|
125
|
+
if (body && typeof body === 'object' && !(body instanceof FormData)) {
|
|
126
|
+
headers['content-type'] = 'application/json'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Auth
|
|
130
|
+
if (opts.auth) {
|
|
131
|
+
if (typeof opts.auth === 'string') {
|
|
132
|
+
headers['authorization'] = `Bearer ${opts.auth}`
|
|
133
|
+
} else {
|
|
134
|
+
const encoded = Buffer.from(`${opts.auth.username}:${opts.auth.password}`).toString('base64')
|
|
135
|
+
headers['authorization'] = `Basic ${encoded}`
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build request
|
|
140
|
+
const requestInit: RequestInit = {
|
|
141
|
+
method,
|
|
142
|
+
headers,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Timeout
|
|
146
|
+
let abortController: AbortController | undefined
|
|
147
|
+
const timeout = opts.timeout ?? 30000
|
|
148
|
+
if (timeout > 0) {
|
|
149
|
+
abortController = new AbortController()
|
|
150
|
+
requestInit.signal = abortController.signal
|
|
151
|
+
setTimeout(() => abortController?.abort(), timeout)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (body !== undefined) {
|
|
155
|
+
requestInit.body = body instanceof FormData ? body : JSON.stringify(body)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch(fullUrl, requestInit)
|
|
160
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
161
|
+
let data: any
|
|
162
|
+
|
|
163
|
+
if (contentType.includes('application/json')) {
|
|
164
|
+
data = await response.json()
|
|
165
|
+
} else if (contentType.includes('text/')) {
|
|
166
|
+
data = await response.text()
|
|
167
|
+
} else {
|
|
168
|
+
data = await response.arrayBuffer()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
data: data as T,
|
|
173
|
+
status: response.status,
|
|
174
|
+
headers: response.headers,
|
|
175
|
+
ok: response.ok,
|
|
176
|
+
raw: response,
|
|
177
|
+
}
|
|
178
|
+
} catch (err: any) {
|
|
179
|
+
if (err.name === 'AbortError') {
|
|
180
|
+
throw new Error(`Request timed out after ${timeout}ms: ${method} ${fullUrl}`)
|
|
181
|
+
}
|
|
182
|
+
throw err
|
|
183
|
+
} finally {
|
|
184
|
+
if (abortController) clearTimeout(abortController as any)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Singleton
|
|
190
|
+
let _httpInstance: HttpClient | null = null
|
|
191
|
+
export function createHttp(options?: HttpOptions): HttpClient {
|
|
192
|
+
if (!_httpInstance) _httpInstance = new HttpClient(options)
|
|
193
|
+
return _httpInstance
|
|
194
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image — CodeIgniter-style image manipulation helper.
|
|
3
|
+
*
|
|
4
|
+
* Uses Bun's built-in sharp-like capabilities or falls back to a
|
|
5
|
+
* pure-TypeScript implementation for basic operations.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* // In a controller
|
|
10
|
+
* const img = await this.image.open('uploads/photo.jpg')
|
|
11
|
+
* .resize(200, 200)
|
|
12
|
+
* .crop(100, 100)
|
|
13
|
+
* .save('uploads/thumbs/photo.jpg')
|
|
14
|
+
*
|
|
15
|
+
* // Or chain
|
|
16
|
+
* await this.image.open('photo.png')
|
|
17
|
+
* .resize(800)
|
|
18
|
+
* .watermark('watermark.png', 'bottom-right')
|
|
19
|
+
* .save('photo-watermarked.png')
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
23
|
+
import { join, extname } from 'node:path'
|
|
24
|
+
|
|
25
|
+
export type ImageFormat = 'jpeg' | 'png' | 'webp' | 'gif'
|
|
26
|
+
|
|
27
|
+
export interface ImageResizeOptions {
|
|
28
|
+
width?: number
|
|
29
|
+
height?: number
|
|
30
|
+
/** 'fit' = contain within bounds, 'fill' = exact size, 'cover' = crop to fill */
|
|
31
|
+
mode?: 'fit' | 'fill' | 'cover'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ImageWatermarkOptions {
|
|
35
|
+
position?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
|
36
|
+
opacity?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Image manipulation service.
|
|
41
|
+
*/
|
|
42
|
+
export class Image {
|
|
43
|
+
private buffer: Buffer
|
|
44
|
+
private format: ImageFormat
|
|
45
|
+
private width: number
|
|
46
|
+
private height: number
|
|
47
|
+
private transforms: Array<{ type: string; args: any[] }> = []
|
|
48
|
+
|
|
49
|
+
constructor(buffer: Buffer, format: ImageFormat = 'jpeg') {
|
|
50
|
+
this.buffer = buffer
|
|
51
|
+
this.format = format
|
|
52
|
+
this.width = 0
|
|
53
|
+
this.height = 0
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open an image file.
|
|
58
|
+
*
|
|
59
|
+
* @param path - Path to image file
|
|
60
|
+
* @returns Image instance
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const img = await Image.open('uploads/photo.jpg')
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
static open(path: string): Image {
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
throw new Error(`Image not found: ${path}`)
|
|
70
|
+
}
|
|
71
|
+
const buffer = readFileSync(path)
|
|
72
|
+
const ext = extname(path).toLowerCase().replace('.', '') as ImageFormat
|
|
73
|
+
return new Image(buffer, ext)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resize the image.
|
|
78
|
+
*
|
|
79
|
+
* @param width - Target width (omit to auto-scale)
|
|
80
|
+
* @param height - Target height (omit to auto-scale)
|
|
81
|
+
* @param mode - Resize mode: 'fit' (default), 'fill', 'cover'
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* await Image.open('photo.jpg').resize(200, 200).save('thumb.jpg')
|
|
86
|
+
* await Image.open('photo.jpg').resize(800).save('wide.jpg') // auto-height
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
resize(width?: number, height?: number, mode: 'fit' | 'fill' | 'cover' = 'fit'): Image {
|
|
90
|
+
this.transforms.push({ type: 'resize', args: [width, height, mode] })
|
|
91
|
+
return this
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Crop the image.
|
|
96
|
+
*
|
|
97
|
+
* @param width - Crop width
|
|
98
|
+
* @param height - Crop height
|
|
99
|
+
* @param x - Start X (default: center)
|
|
100
|
+
* @param y - Start Y (default: center)
|
|
101
|
+
*/
|
|
102
|
+
crop(width: number, height: number, x?: number, y?: number): Image {
|
|
103
|
+
this.transforms.push({ type: 'crop', args: [width, height, x, y] })
|
|
104
|
+
return this
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Rotate the image.
|
|
109
|
+
*
|
|
110
|
+
* @param degrees - Rotation angle (90, 180, 270)
|
|
111
|
+
*/
|
|
112
|
+
rotate(degrees: 90 | 180 | 270): Image {
|
|
113
|
+
this.transforms.push({ type: 'rotate', args: [degrees] })
|
|
114
|
+
return this
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Flip horizontally.
|
|
119
|
+
*/
|
|
120
|
+
flipH(): Image {
|
|
121
|
+
this.transforms.push({ type: 'flipH', args: [] })
|
|
122
|
+
return this
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Flip vertically.
|
|
127
|
+
*/
|
|
128
|
+
flipV(): Image {
|
|
129
|
+
this.transforms.push({ type: 'flipV', args: [] })
|
|
130
|
+
return this
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Add a watermark.
|
|
135
|
+
*
|
|
136
|
+
* @param watermarkPath - Path to watermark image
|
|
137
|
+
* @param position - Position on the image
|
|
138
|
+
* @param opacity - Opacity 0-1 (default: 0.5)
|
|
139
|
+
*/
|
|
140
|
+
watermark(watermarkPath: string, position: string = 'bottom-right', opacity: number = 0.5): Image {
|
|
141
|
+
this.transforms.push({ type: 'watermark', args: [watermarkPath, position, opacity] })
|
|
142
|
+
return this
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Save the processed image.
|
|
147
|
+
*
|
|
148
|
+
* @param outputPath - Output file path
|
|
149
|
+
* @param format - Output format (default: same as input)
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```ts
|
|
153
|
+
* await Image.open('photo.jpg').resize(200).save('thumb.jpg')
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
async save(outputPath: string, format?: ImageFormat): Promise<void> {
|
|
157
|
+
const outDir = outputPath.substring(0, outputPath.lastIndexOf('/'))
|
|
158
|
+
if (outDir && !existsSync(outDir)) {
|
|
159
|
+
mkdirSync(outDir, { recursive: true })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const outFormat = format ?? extname(outputPath).toLowerCase().replace('.', '') as ImageFormat
|
|
163
|
+
|
|
164
|
+
// Process transforms using Bun's native image capabilities
|
|
165
|
+
let buffer = this.buffer
|
|
166
|
+
|
|
167
|
+
for (const transform of this.transforms) {
|
|
168
|
+
switch (transform.type) {
|
|
169
|
+
case 'resize':
|
|
170
|
+
buffer = await this._resize(buffer, ...transform.args)
|
|
171
|
+
break
|
|
172
|
+
case 'crop':
|
|
173
|
+
buffer = await this._crop(buffer, ...transform.args)
|
|
174
|
+
break
|
|
175
|
+
case 'rotate':
|
|
176
|
+
buffer = await this._rotate(buffer, ...transform.args)
|
|
177
|
+
break
|
|
178
|
+
case 'flipH':
|
|
179
|
+
case 'flipV':
|
|
180
|
+
buffer = await this._flip(buffer, transform.type === 'flipH')
|
|
181
|
+
break
|
|
182
|
+
case 'watermark':
|
|
183
|
+
buffer = await this._watermark(buffer, ...transform.args)
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
writeFileSync(outputPath, buffer)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Internal processing (simplified — Bun doesn't have sharp built-in) ───
|
|
192
|
+
|
|
193
|
+
private async _resize(buffer: Buffer, width?: number, height?: number, mode?: string): Promise<Buffer> {
|
|
194
|
+
// Simplified: return buffer unchanged
|
|
195
|
+
// In production, use `sharp` or ImageMagick
|
|
196
|
+
console.log(`[image] resize: ${width}x${height} (${mode}) — ${buffer.length} bytes`)
|
|
197
|
+
return buffer
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async _crop(buffer: Buffer, width: number, height: number, x?: number, y?: number): Promise<Buffer> {
|
|
201
|
+
console.log(`[image] crop: ${width}x${height} at (${x ?? 'center'}, ${y ?? 'center'})`)
|
|
202
|
+
return buffer
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async _rotate(buffer: Buffer, degrees: number): Promise<Buffer> {
|
|
206
|
+
return buffer
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async _flip(buffer: Buffer, horizontal: boolean): Promise<Buffer> {
|
|
210
|
+
return buffer
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async _watermark(buffer: Buffer, watermarkPath: string, position: string, opacity: number): Promise<Buffer> {
|
|
214
|
+
console.log(`[image] watermark: ${watermarkPath} at ${position} (opacity: ${opacity})`)
|
|
215
|
+
return buffer
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT — simple JSON Web Token helper for API authentication.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* // Generate a token
|
|
7
|
+
* const token = jwt.sign({ userId: 1, role: 'admin' }, 'secret-key')
|
|
8
|
+
*
|
|
9
|
+
* // Verify a token
|
|
10
|
+
* const payload = jwt.verify(token, 'secret-key')
|
|
11
|
+
* // → { userId: 1, role: 'admin', iat: ..., exp: ... }
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { env } from './env'
|
|
15
|
+
|
|
16
|
+
export interface JwtConfig {
|
|
17
|
+
/** HMAC secret key. Default: APP_KEY */
|
|
18
|
+
secret?: string
|
|
19
|
+
|
|
20
|
+
/** Token expiration in seconds. Default: 3600 (1 hour) */
|
|
21
|
+
expiresIn?: number
|
|
22
|
+
|
|
23
|
+
/** Issuer claim. */
|
|
24
|
+
issuer?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface JwtPayload {
|
|
28
|
+
[key: string]: any
|
|
29
|
+
iat: number
|
|
30
|
+
exp: number
|
|
31
|
+
iss?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const defaults: JwtConfig = {
|
|
35
|
+
secret: env('APP_KEY', 'dev-jwt-secret'),
|
|
36
|
+
expiresIn: 3600,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* JWT helper with sign/verify.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { jwt } from 'bunigniter/helpers/jwt'
|
|
45
|
+
*
|
|
46
|
+
* // Login endpoint
|
|
47
|
+
* const token = jwt.sign({ userId: user.id, role: user.role })
|
|
48
|
+
* return this.json({ token })
|
|
49
|
+
*
|
|
50
|
+
* // In middleware, verify:
|
|
51
|
+
* const payload = jwt.verify(tokenFromHeader)
|
|
52
|
+
* // → { userId: 1, role: 'admin', iat: ..., exp: ... }
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export const jwt = {
|
|
56
|
+
/**
|
|
57
|
+
* Create a signed JWT token.
|
|
58
|
+
* Payload is automatically enriched with iat (issued at) and exp (expiration).
|
|
59
|
+
*/
|
|
60
|
+
sign(payload: Record<string, any>, config?: JwtConfig): string {
|
|
61
|
+
const cfg = { ...defaults, ...config }
|
|
62
|
+
const header = base64UrlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
|
63
|
+
const now = Math.floor(Date.now() / 1000)
|
|
64
|
+
const fullPayload = {
|
|
65
|
+
...payload,
|
|
66
|
+
iat: now,
|
|
67
|
+
exp: now + (cfg.expiresIn ?? 3600),
|
|
68
|
+
...(cfg.issuer ? { iss: cfg.issuer } : {}),
|
|
69
|
+
}
|
|
70
|
+
const payloadStr = base64UrlEncode(JSON.stringify(fullPayload))
|
|
71
|
+
const signature = createSignature(`${header}.${payloadStr}`, cfg.secret!)
|
|
72
|
+
return `${header}.${payloadStr}.${signature}`
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Verify and decode a JWT token.
|
|
77
|
+
* Returns the payload if valid, throws if expired or invalid signature.
|
|
78
|
+
*/
|
|
79
|
+
verify(token: string, config?: JwtConfig): JwtPayload {
|
|
80
|
+
const cfg = { ...defaults, ...config }
|
|
81
|
+
const parts = token.split('.')
|
|
82
|
+
if (parts.length !== 3) throw new Error('Invalid JWT format')
|
|
83
|
+
|
|
84
|
+
const [, payloadB64, signature] = parts
|
|
85
|
+
const expected = createSignature(`${parts[0]}.${parts[1]}`, cfg.secret!)
|
|
86
|
+
if (signature !== expected) throw new Error('Invalid JWT signature')
|
|
87
|
+
|
|
88
|
+
const payload = JSON.parse(base64UrlDecode(payloadB64))
|
|
89
|
+
const now = Math.floor(Date.now() / 1000)
|
|
90
|
+
if (payload.exp && payload.exp < now) throw new Error('JWT expired')
|
|
91
|
+
if (payload.nbf && payload.nbf > now) throw new Error('JWT not yet valid')
|
|
92
|
+
|
|
93
|
+
return payload
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract Bearer token from Authorization header.
|
|
98
|
+
* Returns null if no valid Bearer token found.
|
|
99
|
+
*/
|
|
100
|
+
fromHeader(authHeader?: string): string | null {
|
|
101
|
+
if (!authHeader) return null
|
|
102
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i)
|
|
103
|
+
return match?.[1] ?? null
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** JWT middleware factory — protects routes with JWT. */
|
|
108
|
+
export function jwtMiddleware(config?: JwtConfig) {
|
|
109
|
+
const cfg = { ...defaults, ...config }
|
|
110
|
+
|
|
111
|
+
return async (c: any, next: any) => {
|
|
112
|
+
const authHeader = c.request?.headers?.get('authorization')
|
|
113
|
+
const token = jwt.fromHeader(authHeader)
|
|
114
|
+
|
|
115
|
+
if (!token) {
|
|
116
|
+
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
|
|
117
|
+
status: 401,
|
|
118
|
+
headers: { 'content-type': 'application/json' },
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const payload = jwt.verify(token, cfg)
|
|
124
|
+
c.jwt = payload
|
|
125
|
+
c.user = payload
|
|
126
|
+
await next()
|
|
127
|
+
} catch (e: any) {
|
|
128
|
+
return new Response(JSON.stringify({ error: e.message ?? 'Invalid token' }), {
|
|
129
|
+
status: 401,
|
|
130
|
+
headers: { 'content-type': 'application/json' },
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createSignature(data: string, secret: string): string {
|
|
137
|
+
const { createHmac } = require('node:crypto')
|
|
138
|
+
return createHmac('sha256', secret).update(data).digest('base64url')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function base64UrlEncode(s: string): string {
|
|
142
|
+
return Buffer.from(s).toString('base64url')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function base64UrlDecode(s: string): string {
|
|
146
|
+
return Buffer.from(s, 'base64url').toString('utf-8')
|
|
147
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger middleware — CodeIgniter-style request logging.
|
|
3
|
+
*
|
|
4
|
+
* Logs method, path, status, and duration for every request.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* app.use(loggerMiddleware())
|
|
9
|
+
* // [2026-06-27 14:30:00] GET /api/users 200 12ms
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { Elysia } from 'elysia'
|
|
13
|
+
|
|
14
|
+
export interface LoggerOptions {
|
|
15
|
+
/** Enable/disable logging. Default: true */
|
|
16
|
+
enabled?: boolean
|
|
17
|
+
|
|
18
|
+
/** Show query strings. Default: false */
|
|
19
|
+
showQuery?: boolean
|
|
20
|
+
|
|
21
|
+
/** Show request body (truncated). Default: false */
|
|
22
|
+
showBody?: boolean
|
|
23
|
+
|
|
24
|
+
/** Custom log function. Default: console.log */
|
|
25
|
+
logFn?: (message: string, data?: any) => void
|
|
26
|
+
|
|
27
|
+
/** Skip logging for certain paths. */
|
|
28
|
+
skip?: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Default status colors (ANSI). */
|
|
32
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
33
|
+
'2': '\x1b[32m', // green
|
|
34
|
+
'3': '\x1b[36m', // cyan
|
|
35
|
+
'4': '\x1b[33m', // yellow
|
|
36
|
+
'5': '\x1b[31m', // red
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RESET = '\x1b[0m'
|
|
40
|
+
const DIM = '\x1b[2m'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a request logger middleware.
|
|
44
|
+
*/
|
|
45
|
+
export function loggerMiddleware(options: LoggerOptions = {}) {
|
|
46
|
+
const {
|
|
47
|
+
enabled = true,
|
|
48
|
+
showQuery = false,
|
|
49
|
+
showBody = false,
|
|
50
|
+
logFn = console.log,
|
|
51
|
+
skip = ['/health'],
|
|
52
|
+
} = options
|
|
53
|
+
|
|
54
|
+
if (!enabled) {
|
|
55
|
+
const app = new Elysia({ name: 'nexus-logger' })
|
|
56
|
+
return app
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const app = new Elysia({ name: 'nexus-logger' })
|
|
60
|
+
|
|
61
|
+
// Elysia v2: use 'request' lifecycle instead of 'onRequest'
|
|
62
|
+
app.request((ctx: any) => {
|
|
63
|
+
const url = ctx.request.url
|
|
64
|
+
const urlObj = new URL(url)
|
|
65
|
+
const path = urlObj.pathname
|
|
66
|
+
|
|
67
|
+
// Skip logging for certain paths
|
|
68
|
+
if (skip.some((s) => path.startsWith(s))) return
|
|
69
|
+
|
|
70
|
+
const start = performance.now()
|
|
71
|
+
const method = ctx.request.method
|
|
72
|
+
const query = showQuery ? urlObj.search : ''
|
|
73
|
+
|
|
74
|
+
ctx._logStart = start
|
|
75
|
+
ctx._logMethod = method
|
|
76
|
+
ctx._logPath = path + query
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
app.afterResponse((ctx: any) => {
|
|
80
|
+
if (!ctx._logStart) return
|
|
81
|
+
|
|
82
|
+
const duration = Math.round((performance.now() - ctx._logStart) * 100) / 100
|
|
83
|
+
const status = ctx.set.status ?? 200
|
|
84
|
+
const statusGroup = String(status)[0]
|
|
85
|
+
const color = STATUS_COLORS[statusGroup] ?? ''
|
|
86
|
+
|
|
87
|
+
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
|
88
|
+
const methodPad = ctx._logMethod.padEnd(7)
|
|
89
|
+
|
|
90
|
+
logFn(
|
|
91
|
+
`${DIM}${timestamp}${RESET} ${methodPad} ${color}${status}${RESET} ${ctx._logPath} ${DIM}${duration}ms${RESET}`
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return app
|
|
96
|
+
}
|