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,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail — email sending with SMTP, File, and Null transports.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* // In a controller
|
|
7
|
+
* await this.mail.send({
|
|
8
|
+
* to: 'user@test.com',
|
|
9
|
+
* subject: 'Welcome!',
|
|
10
|
+
* html: '<h1>Hello</h1>',
|
|
11
|
+
* })
|
|
12
|
+
*
|
|
13
|
+
* // With a transport
|
|
14
|
+
* await this.mail.transport('smtp').send({
|
|
15
|
+
* to: 'user@test.com',
|
|
16
|
+
* subject: 'Hi',
|
|
17
|
+
* text: 'Hello world',
|
|
18
|
+
* })
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { env } from './env'
|
|
24
|
+
|
|
25
|
+
// ─── Types ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface MailMessage {
|
|
28
|
+
to: string | string[]
|
|
29
|
+
from?: string
|
|
30
|
+
subject: string
|
|
31
|
+
text?: string
|
|
32
|
+
html?: string
|
|
33
|
+
cc?: string | string[]
|
|
34
|
+
bcc?: string | string[]
|
|
35
|
+
replyTo?: string
|
|
36
|
+
attachments?: MailAttachment[]
|
|
37
|
+
headers?: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MailAttachment {
|
|
41
|
+
filename: string
|
|
42
|
+
content: Buffer | string
|
|
43
|
+
contentType?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MailOptions {
|
|
47
|
+
/** Default from address. */
|
|
48
|
+
defaultFrom?: string
|
|
49
|
+
|
|
50
|
+
/** Default transport. */
|
|
51
|
+
transport?: MailTransport
|
|
52
|
+
|
|
53
|
+
/** Storage directory for file transport. Default: 'storage/mail' */
|
|
54
|
+
storageDir?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Transports ─────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export interface MailTransport {
|
|
60
|
+
name: string
|
|
61
|
+
send(message: MailMessage): Promise<void>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Null transport — discards all messages (for testing).
|
|
66
|
+
*/
|
|
67
|
+
export class NullTransport implements MailTransport {
|
|
68
|
+
name = 'null'
|
|
69
|
+
async send(_message: MailMessage): Promise<void> {
|
|
70
|
+
// Discard
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* File transport — writes messages to disk (for development).
|
|
76
|
+
*/
|
|
77
|
+
export class FileTransport implements MailTransport {
|
|
78
|
+
name = 'file'
|
|
79
|
+
private dir: string
|
|
80
|
+
|
|
81
|
+
constructor(dir?: string) {
|
|
82
|
+
this.dir = dir ?? join(process.cwd(), 'storage/mail')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async send(message: MailMessage): Promise<void> {
|
|
86
|
+
if (!existsSync(this.dir)) {
|
|
87
|
+
mkdirSync(this.dir, { recursive: true })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const filename = `${Date.now()}_${message.subject.replace(/[^a-zA-Z0-9]/g, '_').slice(0, 50)}.json`
|
|
91
|
+
const content = JSON.stringify(message, null, 2)
|
|
92
|
+
writeFileSync(join(this.dir, filename), content, 'utf-8')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* SMTP transport — sends via SMTP server.
|
|
98
|
+
* Uses Bun's built-in SMTP or a lightweight client.
|
|
99
|
+
*/
|
|
100
|
+
export class SmtpTransport implements MailTransport {
|
|
101
|
+
name = 'smtp'
|
|
102
|
+
private host: string
|
|
103
|
+
private port: number
|
|
104
|
+
private user: string
|
|
105
|
+
private pass: string
|
|
106
|
+
private secure: boolean
|
|
107
|
+
|
|
108
|
+
constructor(options: {
|
|
109
|
+
host?: string
|
|
110
|
+
port?: number
|
|
111
|
+
user?: string
|
|
112
|
+
pass?: string
|
|
113
|
+
secure?: boolean
|
|
114
|
+
} = {}) {
|
|
115
|
+
this.host = options.host ?? env('SMTP_HOST', 'localhost')
|
|
116
|
+
this.port = options.port ?? Number(env('SMTP_PORT', '587'))
|
|
117
|
+
this.user = options.user ?? env('SMTP_USER', '')
|
|
118
|
+
this.pass = options.pass ?? env('SMTP_PASS', '')
|
|
119
|
+
this.secure = options.secure ?? env('SMTP_SECURE', false)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async send(message: MailMessage): Promise<void> {
|
|
123
|
+
// Build email content
|
|
124
|
+
const from = message.from ?? env('MAIL_FROM', 'noreply@localhost')
|
|
125
|
+
const to = Array.isArray(message.to) ? message.to.join(', ') : message.to
|
|
126
|
+
|
|
127
|
+
let headers = `From: ${from}\nTo: ${to}\nSubject: ${message.subject}\n`
|
|
128
|
+
if (message.cc) {
|
|
129
|
+
headers += `Cc: ${Array.isArray(message.cc) ? message.cc.join(', ') : message.cc}\n`
|
|
130
|
+
}
|
|
131
|
+
if (message.replyTo) {
|
|
132
|
+
headers += `Reply-To: ${message.replyTo}\n`
|
|
133
|
+
}
|
|
134
|
+
headers += 'MIME-Version: 1.0\n'
|
|
135
|
+
|
|
136
|
+
let body = ''
|
|
137
|
+
if (message.html) {
|
|
138
|
+
headers += 'Content-Type: text/html; charset=UTF-8\n'
|
|
139
|
+
body = message.html
|
|
140
|
+
} else if (message.text) {
|
|
141
|
+
headers += 'Content-Type: text/plain; charset=UTF-8\n'
|
|
142
|
+
body = message.text
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const raw = `${headers}\n${body}`
|
|
146
|
+
|
|
147
|
+
// Send via SMTP using Bun's TCP socket
|
|
148
|
+
try {
|
|
149
|
+
const { connect } = await import('node:net')
|
|
150
|
+
await new Promise<void>((resolve, reject) => {
|
|
151
|
+
const socket = connect(this.port, this.host, () => {
|
|
152
|
+
let buffer = ''
|
|
153
|
+
let step = 0
|
|
154
|
+
|
|
155
|
+
const send = (cmd: string) => {
|
|
156
|
+
socket.write(cmd + '\r\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
socket.on('data', (data: Buffer) => {
|
|
160
|
+
buffer += data.toString()
|
|
161
|
+
const lines = buffer.split('\r\n')
|
|
162
|
+
buffer = lines.pop() ?? ''
|
|
163
|
+
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
if (line.startsWith('220') && step === 0) {
|
|
166
|
+
step = 1
|
|
167
|
+
send(`EHLO ${this.host}`)
|
|
168
|
+
} else if (line.startsWith('250') && step === 1) {
|
|
169
|
+
if (this.user && this.pass) {
|
|
170
|
+
step = 2
|
|
171
|
+
send('AUTH LOGIN')
|
|
172
|
+
} else {
|
|
173
|
+
step = 3
|
|
174
|
+
send(`MAIL FROM:<${from}>`)
|
|
175
|
+
}
|
|
176
|
+
} else if (line.startsWith('334') && step === 2) {
|
|
177
|
+
send(Buffer.from(this.user).toString('base64'))
|
|
178
|
+
step = 21
|
|
179
|
+
} else if (line.startsWith('334') && step === 21) {
|
|
180
|
+
send(Buffer.from(this.pass).toString('base64'))
|
|
181
|
+
step = 22
|
|
182
|
+
} else if (line.startsWith('235') && step === 22) {
|
|
183
|
+
step = 3
|
|
184
|
+
send(`MAIL FROM:<${from}>`)
|
|
185
|
+
} else if (line.startsWith('250') && step === 3) {
|
|
186
|
+
step = 4
|
|
187
|
+
send(`RCPT TO:<${to}>`)
|
|
188
|
+
} else if (line.startsWith('250') && step === 4) {
|
|
189
|
+
step = 5
|
|
190
|
+
send('DATA')
|
|
191
|
+
} else if (line.startsWith('354') && step === 5) {
|
|
192
|
+
step = 6
|
|
193
|
+
send(raw + '\r\n.')
|
|
194
|
+
} else if (line.startsWith('250') && step === 6) {
|
|
195
|
+
step = 7
|
|
196
|
+
send('QUIT')
|
|
197
|
+
} else if (line.startsWith('221') && step === 7) {
|
|
198
|
+
socket.end()
|
|
199
|
+
resolve()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
socket.on('error', reject)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
setTimeout(() => {
|
|
208
|
+
socket.destroy()
|
|
209
|
+
reject(new Error('SMTP connection timed out'))
|
|
210
|
+
}, 10000)
|
|
211
|
+
})
|
|
212
|
+
} catch (err) {
|
|
213
|
+
// Fall back to file transport on error
|
|
214
|
+
const fallback = new FileTransport(this.dir)
|
|
215
|
+
await fallback.send(message)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private get dir(): string {
|
|
220
|
+
return join(process.cwd(), 'storage/mail')
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Mail Service ───────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Mail service — send emails with configurable transport.
|
|
228
|
+
*
|
|
229
|
+
* Usage in a Controller:
|
|
230
|
+
* ```ts
|
|
231
|
+
* await this.mail.send({
|
|
232
|
+
* to: 'user@test.com',
|
|
233
|
+
* subject: 'Welcome!',
|
|
234
|
+
* html: '<h1>Hello</h1>',
|
|
235
|
+
* })
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
export class Mail {
|
|
239
|
+
private options: MailOptions
|
|
240
|
+
private _transport: MailTransport
|
|
241
|
+
|
|
242
|
+
constructor(options: MailOptions = {}) {
|
|
243
|
+
this.options = options
|
|
244
|
+
this._transport = options.transport ?? new NullTransport()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Send an email.
|
|
249
|
+
*/
|
|
250
|
+
async send(message: MailMessage): Promise<void> {
|
|
251
|
+
const msg: MailMessage = {
|
|
252
|
+
...message,
|
|
253
|
+
from: message.from ?? this.options.defaultFrom ?? env('MAIL_FROM', 'noreply@localhost'),
|
|
254
|
+
}
|
|
255
|
+
await this._transport.send(msg)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Send with a specific transport (one-off override).
|
|
260
|
+
*/
|
|
261
|
+
async transport(transport: MailTransport): Promise<void> {
|
|
262
|
+
this._transport = transport
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let _mailInstance: Mail | null = null
|
|
267
|
+
export function createMail(options?: MailOptions): Mail {
|
|
268
|
+
if (!_mailInstance) {
|
|
269
|
+
_mailInstance = new Mail(options)
|
|
270
|
+
}
|
|
271
|
+
return _mailInstance
|
|
272
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware Loader — Void-style file-based middleware directory.
|
|
3
|
+
*
|
|
4
|
+
* Place files in `middleware/` with numeric prefixes for ordering:
|
|
5
|
+
* ```
|
|
6
|
+
* middleware/
|
|
7
|
+
* ├── 01.logger.ts ← runs first
|
|
8
|
+
* ├── 02.auth.ts ← runs second
|
|
9
|
+
* ├── 03.cors.ts ← runs third
|
|
10
|
+
* └── _helpers.ts ← ignored (starts with _)
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Each file exports a default middleware function:
|
|
14
|
+
* ```ts
|
|
15
|
+
* // middleware/01.request-id.ts
|
|
16
|
+
* import { defineMiddleware } from 'nexusts'
|
|
17
|
+
*
|
|
18
|
+
* export default defineMiddleware(async (c, next) => {
|
|
19
|
+
* const start = performance.now()
|
|
20
|
+
* await next()
|
|
21
|
+
* c.header('X-Response-Time', `${performance.now() - start}ms`)
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Middleware can set context variables via c.set():
|
|
26
|
+
* ```ts
|
|
27
|
+
* declare module 'elysia' {
|
|
28
|
+
* interface ElysiaContext {
|
|
29
|
+
* requestId: string
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
import { readdirSync, statSync, existsSync } from 'node:fs'
|
|
35
|
+
import { join } from 'node:path'
|
|
36
|
+
import { Elysia } from 'elysia'
|
|
37
|
+
|
|
38
|
+
/** Middleware function signature (Hono-compatible). */
|
|
39
|
+
export type MiddlewareFn = (c: any, next: () => Promise<void>) => Promise<void> | void
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Define middleware with proper typing.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* // middleware/01.logger.ts
|
|
47
|
+
* import { defineMiddleware } from 'nexusts'
|
|
48
|
+
*
|
|
49
|
+
* export default defineMiddleware(async (c, next) => {
|
|
50
|
+
* console.log(c.request.method, c.request.url)
|
|
51
|
+
* await next()
|
|
52
|
+
* })
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function defineMiddleware(fn: MiddlewareFn): MiddlewareFn {
|
|
56
|
+
return fn
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Loaded middleware entry. */
|
|
60
|
+
interface MiddlewareEntry {
|
|
61
|
+
name: string
|
|
62
|
+
order: number
|
|
63
|
+
fn: MiddlewareFn
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load and apply middleware from the `middleware/` directory.
|
|
68
|
+
*
|
|
69
|
+
* Files starting with `_` are ignored.
|
|
70
|
+
* Files with numeric prefixes (e.g. `01_logger.ts`) are sorted by prefix.
|
|
71
|
+
* Files without numeric prefix are loaded alphabetically after numbered ones.
|
|
72
|
+
*
|
|
73
|
+
* @param dir - Middleware directory path. Default: 'middleware'
|
|
74
|
+
*/
|
|
75
|
+
export async function loadMiddleware(dir: string = 'middleware'): Promise<MiddlewareFn[]> {
|
|
76
|
+
if (!existsSync(dir)) return []
|
|
77
|
+
|
|
78
|
+
const entries: MiddlewareEntry[] = []
|
|
79
|
+
const files = readdirSync(dir, { withFileTypes: true })
|
|
80
|
+
|
|
81
|
+
for (const file of files) {
|
|
82
|
+
if (!file.isFile() || !file.name.endsWith('.ts') || file.name.startsWith('_')) continue
|
|
83
|
+
|
|
84
|
+
const fullPath = join(process.cwd(), dir, file.name)
|
|
85
|
+
const mod = await import(fullPath)
|
|
86
|
+
const fn = mod.default ?? mod.middleware
|
|
87
|
+
if (typeof fn !== 'function') continue
|
|
88
|
+
|
|
89
|
+
// Extract numeric prefix (e.g. "01" from "01_logger.ts")
|
|
90
|
+
const match = file.name.match(/^(\d+)[._-]/)
|
|
91
|
+
const order = match ? parseInt(match[1], 10) : 999
|
|
92
|
+
|
|
93
|
+
entries.push({
|
|
94
|
+
name: file.name.replace(/\.ts$/, ''),
|
|
95
|
+
order,
|
|
96
|
+
fn,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Sort by order, then by name for stability
|
|
101
|
+
entries.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
|
102
|
+
|
|
103
|
+
return entries.map(e => e.fn)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Apply middleware to an Elysia app.
|
|
108
|
+
* Uses Elysia v2's `request()` lifecycle hook.
|
|
109
|
+
*/
|
|
110
|
+
export function applyMiddlewareToApp(app: Elysia, middleware: MiddlewareFn[]): void {
|
|
111
|
+
for (const fn of middleware) {
|
|
112
|
+
app.request(async (ctx: any) => {
|
|
113
|
+
await fn(ctx, async () => {})
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware loader — applies configured middleware to an Elysia app.
|
|
3
|
+
*
|
|
4
|
+
* Reads from config/app.ts and applies middleware in order.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* // src/index.ts
|
|
9
|
+
* import { applyMiddleware } from './helpers/middleware'
|
|
10
|
+
* applyMiddleware(app, config.middleware)
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import { Elysia } from 'elysia'
|
|
14
|
+
import { corsMiddleware, type CORSOptions } from './cors'
|
|
15
|
+
import { loggerMiddleware, type LoggerOptions } from './logger'
|
|
16
|
+
import { csrfMiddleware, type CSRFOptions } from './csrf'
|
|
17
|
+
import { rateLimiter, type ThrottleOptions } from './throttle'
|
|
18
|
+
|
|
19
|
+
/** Middleware configuration from config/app.ts. */
|
|
20
|
+
export interface MiddlewareConfig {
|
|
21
|
+
/** CORS settings. false to disable. */
|
|
22
|
+
cors?: CORSOptions | false
|
|
23
|
+
|
|
24
|
+
/** Logger settings. false to disable. */
|
|
25
|
+
logger?: LoggerOptions | false
|
|
26
|
+
|
|
27
|
+
/** CSRF protection. false to disable. */
|
|
28
|
+
csrf?: CSRFOptions | false
|
|
29
|
+
|
|
30
|
+
/** Rate limiter. false to disable. */
|
|
31
|
+
throttle?: ThrottleOptions | false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Apply middleware to an Elysia app based on config.
|
|
36
|
+
*/
|
|
37
|
+
export function applyMiddleware(app: Elysia, config?: MiddlewareConfig): void {
|
|
38
|
+
if (!config) return
|
|
39
|
+
|
|
40
|
+
// Order matters: CORS → Logger → CSRF → Rate Limit
|
|
41
|
+
|
|
42
|
+
if (config.cors !== false) {
|
|
43
|
+
app.use(corsMiddleware(config.cors ?? {}))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (config.logger !== false) {
|
|
47
|
+
app.use(loggerMiddleware(config.logger ?? {}))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (config.csrf !== false) {
|
|
51
|
+
app.use(csrfMiddleware(config.csrf ?? {}))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (config.throttle !== false) {
|
|
55
|
+
app.use(rateLimiter(config.throttle ?? {}))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMVC Module System — load modules from `modules/` directory.
|
|
3
|
+
*
|
|
4
|
+
* Each module has its own routes/ and views/:
|
|
5
|
+
* ```
|
|
6
|
+
* modules/
|
|
7
|
+
* blog/
|
|
8
|
+
* routes/posts.ts → GET /blog/posts
|
|
9
|
+
* views/posts.html
|
|
10
|
+
* config/app.ts (optional, module-level config)
|
|
11
|
+
* shop/
|
|
12
|
+
* routes/products.ts → GET /shop/products
|
|
13
|
+
* views/products.html
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Cross-module calls:
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { moduleRun } from '../helpers/modules'
|
|
19
|
+
* const posts = await moduleRun('blog/posts', ctx)
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import { readdirSync, existsSync, statSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { Elysia } from 'elysia'
|
|
25
|
+
import { registerFileRoutes } from '../router/file-router'
|
|
26
|
+
import { registerServerRoutes } from '../router/server-router'
|
|
27
|
+
import type { DbClient } from '../db/drizzle'
|
|
28
|
+
import type { Cache } from '../helpers/cache'
|
|
29
|
+
import type { Queue } from '../helpers/queue'
|
|
30
|
+
import type { Upload } from '../helpers/upload'
|
|
31
|
+
import type { Mail } from '../helpers/mail'
|
|
32
|
+
|
|
33
|
+
export interface ModuleServices {
|
|
34
|
+
db?: DbClient
|
|
35
|
+
dbs?: Record<string, DbClient>
|
|
36
|
+
cache?: Cache
|
|
37
|
+
queue?: Queue
|
|
38
|
+
upload?: Upload
|
|
39
|
+
mail?: Mail
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Scan and register all modules in the `modules/` directory. */
|
|
43
|
+
export async function registerModules(app: Elysia, services: ModuleServices): Promise<void> {
|
|
44
|
+
// Compute modules directory relative to CWD
|
|
45
|
+
const modulesDir = 'modules'
|
|
46
|
+
if (!existsSync(modulesDir)) return
|
|
47
|
+
|
|
48
|
+
const entries = readdirSync(modulesDir, { withFileTypes: true })
|
|
49
|
+
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!entry.isDirectory() || entry.name.startsWith('_')) continue
|
|
52
|
+
|
|
53
|
+
const moduleName = entry.name
|
|
54
|
+
const routesDir = join(modulesDir, moduleName, 'routes')
|
|
55
|
+
const viewsDir = join(modulesDir, moduleName, 'views')
|
|
56
|
+
|
|
57
|
+
if (!existsSync(routesDir)) continue
|
|
58
|
+
|
|
59
|
+
// Each module registers its routes with its name as prefix
|
|
60
|
+
await registerFileRoutes(app, {
|
|
61
|
+
directory: routesDir,
|
|
62
|
+
viewsDir,
|
|
63
|
+
prefix: `/${moduleName}`,
|
|
64
|
+
...services,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
console.log(`[module] ${moduleName} → /${moduleName}/*`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Call a module's controller method programmatically.
|
|
73
|
+
* Syntax: `moduleRun('blog/posts/index', ctx)` or `moduleRun('blog/posts', ctx)`
|
|
74
|
+
*/
|
|
75
|
+
export async function moduleRun(path: string, ctx?: any): Promise<any> {
|
|
76
|
+
const parts = path.split('/')
|
|
77
|
+
const moduleName = parts[0]
|
|
78
|
+
const method = parts.length > 2 ? parts.pop() : 'index'
|
|
79
|
+
const controllerPath = parts.slice(1).join('/') || 'index'
|
|
80
|
+
|
|
81
|
+
const fullPath = join(process.cwd(), 'modules', moduleName, 'routes', `${controllerPath}.ts`)
|
|
82
|
+
if (!existsSync(fullPath)) {
|
|
83
|
+
throw new Error(`[hmvc] Module route not found: ${moduleName}/${controllerPath}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const mod = await import(fullPath)
|
|
87
|
+
const ControllerClass = findExport(mod)
|
|
88
|
+
if (!ControllerClass) throw new Error(`[hmvc] No controller in ${moduleName}/${controllerPath}`)
|
|
89
|
+
|
|
90
|
+
const instance = new ControllerClass()
|
|
91
|
+
if (ctx) {
|
|
92
|
+
;(instance as any).ctx = ctx
|
|
93
|
+
if (ctx.session) (instance as any).session = ctx.session
|
|
94
|
+
if (ctx.db) (instance as any).db = ctx.db
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fn = instance[method]
|
|
98
|
+
if (typeof fn !== 'function') throw new Error(`[hmvc] No method ${method} in ${moduleName}/${controllerPath}`)
|
|
99
|
+
|
|
100
|
+
return fn.call(instance)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findExport(mod: Record<string, any>): any {
|
|
104
|
+
for (const key of Object.keys(mod)) {
|
|
105
|
+
const val = mod[key]
|
|
106
|
+
if (typeof val === 'function' && val.prototype) {
|
|
107
|
+
let proto = val.prototype
|
|
108
|
+
while (proto) {
|
|
109
|
+
if (proto.constructor?.name === 'Controller') return val
|
|
110
|
+
proto = Object.getPrototypeOf(proto)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI — auto-generate OpenAPI 3.1 spec from registered routes.
|
|
3
|
+
*
|
|
4
|
+
* Users can customize route documentation:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // In any controller file (top-level)
|
|
8
|
+
* OpenAPIRegistry.add('/posts', 'GET', {
|
|
9
|
+
* summary: 'List all posts',
|
|
10
|
+
* description: 'Returns paginated list of blog posts',
|
|
11
|
+
* tags: ['Blog'],
|
|
12
|
+
* })
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import type { Elysia } from 'elysia'
|
|
16
|
+
|
|
17
|
+
export interface OpenAPIConfig {
|
|
18
|
+
title?: string
|
|
19
|
+
version?: string
|
|
20
|
+
description?: string
|
|
21
|
+
specPath?: string
|
|
22
|
+
docsPath?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RouteDoc {
|
|
26
|
+
summary?: string
|
|
27
|
+
description?: string
|
|
28
|
+
tags?: string[]
|
|
29
|
+
parameters?: any[]
|
|
30
|
+
requestBody?: any
|
|
31
|
+
responses?: Record<string, any>
|
|
32
|
+
deprecated?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const defaults: OpenAPIConfig = {
|
|
36
|
+
title: 'Bunigniter API',
|
|
37
|
+
version: '1.0.0',
|
|
38
|
+
specPath: '/openapi.json',
|
|
39
|
+
docsPath: '/docs',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── User-facing registry ──────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const routeDocs = new Map<string, Map<string, RouteDoc>>()
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register OpenAPI documentation for a route.
|
|
48
|
+
* Call this at the top level of any route file.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* // routes/posts.ts
|
|
53
|
+
* import { OpenAPIRegistry } from 'bunigniter/helpers/openapi'
|
|
54
|
+
*
|
|
55
|
+
* OpenAPIRegistry.add('/posts', 'GET', {
|
|
56
|
+
* summary: 'List all posts',
|
|
57
|
+
* tags: ['Blog'],
|
|
58
|
+
* })
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export const OpenAPIRegistry = {
|
|
62
|
+
add(path: string, method: string, doc: RouteDoc): void {
|
|
63
|
+
const m = method.toUpperCase()
|
|
64
|
+
if (!routeDocs.has(path)) routeDocs.set(path, new Map())
|
|
65
|
+
routeDocs.get(path)!.set(m, doc)
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Spec generation ───────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export function openapi(app: any, config?: OpenAPIConfig): void {
|
|
72
|
+
const cfg = { ...defaults, ...config }
|
|
73
|
+
|
|
74
|
+
app.get(cfg.specPath, () => {
|
|
75
|
+
const spec = generateSpec(app, cfg)
|
|
76
|
+
return new Response(JSON.stringify(spec, null, 2), {
|
|
77
|
+
headers: { 'content-type': 'application/json' },
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
app.get(cfg.docsPath, () => {
|
|
82
|
+
const html = `<!DOCTYPE html>
|
|
83
|
+
<html><head>
|
|
84
|
+
<title>${cfg.title}</title>
|
|
85
|
+
<meta charset="utf-8" />
|
|
86
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
87
|
+
</head><body>
|
|
88
|
+
<script id="api-reference" data-url="${cfg.specPath}"></script>
|
|
89
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
90
|
+
</body></html>`
|
|
91
|
+
return new Response(html, {
|
|
92
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function generateSpec(app: any, cfg: OpenAPIConfig): Record<string, any> {
|
|
98
|
+
const routes: Array<{ method: string; path: string }> = app.routes ?? []
|
|
99
|
+
const tags = new Set<string>()
|
|
100
|
+
const paths: Record<string, any> = {}
|
|
101
|
+
|
|
102
|
+
for (const route of routes) {
|
|
103
|
+
const method = route.method.toLowerCase()
|
|
104
|
+
const path = route.path
|
|
105
|
+
const userDoc = routeDocs.get(route.path)?.get(route.method)
|
|
106
|
+
|
|
107
|
+
if (!paths[path]) paths[path] = {}
|
|
108
|
+
|
|
109
|
+
const entry = userDoc ?? {}
|
|
110
|
+
if (entry.tags) entry.tags.forEach((t: string) => tags.add(t))
|
|
111
|
+
|
|
112
|
+
paths[path][method] = {
|
|
113
|
+
summary: entry.summary ?? `${route.method} ${path}`,
|
|
114
|
+
description: entry.description ?? '',
|
|
115
|
+
tags: entry.tags,
|
|
116
|
+
deprecated: entry.deprecated,
|
|
117
|
+
parameters: entry.parameters ?? extractParams(path),
|
|
118
|
+
...(entry.requestBody ? { requestBody: entry.requestBody } : {}),
|
|
119
|
+
responses: entry.responses ?? { '200': { description: 'Successful response' } },
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
openapi: '3.1.0',
|
|
125
|
+
info: { title: cfg.title, version: cfg.version, description: cfg.description ?? '' },
|
|
126
|
+
tags: [...tags].map(name => ({ name })),
|
|
127
|
+
paths,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function extractParams(path: string): any[] {
|
|
132
|
+
const params: any[] = []
|
|
133
|
+
const matches = path.match(/:(\w+)/g)
|
|
134
|
+
if (matches) {
|
|
135
|
+
for (const m of matches) {
|
|
136
|
+
params.push({ name: m.slice(1), in: 'path', required: true, schema: { type: 'string' } })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return params
|
|
140
|
+
}
|