@mantiq/core 0.0.1
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/README.md +19 -0
- package/package.json +65 -0
- package/src/application/Application.ts +241 -0
- package/src/cache/CacheManager.ts +180 -0
- package/src/cache/FileCacheStore.ts +113 -0
- package/src/cache/MemcachedCacheStore.ts +115 -0
- package/src/cache/MemoryCacheStore.ts +62 -0
- package/src/cache/NullCacheStore.ts +39 -0
- package/src/cache/RedisCacheStore.ts +125 -0
- package/src/cache/events.ts +52 -0
- package/src/config/ConfigRepository.ts +115 -0
- package/src/config/env.ts +26 -0
- package/src/container/Container.ts +198 -0
- package/src/container/ContextualBindingBuilder.ts +21 -0
- package/src/contracts/Cache.ts +49 -0
- package/src/contracts/Config.ts +24 -0
- package/src/contracts/Container.ts +68 -0
- package/src/contracts/DriverManager.ts +16 -0
- package/src/contracts/Encrypter.ts +32 -0
- package/src/contracts/EventDispatcher.ts +32 -0
- package/src/contracts/ExceptionHandler.ts +20 -0
- package/src/contracts/Hasher.ts +19 -0
- package/src/contracts/Middleware.ts +23 -0
- package/src/contracts/Request.ts +54 -0
- package/src/contracts/Response.ts +19 -0
- package/src/contracts/Router.ts +62 -0
- package/src/contracts/ServiceProvider.ts +31 -0
- package/src/contracts/Session.ts +47 -0
- package/src/encryption/Encrypter.ts +197 -0
- package/src/encryption/errors.ts +30 -0
- package/src/errors/ConfigKeyNotFoundError.ts +7 -0
- package/src/errors/ContainerResolutionError.ts +13 -0
- package/src/errors/ForbiddenError.ts +7 -0
- package/src/errors/HttpError.ts +16 -0
- package/src/errors/MantiqError.ts +16 -0
- package/src/errors/NotFoundError.ts +7 -0
- package/src/errors/TokenMismatchError.ts +10 -0
- package/src/errors/TooManyRequestsError.ts +10 -0
- package/src/errors/UnauthorizedError.ts +7 -0
- package/src/errors/ValidationError.ts +10 -0
- package/src/exceptions/DevErrorPage.ts +564 -0
- package/src/exceptions/Handler.ts +118 -0
- package/src/hashing/Argon2Hasher.ts +46 -0
- package/src/hashing/BcryptHasher.ts +36 -0
- package/src/hashing/HashManager.ts +80 -0
- package/src/helpers/abort.ts +46 -0
- package/src/helpers/app.ts +17 -0
- package/src/helpers/cache.ts +12 -0
- package/src/helpers/config.ts +15 -0
- package/src/helpers/encrypt.ts +22 -0
- package/src/helpers/env.ts +1 -0
- package/src/helpers/hash.ts +20 -0
- package/src/helpers/response.ts +69 -0
- package/src/helpers/route.ts +24 -0
- package/src/helpers/session.ts +11 -0
- package/src/http/Cookie.ts +26 -0
- package/src/http/Kernel.ts +252 -0
- package/src/http/Request.ts +249 -0
- package/src/http/Response.ts +112 -0
- package/src/http/UploadedFile.ts +56 -0
- package/src/index.ts +97 -0
- package/src/macroable/Macroable.ts +174 -0
- package/src/middleware/Cors.ts +91 -0
- package/src/middleware/EncryptCookies.ts +101 -0
- package/src/middleware/Pipeline.ts +66 -0
- package/src/middleware/StartSession.ts +90 -0
- package/src/middleware/TrimStrings.ts +32 -0
- package/src/middleware/VerifyCsrfToken.ts +130 -0
- package/src/providers/CoreServiceProvider.ts +97 -0
- package/src/routing/ResourceRegistrar.ts +64 -0
- package/src/routing/Route.ts +40 -0
- package/src/routing/RouteCollection.ts +50 -0
- package/src/routing/RouteMatcher.ts +92 -0
- package/src/routing/Router.ts +280 -0
- package/src/routing/events.ts +19 -0
- package/src/session/SessionManager.ts +75 -0
- package/src/session/Store.ts +192 -0
- package/src/session/handlers/CookieSessionHandler.ts +42 -0
- package/src/session/handlers/FileSessionHandler.ts +79 -0
- package/src/session/handlers/MemorySessionHandler.ts +35 -0
- package/src/websocket/WebSocketContext.ts +20 -0
- package/src/websocket/WebSocketKernel.ts +60 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { Container, Constructor } from '../contracts/Container.ts'
|
|
2
|
+
import type { ExceptionHandler } from '../contracts/ExceptionHandler.ts'
|
|
3
|
+
import type { Middleware } from '../contracts/Middleware.ts'
|
|
4
|
+
import type { Router, RouteMatch } from '../contracts/Router.ts'
|
|
5
|
+
import { MantiqRequest } from './Request.ts'
|
|
6
|
+
import { MantiqResponse } from './Response.ts'
|
|
7
|
+
import { Pipeline } from '../middleware/Pipeline.ts'
|
|
8
|
+
import { WebSocketKernel } from '../websocket/WebSocketKernel.ts'
|
|
9
|
+
import { ConfigRepository } from '../config/ConfigRepository.ts'
|
|
10
|
+
|
|
11
|
+
export class HttpKernel {
|
|
12
|
+
/**
|
|
13
|
+
* Global middleware applied to every request, in order.
|
|
14
|
+
* Override in a subclass (or set via kernel.setGlobalMiddleware) to customise.
|
|
15
|
+
*/
|
|
16
|
+
protected globalMiddleware: string[] = []
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Named middleware groups (e.g., 'web', 'api').
|
|
20
|
+
* Route groups can reference these by name.
|
|
21
|
+
*/
|
|
22
|
+
protected middlewareGroups: Record<string, string[]> = {
|
|
23
|
+
web: [],
|
|
24
|
+
api: [],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolved middleware alias → class map.
|
|
29
|
+
* Populated by the middleware registration helper below.
|
|
30
|
+
*/
|
|
31
|
+
private middlewareAliases: Record<string, Constructor<Middleware>> = {}
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly container: Container,
|
|
35
|
+
private readonly router: Router,
|
|
36
|
+
private readonly exceptionHandler: ExceptionHandler,
|
|
37
|
+
private readonly wsKernel: WebSocketKernel,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
// ── Middleware registration ───────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register a middleware alias so routes can reference it by name.
|
|
44
|
+
* @example kernel.registerMiddleware('auth', AuthenticateMiddleware)
|
|
45
|
+
*/
|
|
46
|
+
registerMiddleware(alias: string, middleware: Constructor<Middleware>): void {
|
|
47
|
+
this.middlewareAliases[alias] = middleware
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
registerMiddlewareGroup(name: string, middleware: string[]): void {
|
|
51
|
+
this.middlewareGroups[name] = middleware
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setGlobalMiddleware(middleware: string[]): void {
|
|
55
|
+
this.globalMiddleware = middleware
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Middleware registered by packages that run before the app's global middleware.
|
|
60
|
+
* Separate from globalMiddleware so setGlobalMiddleware() doesn't overwrite them.
|
|
61
|
+
*/
|
|
62
|
+
private prependMiddleware: string[] = []
|
|
63
|
+
private appendMiddleware: string[] = []
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Prepend middleware aliases that run before the app's global middleware.
|
|
67
|
+
* Useful for packages that need to inject middleware without touching the app's config.
|
|
68
|
+
*/
|
|
69
|
+
prependGlobalMiddleware(...aliases: string[]): void {
|
|
70
|
+
for (const alias of aliases) {
|
|
71
|
+
if (!this.prependMiddleware.includes(alias)) {
|
|
72
|
+
this.prependMiddleware.push(alias)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Append middleware aliases that run after the app's global middleware.
|
|
79
|
+
*/
|
|
80
|
+
appendGlobalMiddleware(...aliases: string[]): void {
|
|
81
|
+
for (const alias of aliases) {
|
|
82
|
+
if (!this.appendMiddleware.includes(alias)) {
|
|
83
|
+
this.appendMiddleware.push(alias)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Request handling ─────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Main entry point. Passed to Bun.serve() as the fetch handler.
|
|
92
|
+
*/
|
|
93
|
+
async handle(bunRequest: Request, server: Server): Promise<Response> {
|
|
94
|
+
// WebSocket upgrade
|
|
95
|
+
if (bunRequest.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
96
|
+
return this.wsKernel.handleUpgrade(bunRequest, server)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const request = MantiqRequest.fromBun(bunRequest)
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// Combine prepend + global + append middleware
|
|
103
|
+
const allMiddleware = [...this.prependMiddleware, ...this.globalMiddleware, ...this.appendMiddleware]
|
|
104
|
+
const globalClasses = this.resolveMiddlewareList(allMiddleware)
|
|
105
|
+
|
|
106
|
+
const response = await new Pipeline(this.container)
|
|
107
|
+
.send(request)
|
|
108
|
+
.through(globalClasses)
|
|
109
|
+
.then(async (req) => {
|
|
110
|
+
// Match route
|
|
111
|
+
const match = this.router.resolve(req)
|
|
112
|
+
|
|
113
|
+
// Merge route params onto request
|
|
114
|
+
req.setRouteParams(match.params)
|
|
115
|
+
|
|
116
|
+
// Resolve route-level middleware
|
|
117
|
+
const routeClasses = this.resolveMiddlewareList(match.middleware)
|
|
118
|
+
|
|
119
|
+
return new Pipeline(this.container)
|
|
120
|
+
.send(req)
|
|
121
|
+
.through(routeClasses)
|
|
122
|
+
.then((req) => this.callAction(match, req))
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return this.prepareResponse(response)
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return this.exceptionHandler.render(request, err)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Start the Bun HTTP server.
|
|
133
|
+
*/
|
|
134
|
+
async start(): Promise<void> {
|
|
135
|
+
const config = this.container.make(ConfigRepository)
|
|
136
|
+
const port = config.get<number>('app.port', 3000)
|
|
137
|
+
const hostname = config.get<string>('app.host', '0.0.0.0')
|
|
138
|
+
|
|
139
|
+
Bun.serve({
|
|
140
|
+
port,
|
|
141
|
+
hostname,
|
|
142
|
+
fetch: (req, server) => this.handle(req, server),
|
|
143
|
+
websocket: this.wsKernel.getBunHandlers(),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
console.log(`Server running at http://${hostname === '0.0.0.0' ? 'localhost' : hostname}:${port}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Call the route action (controller method or closure).
|
|
153
|
+
* Converts the return value to a Response.
|
|
154
|
+
*/
|
|
155
|
+
private async callAction(match: RouteMatch, request: MantiqRequest): Promise<Response> {
|
|
156
|
+
const action = match.action
|
|
157
|
+
|
|
158
|
+
let result: any
|
|
159
|
+
|
|
160
|
+
if (typeof action === 'function') {
|
|
161
|
+
result = await action(request)
|
|
162
|
+
} else if (Array.isArray(action)) {
|
|
163
|
+
const [ControllerClass, method] = action
|
|
164
|
+
const controller = this.container.make(ControllerClass)
|
|
165
|
+
result = await (controller as any)[method](request)
|
|
166
|
+
} else {
|
|
167
|
+
throw new Error(`Unresolved string action '${action}'. Controllers must be registered with router.controllers().`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return this.prepareResponse(result)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Convert a controller return value to a native Response.
|
|
175
|
+
*/
|
|
176
|
+
private prepareResponse(value: any): Response {
|
|
177
|
+
if (value instanceof Response) return value
|
|
178
|
+
if (value === null || value === undefined) return MantiqResponse.noContent()
|
|
179
|
+
if (typeof value === 'string') return MantiqResponse.html(value)
|
|
180
|
+
if (typeof value === 'object' || Array.isArray(value)) return MantiqResponse.json(value)
|
|
181
|
+
return MantiqResponse.html(String(value))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve a list of middleware strings (aliases + parameters) to class constructors.
|
|
186
|
+
* Expands middleware groups automatically.
|
|
187
|
+
*/
|
|
188
|
+
private resolveMiddlewareList(list: string[]): Constructor<Middleware>[] {
|
|
189
|
+
const resolved: Constructor<Middleware>[] = []
|
|
190
|
+
|
|
191
|
+
for (const entry of list) {
|
|
192
|
+
// Check if it's a group name
|
|
193
|
+
if (this.middlewareGroups[entry]) {
|
|
194
|
+
resolved.push(...this.resolveMiddlewareList(this.middlewareGroups[entry]!))
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Parse alias:param1,param2
|
|
199
|
+
const colonIdx = entry.indexOf(':')
|
|
200
|
+
const alias = colonIdx === -1 ? entry : entry.slice(0, colonIdx)
|
|
201
|
+
const params = colonIdx === -1 ? [] : entry.slice(colonIdx + 1).split(',')
|
|
202
|
+
|
|
203
|
+
const MiddlewareClass = this.middlewareAliases[alias]
|
|
204
|
+
if (!MiddlewareClass) {
|
|
205
|
+
// Try resolving from container by string alias
|
|
206
|
+
try {
|
|
207
|
+
const mw = this.container.make<Constructor<Middleware>>(alias)
|
|
208
|
+
if (params.length > 0) {
|
|
209
|
+
resolved.push(this.wrapWithParams(mw, params))
|
|
210
|
+
} else {
|
|
211
|
+
resolved.push(mw)
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
console.warn(`[Mantiq] Unknown middleware alias: '${alias}'`)
|
|
215
|
+
}
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (params.length > 0) {
|
|
220
|
+
resolved.push(this.wrapWithParams(MiddlewareClass, params))
|
|
221
|
+
} else {
|
|
222
|
+
resolved.push(MiddlewareClass)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return resolved
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Wrap a middleware class so setParameters() is called before handle().
|
|
231
|
+
*/
|
|
232
|
+
private wrapWithParams(
|
|
233
|
+
MiddlewareClass: Constructor<Middleware>,
|
|
234
|
+
params: string[],
|
|
235
|
+
): Constructor<Middleware> {
|
|
236
|
+
const container = this.container
|
|
237
|
+
// @internal: Create a proxy class that injects parameters after instantiation
|
|
238
|
+
return class ParameterisedMiddleware {
|
|
239
|
+
private inner: Middleware
|
|
240
|
+
constructor() {
|
|
241
|
+
this.inner = container.make(MiddlewareClass)
|
|
242
|
+
this.inner.setParameters?.(params)
|
|
243
|
+
}
|
|
244
|
+
handle(request: MantiqRequest, next: () => Promise<Response>) {
|
|
245
|
+
return this.inner.handle(request, next)
|
|
246
|
+
}
|
|
247
|
+
terminate(request: MantiqRequest, response: Response) {
|
|
248
|
+
return this.inner.terminate?.(request, response)
|
|
249
|
+
}
|
|
250
|
+
} as unknown as Constructor<Middleware>
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { MantiqRequest as MantiqRequestContract } from '../contracts/Request.ts'
|
|
2
|
+
import type { SessionStore } from '../session/Store.ts'
|
|
3
|
+
import { UploadedFile } from './UploadedFile.ts'
|
|
4
|
+
import { parseCookies } from './Cookie.ts'
|
|
5
|
+
|
|
6
|
+
export class MantiqRequest implements MantiqRequestContract {
|
|
7
|
+
private parsedBody: Record<string, any> | null = null
|
|
8
|
+
private parsedFiles: Record<string, UploadedFile | UploadedFile[]> = {}
|
|
9
|
+
private parsedQuery: Record<string, string> | null = null
|
|
10
|
+
private routeParams: Record<string, any> = {}
|
|
11
|
+
private authenticatedUser: any = null
|
|
12
|
+
private cookies: Record<string, string> | null = null
|
|
13
|
+
private sessionStore: SessionStore | null = null
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly bunRequest: Request,
|
|
17
|
+
private readonly bunUrl: URL,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create from a Bun Request. Parses the URL once and caches it.
|
|
22
|
+
*/
|
|
23
|
+
static fromBun(request: Request): MantiqRequest {
|
|
24
|
+
const url = new URL(request.url)
|
|
25
|
+
return new MantiqRequest(request, url)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── HTTP basics ──────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
method(): string {
|
|
31
|
+
return this.bunRequest.method.toUpperCase()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
path(): string {
|
|
35
|
+
return this.bunUrl.pathname
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
url(): string {
|
|
39
|
+
return this.bunUrl.pathname + this.bunUrl.search
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fullUrl(): string {
|
|
43
|
+
return this.bunRequest.url
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Input ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
query(): Record<string, string>
|
|
49
|
+
query(key: string, defaultValue?: string): string
|
|
50
|
+
query(key?: string, defaultValue?: string): string | Record<string, string> {
|
|
51
|
+
if (!this.parsedQuery) {
|
|
52
|
+
this.parsedQuery = Object.fromEntries(this.bunUrl.searchParams.entries())
|
|
53
|
+
}
|
|
54
|
+
if (key === undefined) return this.parsedQuery
|
|
55
|
+
return this.parsedQuery[key] ?? defaultValue ?? (undefined as any)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async input(): Promise<Record<string, any>>
|
|
59
|
+
async input(key: string, defaultValue?: any): Promise<any>
|
|
60
|
+
async input(key?: string, defaultValue?: any): Promise<any> {
|
|
61
|
+
if (!this.parsedBody) {
|
|
62
|
+
await this.parseBody()
|
|
63
|
+
}
|
|
64
|
+
const merged = { ...this.query(), ...this.parsedBody }
|
|
65
|
+
if (key === undefined) return merged
|
|
66
|
+
return merged[key] ?? defaultValue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async only(...keys: string[]): Promise<Record<string, any>> {
|
|
70
|
+
const all = await this.input()
|
|
71
|
+
return Object.fromEntries(keys.filter((k) => k in all).map((k) => [k, all[k]]))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async except(...keys: string[]): Promise<Record<string, any>> {
|
|
75
|
+
const all = await this.input()
|
|
76
|
+
return Object.fromEntries(Object.entries(all).filter(([k]) => !keys.includes(k)))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
has(...keys: string[]): boolean {
|
|
80
|
+
const q = this.query()
|
|
81
|
+
return keys.every((k) => k in q || (this.parsedBody !== null && k in this.parsedBody))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async filled(...keys: string[]): Promise<boolean> {
|
|
85
|
+
const all = await this.input()
|
|
86
|
+
return keys.every((k) => all[k] !== undefined && all[k] !== '' && all[k] !== null)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Headers & metadata ───────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
header(key: string, defaultValue?: string): string | undefined {
|
|
92
|
+
return this.bunRequest.headers.get(key.toLowerCase()) ?? defaultValue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
headers(): Record<string, string> {
|
|
96
|
+
const result: Record<string, string> = {}
|
|
97
|
+
this.bunRequest.headers.forEach((value, key) => {
|
|
98
|
+
result[key] = value
|
|
99
|
+
})
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
cookie(key: string, defaultValue?: string): string | undefined {
|
|
104
|
+
if (!this.cookies) {
|
|
105
|
+
this.cookies = parseCookies(this.bunRequest.headers.get('cookie'))
|
|
106
|
+
}
|
|
107
|
+
return this.cookies[key] ?? defaultValue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setCookies(cookies: Record<string, string>): void {
|
|
111
|
+
this.cookies = cookies
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ip(): string {
|
|
115
|
+
// @internal: Bun doesn't expose IP on Request — callers should pass it via middleware if needed
|
|
116
|
+
return this.header('x-forwarded-for')?.split(',')[0]?.trim()
|
|
117
|
+
?? this.header('x-real-ip')
|
|
118
|
+
?? '127.0.0.1'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
userAgent(): string {
|
|
122
|
+
return this.header('user-agent') ?? ''
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
accepts(...types: string[]): string | false {
|
|
126
|
+
const acceptHeader = this.header('accept') ?? '*/*'
|
|
127
|
+
for (const type of types) {
|
|
128
|
+
if (acceptHeader.includes(type) || acceptHeader.includes('*/*')) {
|
|
129
|
+
return type
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
expectsJson(): boolean {
|
|
136
|
+
const accept = this.header('accept') ?? ''
|
|
137
|
+
return accept.includes('application/json') || accept.includes('text/json')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
isJson(): boolean {
|
|
141
|
+
const ct = this.header('content-type') ?? ''
|
|
142
|
+
return ct.includes('application/json')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Files ────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
file(key: string): UploadedFile | null {
|
|
148
|
+
const f = this.parsedFiles[key]
|
|
149
|
+
if (!f) return null
|
|
150
|
+
return Array.isArray(f) ? (f[0] ?? null) : f
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
files(key: string): UploadedFile[] {
|
|
154
|
+
const f = this.parsedFiles[key]
|
|
155
|
+
if (!f) return []
|
|
156
|
+
return Array.isArray(f) ? f : [f]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
hasFile(key: string): boolean {
|
|
160
|
+
return key in this.parsedFiles
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Route params ─────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
param(key: string, defaultValue?: any): any {
|
|
166
|
+
return this.routeParams[key] ?? defaultValue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
params(): Record<string, any> {
|
|
170
|
+
return { ...this.routeParams }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
setRouteParams(params: Record<string, any>): void {
|
|
174
|
+
this.routeParams = params
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Session ──────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
session(): SessionStore {
|
|
180
|
+
if (!this.sessionStore) {
|
|
181
|
+
throw new Error('Session has not been started. Ensure the StartSession middleware is active.')
|
|
182
|
+
}
|
|
183
|
+
return this.sessionStore
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
setSession(session: SessionStore): void {
|
|
187
|
+
this.sessionStore = session
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
hasSession(): boolean {
|
|
191
|
+
return this.sessionStore !== null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Auth ─────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
user<T = any>(): T | null {
|
|
197
|
+
return this.authenticatedUser as T | null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
isAuthenticated(): boolean {
|
|
201
|
+
return this.authenticatedUser !== null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setUser(user: any): void {
|
|
205
|
+
this.authenticatedUser = user
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Raw ──────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
raw(): Request {
|
|
211
|
+
return this.bunRequest
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
private async parseBody(): Promise<void> {
|
|
217
|
+
this.parsedBody = {}
|
|
218
|
+
|
|
219
|
+
const contentType = this.header('content-type') ?? ''
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (contentType.includes('application/json')) {
|
|
223
|
+
this.parsedBody = await this.bunRequest.clone().json()
|
|
224
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
225
|
+
const text = await this.bunRequest.clone().text()
|
|
226
|
+
this.parsedBody = Object.fromEntries(new URLSearchParams(text).entries())
|
|
227
|
+
} else if (contentType.includes('multipart/form-data')) {
|
|
228
|
+
const formData = await this.bunRequest.clone().formData()
|
|
229
|
+
for (const [key, value] of formData.entries()) {
|
|
230
|
+
if (value instanceof File) {
|
|
231
|
+
const uploaded = new UploadedFile(value)
|
|
232
|
+
if (this.parsedFiles[key]) {
|
|
233
|
+
const existing = this.parsedFiles[key]!
|
|
234
|
+
this.parsedFiles[key] = Array.isArray(existing)
|
|
235
|
+
? [...existing, uploaded]
|
|
236
|
+
: [existing, uploaded]
|
|
237
|
+
} else {
|
|
238
|
+
this.parsedFiles[key] = uploaded
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
this.parsedBody![key] = value
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Body parsing failed — leave parsedBody as empty object
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { CookieOptions, MantiqResponseBuilder } from '../contracts/Response.ts'
|
|
2
|
+
import { serializeCookie } from './Cookie.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Static factory methods for common response types.
|
|
6
|
+
*/
|
|
7
|
+
export class MantiqResponse {
|
|
8
|
+
static json(data: any, status: number = 200, headers?: Record<string, string>): Response {
|
|
9
|
+
return new Response(JSON.stringify(data), {
|
|
10
|
+
status,
|
|
11
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static html(content: string, status: number = 200): Response {
|
|
16
|
+
return new Response(content, {
|
|
17
|
+
status,
|
|
18
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static redirect(url: string, status: number = 302): Response {
|
|
23
|
+
return new Response(null, {
|
|
24
|
+
status,
|
|
25
|
+
headers: { Location: url },
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static noContent(): Response {
|
|
30
|
+
return new Response(null, { status: 204 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static stream(
|
|
34
|
+
callback: (controller: ReadableStreamDefaultController) => void | Promise<void>,
|
|
35
|
+
): Response {
|
|
36
|
+
const stream = new ReadableStream({ start: callback })
|
|
37
|
+
return new Response(stream)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static download(
|
|
41
|
+
content: Uint8Array | string,
|
|
42
|
+
filename: string,
|
|
43
|
+
mimeType?: string,
|
|
44
|
+
): Response {
|
|
45
|
+
return new Response(content, {
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': mimeType ?? 'application/octet-stream',
|
|
48
|
+
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Chainable response builder for use in middleware and controllers.
|
|
56
|
+
*/
|
|
57
|
+
export class ResponseBuilder implements MantiqResponseBuilder {
|
|
58
|
+
private statusCode: number = 200
|
|
59
|
+
private statusExplicitlySet: boolean = false
|
|
60
|
+
private responseHeaders: Record<string, string> = {}
|
|
61
|
+
private cookieStrings: string[] = []
|
|
62
|
+
|
|
63
|
+
status(code: number): this {
|
|
64
|
+
this.statusCode = code
|
|
65
|
+
this.statusExplicitlySet = true
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
header(key: string, value: string): this {
|
|
70
|
+
this.responseHeaders[key] = value
|
|
71
|
+
return this
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
withHeaders(headers: Record<string, string>): this {
|
|
75
|
+
Object.assign(this.responseHeaders, headers)
|
|
76
|
+
return this
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
cookie(name: string, value: string, options?: CookieOptions): this {
|
|
80
|
+
this.cookieStrings.push(serializeCookie(name, value, options))
|
|
81
|
+
return this
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
json(data: any): Response {
|
|
85
|
+
const headers = new Headers({
|
|
86
|
+
'Content-Type': 'application/json',
|
|
87
|
+
...this.responseHeaders,
|
|
88
|
+
})
|
|
89
|
+
for (const c of this.cookieStrings) headers.append('Set-Cookie', c)
|
|
90
|
+
return new Response(JSON.stringify(data), { status: this.statusCode, headers })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
html(content: string): Response {
|
|
94
|
+
const headers = new Headers({
|
|
95
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
96
|
+
...this.responseHeaders,
|
|
97
|
+
})
|
|
98
|
+
for (const c of this.cookieStrings) headers.append('Set-Cookie', c)
|
|
99
|
+
return new Response(content, { status: this.statusCode, headers })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
redirect(url: string): Response {
|
|
103
|
+
const headers = new Headers({ Location: url, ...this.responseHeaders })
|
|
104
|
+
for (const c of this.cookieStrings) headers.append('Set-Cookie', c)
|
|
105
|
+
return new Response(null, { status: this.statusExplicitlySet ? this.statusCode : 302, headers })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Shorthand to start a chainable builder */
|
|
110
|
+
export function response(): ResponseBuilder {
|
|
111
|
+
return new ResponseBuilder()
|
|
112
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { MantiqError } from '../errors/MantiqError.ts'
|
|
2
|
+
|
|
3
|
+
export class UploadedFile {
|
|
4
|
+
constructor(private readonly file: File) {}
|
|
5
|
+
|
|
6
|
+
/** Original filename */
|
|
7
|
+
name(): string {
|
|
8
|
+
return this.file.name
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** File extension (without dot) */
|
|
12
|
+
extension(): string {
|
|
13
|
+
const parts = this.file.name.split('.')
|
|
14
|
+
return parts.length > 1 ? (parts[parts.length - 1] ?? '') : ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** MIME type */
|
|
18
|
+
mimeType(): string {
|
|
19
|
+
return this.file.type
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Size in bytes */
|
|
23
|
+
size(): number {
|
|
24
|
+
return this.file.size
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** File was uploaded without errors */
|
|
28
|
+
isValid(): boolean {
|
|
29
|
+
return this.file.size > 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Store the file and return the stored path.
|
|
34
|
+
* @param path - Directory path to store in
|
|
35
|
+
* @param options.disk - Storage disk (currently only local filesystem)
|
|
36
|
+
*/
|
|
37
|
+
async store(path: string, _options?: { disk?: string }): Promise<string> {
|
|
38
|
+
const filename = `${Date.now()}_${this.file.name}`
|
|
39
|
+
const fullPath = `${path}/${filename}`
|
|
40
|
+
const bytes = await this.file.arrayBuffer()
|
|
41
|
+
await Bun.write(fullPath, bytes)
|
|
42
|
+
return fullPath
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async bytes(): Promise<Uint8Array> {
|
|
46
|
+
return new Uint8Array(await this.file.arrayBuffer())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async text(): Promise<string> {
|
|
50
|
+
return this.file.text()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
stream(): ReadableStream {
|
|
54
|
+
return this.file.stream()
|
|
55
|
+
}
|
|
56
|
+
}
|