@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.
- package/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- 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
|
+
}
|