@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.
Files changed (82) hide show
  1. package/README.md +19 -0
  2. package/package.json +65 -0
  3. package/src/application/Application.ts +241 -0
  4. package/src/cache/CacheManager.ts +180 -0
  5. package/src/cache/FileCacheStore.ts +113 -0
  6. package/src/cache/MemcachedCacheStore.ts +115 -0
  7. package/src/cache/MemoryCacheStore.ts +62 -0
  8. package/src/cache/NullCacheStore.ts +39 -0
  9. package/src/cache/RedisCacheStore.ts +125 -0
  10. package/src/cache/events.ts +52 -0
  11. package/src/config/ConfigRepository.ts +115 -0
  12. package/src/config/env.ts +26 -0
  13. package/src/container/Container.ts +198 -0
  14. package/src/container/ContextualBindingBuilder.ts +21 -0
  15. package/src/contracts/Cache.ts +49 -0
  16. package/src/contracts/Config.ts +24 -0
  17. package/src/contracts/Container.ts +68 -0
  18. package/src/contracts/DriverManager.ts +16 -0
  19. package/src/contracts/Encrypter.ts +32 -0
  20. package/src/contracts/EventDispatcher.ts +32 -0
  21. package/src/contracts/ExceptionHandler.ts +20 -0
  22. package/src/contracts/Hasher.ts +19 -0
  23. package/src/contracts/Middleware.ts +23 -0
  24. package/src/contracts/Request.ts +54 -0
  25. package/src/contracts/Response.ts +19 -0
  26. package/src/contracts/Router.ts +62 -0
  27. package/src/contracts/ServiceProvider.ts +31 -0
  28. package/src/contracts/Session.ts +47 -0
  29. package/src/encryption/Encrypter.ts +197 -0
  30. package/src/encryption/errors.ts +30 -0
  31. package/src/errors/ConfigKeyNotFoundError.ts +7 -0
  32. package/src/errors/ContainerResolutionError.ts +13 -0
  33. package/src/errors/ForbiddenError.ts +7 -0
  34. package/src/errors/HttpError.ts +16 -0
  35. package/src/errors/MantiqError.ts +16 -0
  36. package/src/errors/NotFoundError.ts +7 -0
  37. package/src/errors/TokenMismatchError.ts +10 -0
  38. package/src/errors/TooManyRequestsError.ts +10 -0
  39. package/src/errors/UnauthorizedError.ts +7 -0
  40. package/src/errors/ValidationError.ts +10 -0
  41. package/src/exceptions/DevErrorPage.ts +564 -0
  42. package/src/exceptions/Handler.ts +118 -0
  43. package/src/hashing/Argon2Hasher.ts +46 -0
  44. package/src/hashing/BcryptHasher.ts +36 -0
  45. package/src/hashing/HashManager.ts +80 -0
  46. package/src/helpers/abort.ts +46 -0
  47. package/src/helpers/app.ts +17 -0
  48. package/src/helpers/cache.ts +12 -0
  49. package/src/helpers/config.ts +15 -0
  50. package/src/helpers/encrypt.ts +22 -0
  51. package/src/helpers/env.ts +1 -0
  52. package/src/helpers/hash.ts +20 -0
  53. package/src/helpers/response.ts +69 -0
  54. package/src/helpers/route.ts +24 -0
  55. package/src/helpers/session.ts +11 -0
  56. package/src/http/Cookie.ts +26 -0
  57. package/src/http/Kernel.ts +252 -0
  58. package/src/http/Request.ts +249 -0
  59. package/src/http/Response.ts +112 -0
  60. package/src/http/UploadedFile.ts +56 -0
  61. package/src/index.ts +97 -0
  62. package/src/macroable/Macroable.ts +174 -0
  63. package/src/middleware/Cors.ts +91 -0
  64. package/src/middleware/EncryptCookies.ts +101 -0
  65. package/src/middleware/Pipeline.ts +66 -0
  66. package/src/middleware/StartSession.ts +90 -0
  67. package/src/middleware/TrimStrings.ts +32 -0
  68. package/src/middleware/VerifyCsrfToken.ts +130 -0
  69. package/src/providers/CoreServiceProvider.ts +97 -0
  70. package/src/routing/ResourceRegistrar.ts +64 -0
  71. package/src/routing/Route.ts +40 -0
  72. package/src/routing/RouteCollection.ts +50 -0
  73. package/src/routing/RouteMatcher.ts +92 -0
  74. package/src/routing/Router.ts +280 -0
  75. package/src/routing/events.ts +19 -0
  76. package/src/session/SessionManager.ts +75 -0
  77. package/src/session/Store.ts +192 -0
  78. package/src/session/handlers/CookieSessionHandler.ts +42 -0
  79. package/src/session/handlers/FileSessionHandler.ts +79 -0
  80. package/src/session/handlers/MemorySessionHandler.ts +35 -0
  81. package/src/websocket/WebSocketContext.ts +20 -0
  82. 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
+ }