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,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-Derived Validators — Void-style createInsertSchema / createSelectSchema / createUpdateSchema.
|
|
3
|
+
*
|
|
4
|
+
* Generates Zod schemas from Drizzle table definitions for use with
|
|
5
|
+
* `defineHandler.withValidator()`.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* // db/schema.ts
|
|
10
|
+
* import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
|
11
|
+
* import { createInsertSchema } from 'nexusts/db'
|
|
12
|
+
*
|
|
13
|
+
* export const users = sqliteTable('users', {
|
|
14
|
+
* id: integer('id').primaryKey({ autoIncrement: true }),
|
|
15
|
+
* name: text('name').notNull(),
|
|
16
|
+
* email: text('email').notNull().unique(),
|
|
17
|
+
* role: text('role').notNull().default('user'),
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* export const insertUserSchema = createInsertSchema(users, {
|
|
21
|
+
* name: (schema) => schema.min(2),
|
|
22
|
+
* email: (schema) => schema.email(),
|
|
23
|
+
* })
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Then in a route handler:
|
|
27
|
+
* ```ts
|
|
28
|
+
* import { defineHandler } from 'nexusts'
|
|
29
|
+
* import { insertUserSchema } from '@schema'
|
|
30
|
+
*
|
|
31
|
+
* export const POST = defineHandler.withValidator({
|
|
32
|
+
* body: insertUserSchema,
|
|
33
|
+
* })(async (c, { body }) => {
|
|
34
|
+
* return db.insert(users).values(body).returning()
|
|
35
|
+
* })
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
import { z } from 'zod'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Column type info extracted from a Drizzle table column definition.
|
|
42
|
+
*/
|
|
43
|
+
interface ColumnInfo {
|
|
44
|
+
name: string
|
|
45
|
+
type: string
|
|
46
|
+
notNull: boolean
|
|
47
|
+
hasDefault: boolean
|
|
48
|
+
isPrimaryKey: boolean
|
|
49
|
+
isAutoIncrement: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Infer column info from a Drizzle table's `_` internal structure.
|
|
54
|
+
* This works with drizzle-orm >= 0.45.
|
|
55
|
+
*/
|
|
56
|
+
function getColumns(table: any): ColumnInfo[] {
|
|
57
|
+
const cols: ColumnInfo[] = []
|
|
58
|
+
// Drizzle stores columns in table[Symbol.for('drizzle:columns')]
|
|
59
|
+
const drizzleCols = table?.[Symbol.for('drizzle:columns')] ?? table?.['_'] ?? {}
|
|
60
|
+
|
|
61
|
+
for (const [name, col] of Object.entries<any>(drizzleCols)) {
|
|
62
|
+
cols.push({
|
|
63
|
+
name,
|
|
64
|
+
type: col?.type ?? 'text',
|
|
65
|
+
notNull: col?.notNull ?? false,
|
|
66
|
+
hasDefault: col?.hasDefault ?? false,
|
|
67
|
+
isPrimaryKey: col?.primaryKey ?? false,
|
|
68
|
+
isAutoIncrement: col?.autoIncrement ?? false,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fallback: try to get columns from the table's structure
|
|
73
|
+
if (cols.length === 0) {
|
|
74
|
+
for (const key of Object.keys(table)) {
|
|
75
|
+
if (key.startsWith('_') || key === 'name' || key === 'Symbol') continue
|
|
76
|
+
const col = table[key]
|
|
77
|
+
if (col && typeof col === 'object' && col.name) {
|
|
78
|
+
cols.push({
|
|
79
|
+
name: col.name,
|
|
80
|
+
type: col.type ?? 'text',
|
|
81
|
+
notNull: col.notNull ?? false,
|
|
82
|
+
hasDefault: col.hasDefault ?? false,
|
|
83
|
+
isPrimaryKey: col.primaryKey ?? false,
|
|
84
|
+
isAutoIncrement: col.autoIncrement ?? false,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return cols
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Map Drizzle column types to Zod schemas.
|
|
95
|
+
*/
|
|
96
|
+
function columnToZod(col: ColumnInfo): z.ZodTypeAny {
|
|
97
|
+
let schema: z.ZodTypeAny
|
|
98
|
+
|
|
99
|
+
switch (col.type) {
|
|
100
|
+
case 'number':
|
|
101
|
+
case 'integer':
|
|
102
|
+
case 'serial':
|
|
103
|
+
schema = z.number()
|
|
104
|
+
break
|
|
105
|
+
case 'boolean':
|
|
106
|
+
schema = z.boolean()
|
|
107
|
+
break
|
|
108
|
+
case 'json':
|
|
109
|
+
case 'jsonb':
|
|
110
|
+
schema = z.record(z.any())
|
|
111
|
+
break
|
|
112
|
+
case 'text':
|
|
113
|
+
case 'varchar':
|
|
114
|
+
case 'char':
|
|
115
|
+
case 'string':
|
|
116
|
+
default:
|
|
117
|
+
schema = z.string()
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Apply nullability
|
|
122
|
+
if (!col.notNull) {
|
|
123
|
+
schema = schema.nullable().optional()
|
|
124
|
+
}
|
|
125
|
+
if (col.isAutoIncrement) {
|
|
126
|
+
schema = schema.optional()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return schema
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a Zod schema for INSERT operations.
|
|
134
|
+
* Auto-generated columns (id, timestamps with defaults) become optional.
|
|
135
|
+
*
|
|
136
|
+
* @param table - Drizzle table definition
|
|
137
|
+
* @param refinements - Optional field-level refinements (e.g. `.min(2)`, `.email()`)
|
|
138
|
+
* @returns Zod object schema
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```ts
|
|
142
|
+
* export const insertUserSchema = createInsertSchema(users, {
|
|
143
|
+
* name: (schema) => schema.min(2),
|
|
144
|
+
* email: (schema) => schema.email(),
|
|
145
|
+
* })
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export function createInsertSchema<T extends Record<string, any>>(
|
|
149
|
+
table: any,
|
|
150
|
+
refinements?: Partial<{
|
|
151
|
+
[K in keyof T]: (schema: z.ZodTypeAny) => z.ZodTypeAny
|
|
152
|
+
}>
|
|
153
|
+
): z.ZodObject<any> {
|
|
154
|
+
const columns = getColumns(table)
|
|
155
|
+
const shape: Record<string, z.ZodTypeAny> = {}
|
|
156
|
+
|
|
157
|
+
for (const col of columns) {
|
|
158
|
+
// Skip auto-increment primary keys (DB generates them)
|
|
159
|
+
if (col.isAutoIncrement) continue
|
|
160
|
+
|
|
161
|
+
let schema = columnToZod(col)
|
|
162
|
+
|
|
163
|
+
// Apply refinements
|
|
164
|
+
if (refinements && refinements[col.name as keyof typeof refinements]) {
|
|
165
|
+
const refine = refinements[col.name as keyof typeof refinements]!
|
|
166
|
+
schema = refine(schema)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
shape[col.name] = schema
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return z.object(shape)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create a Zod schema for SELECT operations.
|
|
177
|
+
* All columns are optional (you might not select all).
|
|
178
|
+
*
|
|
179
|
+
* @param table - Drizzle table definition
|
|
180
|
+
* @returns Zod object schema
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```ts
|
|
184
|
+
* export const selectUserSchema = createSelectSchema(users)
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export function createSelectSchema(table: any): z.ZodObject<any> {
|
|
188
|
+
const columns = getColumns(table)
|
|
189
|
+
const shape: Record<string, z.ZodTypeAny> = {}
|
|
190
|
+
|
|
191
|
+
for (const col of columns) {
|
|
192
|
+
shape[col.name] = columnToZod(col).optional()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return z.object(shape)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create a Zod schema for UPDATE operations.
|
|
200
|
+
* All fields are optional (partial update).
|
|
201
|
+
*
|
|
202
|
+
* @param table - Drizzle table definition
|
|
203
|
+
* @param refinements - Optional field-level refinements
|
|
204
|
+
* @returns Zod object schema
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```ts
|
|
208
|
+
* export const updateUserSchema = createUpdateSchema(users, {
|
|
209
|
+
* email: (schema) => schema.email(),
|
|
210
|
+
* })
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export function createUpdateSchema<T extends Record<string, any>>(
|
|
214
|
+
table: any,
|
|
215
|
+
refinements?: Partial<{
|
|
216
|
+
[K in keyof T]: (schema: z.ZodTypeAny) => z.ZodTypeAny
|
|
217
|
+
}>
|
|
218
|
+
): z.ZodObject<any> {
|
|
219
|
+
const insertSchema = createInsertSchema(table, refinements)
|
|
220
|
+
const shape = insertSchema.shape
|
|
221
|
+
|
|
222
|
+
// Make all fields optional
|
|
223
|
+
const optionalShape: Record<string, z.ZodTypeAny> = {}
|
|
224
|
+
for (const [key, schema] of Object.entries(shape)) {
|
|
225
|
+
optionalShape[key] = (schema as z.ZodTypeAny).optional()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return z.object(optionalShape)
|
|
229
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge routes builder — generates pre-compiled route handlers for Edge runtimes.
|
|
3
|
+
*
|
|
4
|
+
* Usage: `bun run bi build:edge`
|
|
5
|
+
*
|
|
6
|
+
* This scans `pages/` and generates `edge-app.ts` — a static file with
|
|
7
|
+
* all routes pre-registered. Works on Cloudflare Workers, Deno, etc.
|
|
8
|
+
*/
|
|
9
|
+
import { readdirSync, statSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join, basename } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const CWD = process.cwd()
|
|
13
|
+
|
|
14
|
+
export async function buildEdgeRoutes(): Promise<void> {
|
|
15
|
+
// Scan routes/ for controllers (was pages/, now routes/)
|
|
16
|
+
const routesDir = join(CWD, 'routes')
|
|
17
|
+
if (!existsSync(routesDir)) {
|
|
18
|
+
console.error('[build] No routes/ directory found.')
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const files = scanDir(routesDir)
|
|
23
|
+
const imports: string[] = []
|
|
24
|
+
const routes: string[] = []
|
|
25
|
+
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
if (file.endsWith('.server.ts')) continue
|
|
28
|
+
|
|
29
|
+
const fullPath = join(routesDir, file)
|
|
30
|
+
const s = statSync(fullPath)
|
|
31
|
+
if (!s.isFile()) continue
|
|
32
|
+
|
|
33
|
+
// Read the file to find the exported class name
|
|
34
|
+
const content = readFileSync(fullPath, 'utf-8')
|
|
35
|
+
const classMatch = content.match(/export class (\w+) extends Controller/)
|
|
36
|
+
const name = classMatch ? classMatch[1] : 'UnknownController'
|
|
37
|
+
const importPath = `./routes/${file}`
|
|
38
|
+
const isIndex = basename(file, '.ts') === 'index'
|
|
39
|
+
const prefix = process.env.ROUTER_PREFIX ?? '/api'
|
|
40
|
+
|
|
41
|
+
// Build URL path
|
|
42
|
+
let urlPath
|
|
43
|
+
if (isIndex) {
|
|
44
|
+
urlPath = prefix
|
|
45
|
+
} else {
|
|
46
|
+
urlPath = file
|
|
47
|
+
.replace(/\.ts$/, '')
|
|
48
|
+
.replace(/\.\.\./g, '*')
|
|
49
|
+
.replace(/\[(\w+)\]/g, ':$1')
|
|
50
|
+
urlPath = `${prefix}/${urlPath}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
imports.push(`import { ${name} } from '${importPath}'`)
|
|
54
|
+
|
|
55
|
+
const methodMap: Record<string, string> = {
|
|
56
|
+
index: 'GET', show: 'GET', create: 'POST',
|
|
57
|
+
update: 'PUT', destroy: 'DELETE',
|
|
58
|
+
}
|
|
59
|
+
const idMethods = new Set(['show', 'update', 'destroy'])
|
|
60
|
+
|
|
61
|
+
// Only generate routes for methods that exist on the controller
|
|
62
|
+
for (const [method, verb] of Object.entries(methodMap)) {
|
|
63
|
+
if (!content.includes(`async ${method}`)) continue
|
|
64
|
+
|
|
65
|
+
const isIdMethod = idMethods.has(method)
|
|
66
|
+
const methodPath = isIdMethod ? `${urlPath}/:id` : urlPath
|
|
67
|
+
const httpMethod = verb.toLowerCase()
|
|
68
|
+
|
|
69
|
+
routes.push(` app.${httpMethod}('${methodPath}', async (ctx) => {
|
|
70
|
+
const ctrl = new ${name}()
|
|
71
|
+
;(ctrl as any).ctx = ctx
|
|
72
|
+
const id = ctx.params?.id ? Number(ctx.params.id) : undefined
|
|
73
|
+
const result = await ctrl.${method}(${isIdMethod ? 'id' : ''})
|
|
74
|
+
if (result instanceof Response) return result
|
|
75
|
+
return new Response(JSON.stringify(result ?? {}), {
|
|
76
|
+
headers: { 'content-type': 'application/json' }
|
|
77
|
+
})
|
|
78
|
+
})`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const output = `// Auto-generated edge routes — do not edit manually.
|
|
83
|
+
// Generated by: bun run bi build:edge
|
|
84
|
+
|
|
85
|
+
import { Elysia } from 'elysia'
|
|
86
|
+
|
|
87
|
+
${imports.join('\n')}
|
|
88
|
+
|
|
89
|
+
const app = new Elysia()
|
|
90
|
+
|
|
91
|
+
// Health check
|
|
92
|
+
app.get('/health', () => new Response(JSON.stringify({
|
|
93
|
+
status: 'ok',
|
|
94
|
+
runtime: typeof Bun !== 'undefined' ? 'bun' : 'edge',
|
|
95
|
+
}), { headers: { 'content-type': 'application/json' }}))
|
|
96
|
+
|
|
97
|
+
// Routes
|
|
98
|
+
${routes.join('\n\n')}
|
|
99
|
+
|
|
100
|
+
export default app
|
|
101
|
+
`
|
|
102
|
+
|
|
103
|
+
const outPath = join(CWD, 'edge-app.ts')
|
|
104
|
+
writeFileSync(outPath, output, 'utf-8')
|
|
105
|
+
console.log(`[build] Edge routes written to ${outPath}`)
|
|
106
|
+
console.log(`[build] ${files.length} controllers, ${routes.length} routes`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function scanDir(dir: string, baseDir = ''): string[] {
|
|
110
|
+
const files: string[] = []
|
|
111
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
files.push(...scanDir(join(dir, entry.name), baseDir ? `${baseDir}/${entry.name}` : entry.name))
|
|
115
|
+
} else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.startsWith('_')) {
|
|
116
|
+
files.push(baseDir ? `${baseDir}/${entry.name}` : entry.name)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return files.sort()
|
|
120
|
+
}
|
package/dist/edge.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge entry point — for Cloudflare Workers, Deno, and other Edge runtimes.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the Bun entry point (`src/index.ts`), this version:
|
|
5
|
+
* - Does NOT use file-based routing (pages are pre-registered)
|
|
6
|
+
* - Does NOT use node:fs modules
|
|
7
|
+
* - Uses Elysia v2's web-standard adapter
|
|
8
|
+
* - Exposes `fetch` handler directly
|
|
9
|
+
*
|
|
10
|
+
* Usage (Cloudflare Workers):
|
|
11
|
+
* ```ts
|
|
12
|
+
* import app from './src/edge'
|
|
13
|
+
* export default { fetch: app.fetch }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Usage (Deno):
|
|
17
|
+
* ```ts
|
|
18
|
+
* import app from './src/edge'
|
|
19
|
+
* Deno.serve(app.fetch)
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import { Elysia } from 'elysia'
|
|
23
|
+
import { applyMiddleware } from './helpers/middleware'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create an edge-compatible application.
|
|
27
|
+
* Routes must be registered manually or via a pre-built router.
|
|
28
|
+
*/
|
|
29
|
+
export function createEdgeApp(config?: { middleware?: any }) {
|
|
30
|
+
const app = new Elysia()
|
|
31
|
+
|
|
32
|
+
// Apply middleware
|
|
33
|
+
applyMiddleware(app, config?.middleware)
|
|
34
|
+
|
|
35
|
+
// Health check
|
|
36
|
+
app.get('/health', () => new Response(JSON.stringify({
|
|
37
|
+
status: 'ok',
|
|
38
|
+
runtime: typeof Bun !== 'undefined' ? 'bun' :
|
|
39
|
+
typeof (globalThis as any).Deno !== 'undefined' ? 'deno' :
|
|
40
|
+
typeof (globalThis as any).navigator !== 'undefined' ? 'cloudflare' :
|
|
41
|
+
'unknown',
|
|
42
|
+
timestamp: new Date().toISOString(),
|
|
43
|
+
}), {
|
|
44
|
+
headers: { 'content-type': 'application/json' },
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
return app
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register a route directly (edge-compatible, no filesystem access).
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* import { createEdgeApp, register } from './src/edge'
|
|
56
|
+
* const app = createEdgeApp()
|
|
57
|
+
* register(app, 'GET', '/api/hello', () => new Response('Hello Edge!'))
|
|
58
|
+
* export default { fetch: app.fetch }
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function register(
|
|
62
|
+
app: Elysia,
|
|
63
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
|
64
|
+
path: string,
|
|
65
|
+
handler: (...args: any[]) => any
|
|
66
|
+
): void {
|
|
67
|
+
const lower = method.toLowerCase() as 'get' | 'post' | 'put' | 'delete' | 'patch'
|
|
68
|
+
;(app as any)[lower](path, handler)
|
|
69
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache — CodeIgniter-style key-value cache with TTL support.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* // In a controller
|
|
7
|
+
* const users = this.cache.get('users_list')
|
|
8
|
+
* if (!users) {
|
|
9
|
+
* const data = await this.db.query('SELECT * FROM users')
|
|
10
|
+
* this.cache.set('users_list', data, 300) // 5 min TTL
|
|
11
|
+
* }
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export interface CacheOptions {
|
|
15
|
+
/** Default TTL in seconds. Default: 60 */
|
|
16
|
+
defaultTtl?: number
|
|
17
|
+
|
|
18
|
+
/** Max cache entries. Default: 1000 */
|
|
19
|
+
maxEntries?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CacheEntry {
|
|
23
|
+
data: any
|
|
24
|
+
expiresAt: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** In-memory cache store. */
|
|
28
|
+
const store = new Map<string, CacheEntry>()
|
|
29
|
+
|
|
30
|
+
/** Periodic cleanup. */
|
|
31
|
+
let cleanupTimer: Timer | null = null
|
|
32
|
+
function ensureCleanup(interval = 30000) {
|
|
33
|
+
if (cleanupTimer) return
|
|
34
|
+
cleanupTimer = setInterval(() => {
|
|
35
|
+
const now = Date.now()
|
|
36
|
+
for (const [key, entry] of store) {
|
|
37
|
+
if (entry.expiresAt <= now) store.delete(key)
|
|
38
|
+
}
|
|
39
|
+
}, interval)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cache service — get/set/delete/remember with TTL.
|
|
44
|
+
*
|
|
45
|
+
* Usage in a Controller (injected by the framework):
|
|
46
|
+
* ```ts
|
|
47
|
+
* this.cache.get('key')
|
|
48
|
+
* this.cache.set('key', value, 300)
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export class Cache {
|
|
52
|
+
private defaultTtl: number
|
|
53
|
+
private maxEntries: number
|
|
54
|
+
|
|
55
|
+
constructor(options: CacheOptions = {}) {
|
|
56
|
+
this.defaultTtl = options.defaultTtl ?? 60
|
|
57
|
+
this.maxEntries = options.maxEntries ?? 1000
|
|
58
|
+
ensureCleanup()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get a cached value.
|
|
63
|
+
* Returns undefined if key doesn't exist or is expired.
|
|
64
|
+
*/
|
|
65
|
+
get<T = any>(key: string): T | undefined {
|
|
66
|
+
const entry = store.get(key)
|
|
67
|
+
if (!entry) return undefined
|
|
68
|
+
if (entry.expiresAt <= Date.now()) {
|
|
69
|
+
store.delete(key)
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
return entry.data as T
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Set a cached value with optional TTL.
|
|
77
|
+
*
|
|
78
|
+
* @param key - Cache key
|
|
79
|
+
* @param value - Value to store
|
|
80
|
+
* @param ttl - Time to live in seconds. Default: config.defaultTtl
|
|
81
|
+
*/
|
|
82
|
+
set(key: string, value: any, ttl?: number): void {
|
|
83
|
+
if (store.size >= this.maxEntries) {
|
|
84
|
+
// Evict oldest entry
|
|
85
|
+
const oldest = store.entries().next().value
|
|
86
|
+
if (oldest) store.delete(oldest[0])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
store.set(key, {
|
|
90
|
+
data: value,
|
|
91
|
+
expiresAt: Date.now() + (ttl ?? this.defaultTtl) * 1000,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Delete a cached value.
|
|
97
|
+
*/
|
|
98
|
+
delete(key: string): void {
|
|
99
|
+
store.delete(key)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clear all cached values.
|
|
104
|
+
*/
|
|
105
|
+
clear(): void {
|
|
106
|
+
store.clear()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if a key exists and is not expired.
|
|
111
|
+
*/
|
|
112
|
+
has(key: string): boolean {
|
|
113
|
+
const entry = store.get(key)
|
|
114
|
+
if (!entry) return false
|
|
115
|
+
if (entry.expiresAt <= Date.now()) {
|
|
116
|
+
store.delete(key)
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remember — get or set via a callback.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* const users = await this.cache.remember('users', 300, async () => {
|
|
128
|
+
* return this.db.query('SELECT * FROM users')
|
|
129
|
+
* })
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
async remember<T>(key: string, ttl: number, callback: () => Promise<T>): Promise<T> {
|
|
133
|
+
const cached = this.get<T>(key)
|
|
134
|
+
if (cached !== undefined) return cached
|
|
135
|
+
|
|
136
|
+
const value = await callback()
|
|
137
|
+
this.set(key, value, ttl)
|
|
138
|
+
return value
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Increment a numeric value.
|
|
143
|
+
*/
|
|
144
|
+
increment(key: string, amount = 1): number {
|
|
145
|
+
const current = this.get<number>(key) ?? 0
|
|
146
|
+
const newValue = current + amount
|
|
147
|
+
this.set(key, newValue)
|
|
148
|
+
return newValue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Decrement a numeric value.
|
|
153
|
+
*/
|
|
154
|
+
decrement(key: string, amount = 1): number {
|
|
155
|
+
return this.increment(key, -amount)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Number of cached entries. */
|
|
159
|
+
get size(): number {
|
|
160
|
+
return store.size
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Create a shared cache instance.
|
|
166
|
+
*/
|
|
167
|
+
let _instance: Cache | null = null
|
|
168
|
+
export function createCache(options?: CacheOptions): Cache {
|
|
169
|
+
if (!_instance) {
|
|
170
|
+
_instance = new Cache(options)
|
|
171
|
+
}
|
|
172
|
+
return _instance
|
|
173
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS middleware — Cross-Origin Resource Sharing.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* // config/app.ts
|
|
7
|
+
* export default {
|
|
8
|
+
* middleware: {
|
|
9
|
+
* cors: {
|
|
10
|
+
* origin: ['https://myapp.com', 'http://localhost:5173'],
|
|
11
|
+
* methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
import { Elysia } from 'elysia'
|
|
18
|
+
|
|
19
|
+
export interface CORSOptions {
|
|
20
|
+
/** Allowed origins. Default: '*' */
|
|
21
|
+
origin?: string | string[] | ((origin: string) => boolean | string | undefined)
|
|
22
|
+
|
|
23
|
+
/** Allowed methods. Default: 'GET,POST,PUT,PATCH,DELETE,OPTIONS' */
|
|
24
|
+
methods?: string
|
|
25
|
+
|
|
26
|
+
/** Allowed headers. Default: 'Content-Type,Authorization,X-Inertia' */
|
|
27
|
+
allowedHeaders?: string
|
|
28
|
+
|
|
29
|
+
/** Expose headers. */
|
|
30
|
+
exposeHeaders?: string
|
|
31
|
+
|
|
32
|
+
/** Allow credentials (cookies). Default: true */
|
|
33
|
+
credentials?: boolean
|
|
34
|
+
|
|
35
|
+
/** Max age for preflight cache (seconds). Default: 86400 */
|
|
36
|
+
maxAge?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a CORS middleware plugin.
|
|
41
|
+
*/
|
|
42
|
+
export function corsMiddleware(options: CORSOptions = {}) {
|
|
43
|
+
const {
|
|
44
|
+
origin = '*',
|
|
45
|
+
methods = 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
|
|
46
|
+
allowedHeaders = 'Content-Type,Authorization,X-Inertia,X-Requested-With',
|
|
47
|
+
credentials = true,
|
|
48
|
+
maxAge = 86400,
|
|
49
|
+
exposeHeaders,
|
|
50
|
+
} = options
|
|
51
|
+
|
|
52
|
+
const app = new Elysia({ name: 'nexus-cors' })
|
|
53
|
+
|
|
54
|
+
app.derive(async (ctx: any) => {
|
|
55
|
+
const requestOrigin = ctx.request.headers.get('origin')
|
|
56
|
+
|
|
57
|
+
// Determine allowed origin
|
|
58
|
+
let allowOrigin = '*'
|
|
59
|
+
if (origin === '*') {
|
|
60
|
+
allowOrigin = requestOrigin ?? '*'
|
|
61
|
+
} else if (typeof origin === 'string') {
|
|
62
|
+
allowOrigin = origin
|
|
63
|
+
} else if (Array.isArray(origin)) {
|
|
64
|
+
if (requestOrigin && origin.includes(requestOrigin)) {
|
|
65
|
+
allowOrigin = requestOrigin
|
|
66
|
+
}
|
|
67
|
+
} else if (typeof origin === 'function') {
|
|
68
|
+
const result = origin(requestOrigin ?? '')
|
|
69
|
+
if (result) allowOrigin = typeof result === 'string' ? result : requestOrigin ?? '*'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Handle preflight
|
|
73
|
+
if (ctx.request.method === 'OPTIONS') {
|
|
74
|
+
return new Response(null, {
|
|
75
|
+
status: 204,
|
|
76
|
+
headers: {
|
|
77
|
+
'Access-Control-Allow-Origin': allowOrigin,
|
|
78
|
+
'Access-Control-Allow-Methods': methods,
|
|
79
|
+
'Access-Control-Allow-Headers': allowedHeaders,
|
|
80
|
+
'Access-Control-Max-Age': String(maxAge),
|
|
81
|
+
...(credentials ? { 'Access-Control-Allow-Credentials': 'true' } : {}),
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Store for later use
|
|
87
|
+
return { _corsOrigin: allowOrigin }
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
app.afterResponse((ctx: any) => {
|
|
91
|
+
const allowOrigin = ctx._corsOrigin ?? '*'
|
|
92
|
+
ctx.set.headers ??= {}
|
|
93
|
+
ctx.set.headers['Access-Control-Allow-Origin'] = allowOrigin
|
|
94
|
+
if (credentials && allowOrigin !== '*') {
|
|
95
|
+
ctx.set.headers['Access-Control-Allow-Credentials'] = 'true'
|
|
96
|
+
}
|
|
97
|
+
if (exposeHeaders) {
|
|
98
|
+
ctx.set.headers['Access-Control-Expose-Headers'] = exposeHeaders
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return app
|
|
103
|
+
}
|