@stravigor/core 0.1.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 (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. package/tsconfig.json +4 -0
@@ -0,0 +1,556 @@
1
+ import type { ServerWebSocket } from 'bun'
2
+ import { app } from '../core/application.ts'
3
+ import Context from './context.ts'
4
+ import { resolveCorsConfig, preflightResponse, withCorsHeaders } from './cors.ts'
5
+ import type { CorsOptions, ResolvedCorsConfig } from './cors.ts'
6
+ import { compose } from './middleware.ts'
7
+ import type { Handler, Middleware } from './middleware.ts'
8
+ import type { ExceptionHandler } from '../exceptions/exception_handler.ts'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ type Constructor<T = any> = new (...args: any[]) => T
15
+
16
+ /** A controller–method pair: `[ControllerClass, 'methodName']`. */
17
+ export type ControllerAction = [Constructor, string]
18
+
19
+ /** Accepted as a route handler: a function or a `[Controller, 'method']` tuple. */
20
+ export type HandlerInput = Handler | ControllerAction
21
+
22
+ interface RouteDefinition {
23
+ method: string
24
+ pattern: string
25
+ regex: RegExp
26
+ paramNames: string[]
27
+ handler: Handler
28
+ middleware: Middleware[]
29
+ name?: string
30
+ subdomain?: string
31
+ subdomainParamName?: string
32
+ }
33
+
34
+ export interface WebSocketHandlers {
35
+ open?: (ws: ServerWebSocket<WebSocketData>) => void
36
+ message?: (ws: ServerWebSocket<WebSocketData>, data: string | Buffer) => void
37
+ close?: (ws: ServerWebSocket<WebSocketData>) => void
38
+ drain?: (ws: ServerWebSocket<WebSocketData>) => void
39
+ }
40
+
41
+ export interface WebSocketData {
42
+ handlers: WebSocketHandlers
43
+ params: Record<string, string>
44
+ request?: Request
45
+ }
46
+
47
+ interface WebSocketRoute {
48
+ pattern: string
49
+ regex: RegExp
50
+ paramNames: string[]
51
+ handlers: WebSocketHandlers
52
+ subdomain?: string
53
+ subdomainParamName?: string
54
+ }
55
+
56
+ export interface GroupOptions {
57
+ prefix?: string
58
+ middleware?: Middleware[]
59
+ subdomain?: string
60
+ }
61
+
62
+ interface GroupState {
63
+ prefix: string
64
+ middleware: Middleware[]
65
+ subdomain?: string
66
+ subdomainParamName?: string
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Helpers
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /** Convert a route pattern to a RegExp and extract parameter names. */
74
+ function parsePattern(pattern: string): { regex: RegExp; paramNames: string[] } {
75
+ const paramNames: string[] = []
76
+
77
+ const regexStr = pattern
78
+ // wildcard catch-all: *path → (.+)
79
+ .replace(/\/\*(\w+)/, (_, name) => {
80
+ paramNames.push(name)
81
+ return '/(.+)'
82
+ })
83
+ // named params: :id → ([^/]+)
84
+ .replace(/:(\w+)/g, (_, name) => {
85
+ paramNames.push(name)
86
+ return '([^/]+)'
87
+ })
88
+
89
+ return { regex: new RegExp(`^${regexStr}$`), paramNames }
90
+ }
91
+
92
+ /** Parse a subdomain pattern, extracting any dynamic parameter name. */
93
+ function parseSubdomain(pattern: string): { value: string; paramName?: string } {
94
+ if (pattern.startsWith(':')) {
95
+ return { value: pattern, paramName: pattern.slice(1) }
96
+ }
97
+ return { value: pattern }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // RouteRef — returned by route methods for chaining (.as)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ class RouteRef {
105
+ constructor(private route: RouteDefinition) {}
106
+
107
+ /** Assign a name to this route (for future URL generation). */
108
+ as(name: string): this {
109
+ this.route.name = name
110
+ return this
111
+ }
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // ResourceRegistrar — fluent builder returned by router.resource()
116
+ // ---------------------------------------------------------------------------
117
+
118
+ class ResourceRegistrar {
119
+ private actions: Set<string> | null = null
120
+ private isSingleton = false
121
+
122
+ constructor(
123
+ private router: Router,
124
+ private path: string,
125
+ private controller: Record<string, Handler>,
126
+ private mw: Middleware[] | undefined,
127
+ private groupSnapshot: GroupState | undefined
128
+ ) {
129
+ // Defer registration so .only() / .singleton() can be chained first.
130
+ queueMicrotask(() => this.register())
131
+ }
132
+
133
+ /** Restrict to a subset of resource actions. */
134
+ only(actions: string[]): this {
135
+ this.actions = new Set(actions)
136
+ return this
137
+ }
138
+
139
+ /** Register as a singleton resource (show, update, destroy — no `:id` param). */
140
+ singleton(): this {
141
+ this.isSingleton = true
142
+ this.actions = new Set(['show', 'update', 'destroy'])
143
+ return this
144
+ }
145
+
146
+ private register(): void {
147
+ const has = (action: string) =>
148
+ this.controller[action] && (!this.actions || this.actions.has(action))
149
+
150
+ const bind = (method: Handler) => method.bind(this.controller)
151
+ const p = this.path
152
+ const suffix = this.isSingleton ? '' : '/:id'
153
+
154
+ // Restore group state that was active at construction time
155
+ if (this.groupSnapshot) this.router.groupStack.push(this.groupSnapshot)
156
+
157
+ const routes = () => {
158
+ if (has('index')) this.router.get(p, bind(this.controller.index!))
159
+ if (has('store')) this.router.post(p, bind(this.controller.store!))
160
+ if (has('show')) this.router.get(`${p}${suffix}`, bind(this.controller.show!))
161
+ if (has('update')) {
162
+ this.router.put(`${p}${suffix}`, bind(this.controller.update!))
163
+ this.router.patch(`${p}${suffix}`, bind(this.controller.update!))
164
+ }
165
+ if (has('destroy')) this.router.delete(`${p}${suffix}`, bind(this.controller.destroy!))
166
+ }
167
+
168
+ if (this.mw?.length) {
169
+ this.router.group({ prefix: '', middleware: this.mw }, routes)
170
+ } else {
171
+ routes()
172
+ }
173
+
174
+ if (this.groupSnapshot) this.router.groupStack.pop()
175
+ }
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Router
180
+ // ---------------------------------------------------------------------------
181
+
182
+ export default class Router {
183
+ private routes: RouteDefinition[] = []
184
+ private wsRoutes: WebSocketRoute[] = []
185
+ private globalMiddleware: Middleware[] = []
186
+ /** @internal Exposed for ResourceRegistrar deferred registration. */
187
+ groupStack: GroupState[] = []
188
+ private domain = 'localhost'
189
+ private corsConfig: ResolvedCorsConfig | null = null
190
+ private exceptionHandler: ExceptionHandler | null = null
191
+
192
+ /** Set the base domain used for subdomain extraction. */
193
+ setDomain(domain: string): void {
194
+ this.domain = domain
195
+ }
196
+
197
+ /**
198
+ * Enable CORS handling for the router.
199
+ *
200
+ * When configured, the router automatically responds to OPTIONS preflight
201
+ * requests and adds CORS headers to all matched route responses.
202
+ *
203
+ * @example
204
+ * router.cors({ origin: 'https://app.example.com', credentials: true })
205
+ * router.cors({ origin: ['https://app.example.com', 'https://admin.example.com'] })
206
+ * router.cors() // allow all origins
207
+ */
208
+ cors(options?: CorsOptions): void {
209
+ this.corsConfig = resolveCorsConfig(options)
210
+ }
211
+
212
+ /**
213
+ * Register an exception handler to catch thrown errors and convert them
214
+ * to HTTP responses.
215
+ *
216
+ * @example
217
+ * const handler = new ExceptionHandler(isDev)
218
+ * router.useExceptionHandler(handler)
219
+ */
220
+ useExceptionHandler(handler: ExceptionHandler): void {
221
+ this.exceptionHandler = handler
222
+ }
223
+
224
+ // ---- Global middleware ---------------------------------------------------
225
+
226
+ /** Register middleware that runs on every request. */
227
+ use(...middleware: Middleware[]): void {
228
+ this.globalMiddleware.push(...middleware)
229
+ }
230
+
231
+ // ---- HTTP route methods --------------------------------------------------
232
+
233
+ get(path: string, handler: HandlerInput): RouteRef {
234
+ return this.addRoute('GET', path, handler)
235
+ }
236
+
237
+ post(path: string, handler: HandlerInput): RouteRef {
238
+ return this.addRoute('POST', path, handler)
239
+ }
240
+
241
+ put(path: string, handler: HandlerInput): RouteRef {
242
+ return this.addRoute('PUT', path, handler)
243
+ }
244
+
245
+ patch(path: string, handler: HandlerInput): RouteRef {
246
+ return this.addRoute('PATCH', path, handler)
247
+ }
248
+
249
+ delete(path: string, handler: HandlerInput): RouteRef {
250
+ return this.addRoute('DELETE', path, handler)
251
+ }
252
+
253
+ head(path: string, handler: HandlerInput): RouteRef {
254
+ return this.addRoute('HEAD', path, handler)
255
+ }
256
+
257
+ options(path: string, handler: HandlerInput): RouteRef {
258
+ return this.addRoute('OPTIONS', path, handler)
259
+ }
260
+
261
+ // ---- Resource routes ------------------------------------------------------
262
+
263
+ /**
264
+ * Register RESTful resource routes for a controller.
265
+ *
266
+ * Accepts either a controller instance or a class constructor.
267
+ * When a class is passed, it is instantiated via {@link Container.make}
268
+ * with automatic dependency injection.
269
+ *
270
+ * Only registers routes for methods that exist on the controller.
271
+ * Returns a {@link ResourceRegistrar} for chaining `.only()` or `.singleton()`.
272
+ *
273
+ * @example
274
+ * router.resource('/users', UserController)
275
+ * router.resource('/posts', PostController).only(['index', 'show'])
276
+ * router.resource('/settings', SettingController).singleton()
277
+ */
278
+ resource(
279
+ path: string,
280
+ controller: Record<string, Handler> | Constructor,
281
+ middleware?: Middleware[]
282
+ ): ResourceRegistrar {
283
+ if (typeof controller === 'function') {
284
+ controller = app.make(controller) as Record<string, Handler>
285
+ }
286
+
287
+ const group = this.currentGroup()
288
+ return new ResourceRegistrar(
289
+ this,
290
+ path,
291
+ controller,
292
+ middleware,
293
+ group ? { ...group } : undefined
294
+ )
295
+ }
296
+
297
+ // ---- WebSocket routes ----------------------------------------------------
298
+
299
+ /** Register a WebSocket route. */
300
+ ws(path: string, handlers: WebSocketHandlers): void {
301
+ const fullPath = this.currentPrefix() + path
302
+ const { regex, paramNames } = parsePattern(fullPath)
303
+ const group = this.currentGroup()
304
+
305
+ this.wsRoutes.push({
306
+ pattern: fullPath,
307
+ regex,
308
+ paramNames,
309
+ handlers,
310
+ subdomain: group?.subdomain,
311
+ subdomainParamName: group?.subdomainParamName,
312
+ })
313
+ }
314
+
315
+ // ---- Groups & subdomains -------------------------------------------------
316
+
317
+ /**
318
+ * Define a route group with shared prefix, middleware, or subdomain.
319
+ *
320
+ * @example
321
+ * router.group({ prefix: '/api', middleware: [auth] }, (r) => {
322
+ * r.get('/users', listUsers)
323
+ * })
324
+ */
325
+ group(options: GroupOptions, callback: (router: Router) => void): void {
326
+ const parent = this.currentGroup()
327
+ const prefix = (parent?.prefix ?? '') + (options.prefix ?? '')
328
+ const middleware = [...(parent?.middleware ?? []), ...(options.middleware ?? [])]
329
+
330
+ let subdomain = parent?.subdomain
331
+ let subdomainParamName = parent?.subdomainParamName
332
+
333
+ if (options.subdomain) {
334
+ const parsed = parseSubdomain(options.subdomain)
335
+ subdomain = parsed.value
336
+ subdomainParamName = parsed.paramName
337
+ }
338
+
339
+ this.groupStack.push({ prefix, middleware, subdomain, subdomainParamName })
340
+ callback(this)
341
+ this.groupStack.pop()
342
+ }
343
+
344
+ /**
345
+ * Define a subdomain-scoped group.
346
+ *
347
+ * @example
348
+ * router.subdomain('api', (r) => {
349
+ * r.get('/data', apiData) // api.example.com/data
350
+ * })
351
+ *
352
+ * router.subdomain(':tenant', (r) => {
353
+ * r.get('/home', home) // acme.example.com/home
354
+ * // ctx.params.tenant === 'acme'
355
+ * })
356
+ */
357
+ subdomain(pattern: string, callback: (router: Router) => void): void {
358
+ this.group({ subdomain: pattern }, callback)
359
+ }
360
+
361
+ // ---- Dispatch ------------------------------------------------------------
362
+
363
+ /**
364
+ * Match the incoming request and run the middleware pipeline + handler.
365
+ * Returns `undefined` when a WebSocket upgrade succeeds.
366
+ */
367
+ handle(
368
+ request: Request,
369
+ server?: { upgrade(req: Request, opts?: unknown): boolean }
370
+ ): Response | Promise<Response> | undefined {
371
+ const url = new URL(request.url)
372
+ const path = url.pathname
373
+ const method = request.method
374
+ const subdomain = this.extractSubdomain(request)
375
+
376
+ // WebSocket routes (checked first)
377
+ for (const wsRoute of this.wsRoutes) {
378
+ if (!this.matchSubdomain(wsRoute, subdomain)) continue
379
+ const match = wsRoute.regex.exec(path)
380
+ if (!match) continue
381
+
382
+ const params = this.extractParams(wsRoute.paramNames, match)
383
+ if (wsRoute.subdomainParamName) params[wsRoute.subdomainParamName] = subdomain
384
+
385
+ if (server?.upgrade(request, { data: { handlers: wsRoute.handlers, params, request } })) {
386
+ return undefined
387
+ }
388
+ }
389
+
390
+ // CORS preflight — auto-respond to OPTIONS when no explicit route handles it
391
+ if (method === 'OPTIONS' && this.corsConfig) {
392
+ const hasExplicit = this.routes.some(
393
+ r => r.method === 'OPTIONS' && this.matchSubdomain(r, subdomain) && r.regex.test(path)
394
+ )
395
+
396
+ if (!hasExplicit) {
397
+ const hasRoute = this.routes.some(
398
+ r => this.matchSubdomain(r, subdomain) && r.regex.test(path)
399
+ )
400
+
401
+ if (hasRoute) {
402
+ return preflightResponse(
403
+ this.corsConfig,
404
+ request.headers.get('origin'),
405
+ request.headers.get('access-control-request-headers')
406
+ )
407
+ }
408
+ }
409
+ }
410
+
411
+ // HTTP routes
412
+ for (const route of this.routes) {
413
+ if (route.method !== method) continue
414
+ if (!this.matchSubdomain(route, subdomain)) continue
415
+
416
+ const match = route.regex.exec(path)
417
+ if (!match) continue
418
+
419
+ const params = this.extractParams(route.paramNames, match)
420
+ if (route.subdomainParamName) params[route.subdomainParamName] = subdomain
421
+
422
+ const ctx = new Context(request, params, this.domain)
423
+ const allMiddleware = [...this.globalMiddleware, ...route.middleware]
424
+
425
+ let result: Response | Promise<Response>
426
+ try {
427
+ if (allMiddleware.length === 0) {
428
+ result = route.handler(ctx)
429
+ } else {
430
+ result = compose(allMiddleware, route.handler)(ctx)
431
+ }
432
+
433
+ if (result instanceof Promise) {
434
+ result = result.catch(err => this.handleError(err, ctx))
435
+ }
436
+ } catch (err) {
437
+ result = this.handleError(err, ctx)
438
+ }
439
+
440
+ if (this.corsConfig) {
441
+ const corsConfig = this.corsConfig
442
+ const requestOrigin = request.headers.get('origin')
443
+ if (result instanceof Promise) {
444
+ return result.then(res => withCorsHeaders(res, corsConfig, requestOrigin))
445
+ }
446
+ return withCorsHeaders(result, corsConfig, requestOrigin)
447
+ }
448
+
449
+ return result
450
+ }
451
+
452
+ return new Response('Not Found', { status: 404 })
453
+ }
454
+
455
+ /**
456
+ * Returns a generic WebSocket handler object for Bun.serve().
457
+ * Dispatches events to the route-specific handlers stored in `ws.data`.
458
+ */
459
+ websocketHandler(): {
460
+ open: (ws: ServerWebSocket<WebSocketData>) => void
461
+ message: (ws: ServerWebSocket<WebSocketData>, data: string | Buffer) => void
462
+ close: (ws: ServerWebSocket<WebSocketData>) => void
463
+ drain: (ws: ServerWebSocket<WebSocketData>) => void
464
+ } {
465
+ return {
466
+ open(ws) {
467
+ ws.data?.handlers?.open?.(ws)
468
+ },
469
+ message(ws, message) {
470
+ ws.data?.handlers?.message?.(ws, message)
471
+ },
472
+ close(ws) {
473
+ ws.data?.handlers?.close?.(ws)
474
+ },
475
+ drain(ws) {
476
+ ws.data?.handlers?.drain?.(ws)
477
+ },
478
+ }
479
+ }
480
+
481
+ // ---- Error handling ------------------------------------------------------
482
+
483
+ private handleError(err: unknown, ctx: Context): Response {
484
+ if (this.exceptionHandler) return this.exceptionHandler.handle(err, ctx)
485
+ console.error('Unhandled error:', err)
486
+ return new Response('Internal Server Error', { status: 500 })
487
+ }
488
+
489
+ // ---- Internal helpers ----------------------------------------------------
490
+
491
+ private currentGroup(): GroupState | undefined {
492
+ return this.groupStack[this.groupStack.length - 1]
493
+ }
494
+
495
+ private currentPrefix(): string {
496
+ return this.currentGroup()?.prefix ?? ''
497
+ }
498
+
499
+ /** Resolve a `[Controller, 'method']` tuple into a Handler. */
500
+ private toHandler(input: HandlerInput): Handler {
501
+ if (Array.isArray(input)) {
502
+ const [Ctor, method] = input
503
+ const instance = app.has(Ctor) ? app.resolve(Ctor) : app.make(Ctor)
504
+ return ctx => instance[method](ctx)
505
+ }
506
+ return input
507
+ }
508
+
509
+ private addRoute(method: string, path: string, handler: HandlerInput): RouteRef {
510
+ const fullPath = this.currentPrefix() + path
511
+ const { regex, paramNames } = parsePattern(fullPath)
512
+ const group = this.currentGroup()
513
+
514
+ const route: RouteDefinition = {
515
+ method,
516
+ pattern: fullPath,
517
+ regex,
518
+ paramNames,
519
+ handler: this.toHandler(handler),
520
+ middleware: group?.middleware ? [...group.middleware] : [],
521
+ subdomain: group?.subdomain,
522
+ subdomainParamName: group?.subdomainParamName,
523
+ }
524
+
525
+ this.routes.push(route)
526
+ return new RouteRef(route)
527
+ }
528
+
529
+ private extractSubdomain(request: Request): string {
530
+ const host = request.headers.get('host') ?? ''
531
+ const hostname = host.split(':')[0] ?? ''
532
+
533
+ if (hostname.endsWith(this.domain) && hostname.length > this.domain.length) {
534
+ return hostname.slice(0, -(this.domain.length + 1))
535
+ }
536
+
537
+ return ''
538
+ }
539
+
540
+ private matchSubdomain(
541
+ route: { subdomain?: string; subdomainParamName?: string },
542
+ subdomain: string
543
+ ): boolean {
544
+ if (!route.subdomain) return true
545
+ if (route.subdomainParamName) return subdomain.length > 0
546
+ return route.subdomain === subdomain
547
+ }
548
+
549
+ private extractParams(names: string[], match: RegExpExecArray): Record<string, string> {
550
+ const params: Record<string, string> = {}
551
+ for (let i = 0; i < names.length; i++) {
552
+ params[names[i]!] = match[i + 1]!
553
+ }
554
+ return params
555
+ }
556
+ }
@@ -0,0 +1,79 @@
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { inject } from '../core/inject.ts'
4
+ import Configuration from '../config/configuration.ts'
5
+ import type Router from './router.ts'
6
+ import type { WebSocketData } from './router.ts'
7
+
8
+ @inject
9
+ export default class Server {
10
+ private instance: ReturnType<typeof Bun.serve> | null = null
11
+
12
+ constructor(private config: Configuration) {}
13
+
14
+ /** Start listening with the given router. */
15
+ start(router: Router): void {
16
+ const port = this.config.get('http.port', 3000) as number
17
+ const hostname = this.config.get('http.host', '0.0.0.0') as string
18
+ const domain = this.config.get('http.domain', 'localhost') as string
19
+ const publicDir = this.config.get('http.public') as string | undefined
20
+
21
+ router.setDomain(domain)
22
+
23
+ const staticRoutes = publicDir ? this.scanPublicDir(publicDir) : undefined
24
+
25
+ this.instance = Bun.serve<WebSocketData>({
26
+ port,
27
+ hostname,
28
+ ...(staticRoutes ? { static: staticRoutes } : {}),
29
+ fetch: (request: Request, server: import('bun').Server<WebSocketData>) => {
30
+ return router.handle(request, server) as Response | Promise<Response>
31
+ },
32
+ websocket: router.websocketHandler(),
33
+ error(error: Error) {
34
+ console.error('Unhandled server error:', error)
35
+ return new Response('Internal Server Error', { status: 500 })
36
+ },
37
+ } as any)
38
+
39
+ console.log(`Server listening on ${hostname}:${port}`)
40
+ }
41
+
42
+ /** Gracefully stop the server. */
43
+ stop(): void {
44
+ this.instance?.stop()
45
+ this.instance = null
46
+ }
47
+
48
+ /**
49
+ * Recursively scan a public directory and build a static route map.
50
+ * Maps URL paths to pre-built Response objects backed by Bun.file().
51
+ */
52
+ private scanPublicDir(dir: string): Record<string, Response> {
53
+ const routes: Record<string, Response> = {}
54
+
55
+ const walk = (currentDir: string): void => {
56
+ let entries: string[]
57
+ try {
58
+ entries = readdirSync(currentDir)
59
+ } catch {
60
+ return
61
+ }
62
+
63
+ for (const entry of entries) {
64
+ const fullPath = join(currentDir, entry)
65
+ const stat = statSync(fullPath)
66
+
67
+ if (stat.isDirectory()) {
68
+ walk(fullPath)
69
+ } else if (stat.isFile()) {
70
+ const urlPath = '/' + relative(dir, fullPath)
71
+ routes[urlPath] = new Response(Bun.file(fullPath))
72
+ }
73
+ }
74
+ }
75
+
76
+ walk(dir)
77
+ return routes
78
+ }
79
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "required": "This field is required",
3
+ "string": "Must be a string",
4
+ "integer": "Must be an integer",
5
+ "number": "Must be a number",
6
+ "boolean": "Must be a boolean",
7
+ "min": {
8
+ "number": "Must be at least :min",
9
+ "string": "Must be at least :min characters"
10
+ },
11
+ "max": {
12
+ "number": "Must be at most :max",
13
+ "string": "Must be at most :max characters"
14
+ },
15
+ "email": "Must be a valid email address",
16
+ "url": "Must be a valid URL",
17
+ "regex": "Invalid format",
18
+ "enum": "Must be one of: :values",
19
+ "array": "Must be an array"
20
+ }