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,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
+ }