@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,130 @@
|
|
|
1
|
+
import type { Middleware, NextFunction } from '../contracts/Middleware.ts'
|
|
2
|
+
import type { MantiqRequest } from '../contracts/Request.ts'
|
|
3
|
+
import { TokenMismatchError } from '../errors/TokenMismatchError.ts'
|
|
4
|
+
import { AesEncrypter } from '../encryption/Encrypter.ts'
|
|
5
|
+
import { serializeCookie } from '../http/Cookie.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CSRF protection middleware.
|
|
9
|
+
*
|
|
10
|
+
* Verifies that state-changing requests (POST, PUT, PATCH, DELETE)
|
|
11
|
+
* include a valid CSRF token. The token is checked against the session.
|
|
12
|
+
*
|
|
13
|
+
* Token can be provided via:
|
|
14
|
+
* - `_token` field in the request body
|
|
15
|
+
* - `X-CSRF-TOKEN` header (plain token)
|
|
16
|
+
* - `X-XSRF-TOKEN` header (encrypted token — set from the XSRF-TOKEN cookie)
|
|
17
|
+
*
|
|
18
|
+
* Also sets the XSRF-TOKEN cookie (readable by JavaScript) on every response.
|
|
19
|
+
*/
|
|
20
|
+
export class VerifyCsrfToken implements Middleware {
|
|
21
|
+
/** URIs that should be excluded from CSRF verification. */
|
|
22
|
+
protected except: string[] = []
|
|
23
|
+
|
|
24
|
+
constructor(private readonly encrypter: AesEncrypter) {}
|
|
25
|
+
|
|
26
|
+
async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
|
|
27
|
+
if (this.shouldVerify(request) && !(await this.tokensMatch(request))) {
|
|
28
|
+
throw new TokenMismatchError()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const response = await next()
|
|
32
|
+
|
|
33
|
+
return this.addXsrfCookie(response, request)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private shouldVerify(request: MantiqRequest): boolean {
|
|
37
|
+
const method = request.method()
|
|
38
|
+
|
|
39
|
+
// Read-only methods don't need CSRF
|
|
40
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return false
|
|
41
|
+
|
|
42
|
+
// Check exclusions
|
|
43
|
+
const path = request.path()
|
|
44
|
+
return !this.except.some((pattern) => {
|
|
45
|
+
if (pattern.endsWith('*')) {
|
|
46
|
+
return path.startsWith(pattern.slice(0, -1))
|
|
47
|
+
}
|
|
48
|
+
return path === pattern
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async tokensMatch(request: MantiqRequest): Promise<boolean> {
|
|
53
|
+
if (!request.hasSession()) return false
|
|
54
|
+
|
|
55
|
+
const sessionToken = request.session().token()
|
|
56
|
+
const token = await this.getTokenFromRequest(request)
|
|
57
|
+
|
|
58
|
+
if (!token || !sessionToken) return false
|
|
59
|
+
|
|
60
|
+
return timingSafeEqual(token, sessionToken)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async getTokenFromRequest(request: MantiqRequest): Promise<string | null> {
|
|
64
|
+
// 1. Check _token in body (form submission)
|
|
65
|
+
try {
|
|
66
|
+
const bodyToken = await request.input('_token')
|
|
67
|
+
if (bodyToken) return bodyToken
|
|
68
|
+
} catch {
|
|
69
|
+
// Body parsing might fail — continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. Check X-CSRF-TOKEN header (plain token, e.g. from meta tag)
|
|
73
|
+
const csrfHeader = request.header('x-csrf-token')
|
|
74
|
+
if (csrfHeader) return csrfHeader
|
|
75
|
+
|
|
76
|
+
// 3. Check X-XSRF-TOKEN header (encrypted, from cookie)
|
|
77
|
+
const xsrfHeader = request.header('x-xsrf-token')
|
|
78
|
+
if (xsrfHeader) {
|
|
79
|
+
try {
|
|
80
|
+
return await this.encrypter.decrypt(xsrfHeader)
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Add XSRF-TOKEN cookie to response (readable by JavaScript).
|
|
91
|
+
*/
|
|
92
|
+
private addXsrfCookie(response: Response, request: MantiqRequest): Response {
|
|
93
|
+
if (!request.hasSession()) return response
|
|
94
|
+
|
|
95
|
+
const token = request.session().token()
|
|
96
|
+
const headers = new Headers(response.headers)
|
|
97
|
+
|
|
98
|
+
headers.append(
|
|
99
|
+
'Set-Cookie',
|
|
100
|
+
serializeCookie('XSRF-TOKEN', token, {
|
|
101
|
+
path: '/',
|
|
102
|
+
httpOnly: false, // Must be readable by JS
|
|
103
|
+
sameSite: 'Lax',
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return new Response(response.body, {
|
|
108
|
+
status: response.status,
|
|
109
|
+
statusText: response.statusText,
|
|
110
|
+
headers,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Constant-time string comparison to prevent timing attacks on token verification.
|
|
117
|
+
*/
|
|
118
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
119
|
+
if (a.length !== b.length) return false
|
|
120
|
+
|
|
121
|
+
const encoder = new TextEncoder()
|
|
122
|
+
const bufA = encoder.encode(a)
|
|
123
|
+
const bufB = encoder.encode(b)
|
|
124
|
+
|
|
125
|
+
let result = 0
|
|
126
|
+
for (let i = 0; i < bufA.length; i++) {
|
|
127
|
+
result |= bufA[i]! ^ bufB[i]!
|
|
128
|
+
}
|
|
129
|
+
return result === 0
|
|
130
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ServiceProvider } from '../contracts/ServiceProvider.ts'
|
|
2
|
+
import { RouterImpl } from '../routing/Router.ts'
|
|
3
|
+
import { HttpKernel } from '../http/Kernel.ts'
|
|
4
|
+
import { WebSocketKernel } from '../websocket/WebSocketKernel.ts'
|
|
5
|
+
import { DefaultExceptionHandler } from '../exceptions/Handler.ts'
|
|
6
|
+
import { ConfigRepository } from '../config/ConfigRepository.ts'
|
|
7
|
+
import { CorsMiddleware } from '../middleware/Cors.ts'
|
|
8
|
+
import { TrimStringsMiddleware } from '../middleware/TrimStrings.ts'
|
|
9
|
+
import { StartSession } from '../middleware/StartSession.ts'
|
|
10
|
+
import { EncryptCookies } from '../middleware/EncryptCookies.ts'
|
|
11
|
+
import { VerifyCsrfToken } from '../middleware/VerifyCsrfToken.ts'
|
|
12
|
+
import { ROUTER } from '../helpers/route.ts'
|
|
13
|
+
import { ENCRYPTER } from '../helpers/encrypt.ts'
|
|
14
|
+
import { AesEncrypter } from '../encryption/Encrypter.ts'
|
|
15
|
+
import { HashManager } from '../hashing/HashManager.ts'
|
|
16
|
+
import { CacheManager } from '../cache/CacheManager.ts'
|
|
17
|
+
import { SessionManager } from '../session/SessionManager.ts'
|
|
18
|
+
|
|
19
|
+
// Symbols used as abstract keys for non-class bindings
|
|
20
|
+
export const HTTP_KERNEL = Symbol('HttpKernel')
|
|
21
|
+
export const EXCEPTION_HANDLER = Symbol('ExceptionHandler')
|
|
22
|
+
export const WS_KERNEL = Symbol('WebSocketKernel')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The first provider loaded. Registers all core framework bindings.
|
|
26
|
+
*
|
|
27
|
+
* Binding order matters within register():
|
|
28
|
+
* - ConfigRepository is already bound by Application.loadConfig() before any provider runs
|
|
29
|
+
* - This provider registers Router, Kernels, ExceptionHandler
|
|
30
|
+
*/
|
|
31
|
+
export class CoreServiceProvider extends ServiceProvider {
|
|
32
|
+
override register(): void {
|
|
33
|
+
const configRepo = this.app.make(ConfigRepository)
|
|
34
|
+
|
|
35
|
+
// Router — singleton, receives config for URL generation
|
|
36
|
+
this.app.singleton(RouterImpl, (c) => {
|
|
37
|
+
const config = c.make(ConfigRepository)
|
|
38
|
+
return new RouterImpl(config)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Alias the router under its symbol so route() helper can find it
|
|
42
|
+
this.app.alias(RouterImpl, ROUTER)
|
|
43
|
+
|
|
44
|
+
// WebSocket kernel — singleton, no deps
|
|
45
|
+
this.app.singleton(WebSocketKernel, () => new WebSocketKernel())
|
|
46
|
+
|
|
47
|
+
// Exception handler — singleton
|
|
48
|
+
this.app.singleton(DefaultExceptionHandler, () => new DefaultExceptionHandler())
|
|
49
|
+
|
|
50
|
+
// ── Encryption ────────────────────────────────────────────────────────
|
|
51
|
+
// AesEncrypter is async to create (key import), so we register a placeholder.
|
|
52
|
+
// Actual initialization happens in boot(). If no APP_KEY, encryption features
|
|
53
|
+
// will throw on use rather than on startup (graceful degradation for apps
|
|
54
|
+
// that don't need encryption).
|
|
55
|
+
|
|
56
|
+
// ── Hashing ───────────────────────────────────────────────────────────
|
|
57
|
+
this.app.singleton(HashManager, () => {
|
|
58
|
+
return new HashManager(configRepo.get('hashing', {}))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// ── Cache ─────────────────────────────────────────────────────────────
|
|
62
|
+
this.app.singleton(CacheManager, () => {
|
|
63
|
+
return new CacheManager(configRepo.get('cache', {}))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ── Sessions ──────────────────────────────────────────────────────────
|
|
67
|
+
this.app.singleton(SessionManager, () => {
|
|
68
|
+
return new SessionManager(configRepo.get('session', {}))
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ── Built-in middleware ────────────────────────────────────────────────
|
|
72
|
+
this.app.singleton(CorsMiddleware, (c) => new CorsMiddleware(c.make(ConfigRepository)))
|
|
73
|
+
this.app.singleton(TrimStringsMiddleware, () => new TrimStringsMiddleware())
|
|
74
|
+
this.app.singleton(StartSession, (c) => new StartSession(c.make(SessionManager)))
|
|
75
|
+
|
|
76
|
+
// EncryptCookies and VerifyCsrfToken depend on AesEncrypter (set up in boot)
|
|
77
|
+
this.app.bind(EncryptCookies, (c) => new EncryptCookies(c.make<AesEncrypter>(ENCRYPTER)))
|
|
78
|
+
this.app.bind(VerifyCsrfToken, (c) => new VerifyCsrfToken(c.make<AesEncrypter>(ENCRYPTER)))
|
|
79
|
+
|
|
80
|
+
// HTTP kernel — singleton, depends on Router + ExceptionHandler + WsKernel
|
|
81
|
+
this.app.singleton(HttpKernel, (c) => {
|
|
82
|
+
const router = c.make(RouterImpl)
|
|
83
|
+
const exceptionHandler = c.make(DefaultExceptionHandler)
|
|
84
|
+
const wsKernel = c.make(WebSocketKernel)
|
|
85
|
+
return new HttpKernel(c, router, exceptionHandler, wsKernel)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override async boot(): Promise<void> {
|
|
90
|
+
// ── Encryption (async key import) ─────────────────────────────────────
|
|
91
|
+
const appKey = this.app.make(ConfigRepository).get('app.key', undefined) as string | undefined
|
|
92
|
+
if (appKey) {
|
|
93
|
+
const encrypter = await AesEncrypter.fromAppKey(appKey)
|
|
94
|
+
this.app.instance(ENCRYPTER, encrypter)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Constructor } from '../contracts/Container.ts'
|
|
2
|
+
import type { Router } from '../contracts/Router.ts'
|
|
3
|
+
|
|
4
|
+
const RESOURCE_METHODS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'] as const
|
|
5
|
+
const API_RESOURCE_METHODS = ['index', 'store', 'show', 'update', 'destroy'] as const
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generates RESTful route sets for a controller.
|
|
9
|
+
*
|
|
10
|
+
* resource('photos', PhotoController) generates:
|
|
11
|
+
* GET /photos → index (photos.index)
|
|
12
|
+
* GET /photos/create → create (photos.create)
|
|
13
|
+
* POST /photos → store (photos.store)
|
|
14
|
+
* GET /photos/:photo → show (photos.show)
|
|
15
|
+
* GET /photos/:photo/edit → edit (photos.edit)
|
|
16
|
+
* PUT /photos/:photo → update (photos.update)
|
|
17
|
+
* DELETE /photos/:photo → destroy (photos.destroy)
|
|
18
|
+
*
|
|
19
|
+
* apiResource omits create + edit (those are frontend pages).
|
|
20
|
+
*/
|
|
21
|
+
export class ResourceRegistrar {
|
|
22
|
+
constructor(private readonly router: Router) {}
|
|
23
|
+
|
|
24
|
+
register(name: string, controller: Constructor<any>, apiOnly = false): void {
|
|
25
|
+
const methods = apiOnly ? API_RESOURCE_METHODS : RESOURCE_METHODS
|
|
26
|
+
const param = this.singularize(name)
|
|
27
|
+
const prefix = `/${name}`
|
|
28
|
+
|
|
29
|
+
for (const method of methods) {
|
|
30
|
+
switch (method) {
|
|
31
|
+
case 'index':
|
|
32
|
+
this.router.get(prefix, [controller, 'index']).name(`${name}.index`)
|
|
33
|
+
break
|
|
34
|
+
case 'create':
|
|
35
|
+
this.router.get(`${prefix}/create`, [controller, 'create']).name(`${name}.create`)
|
|
36
|
+
break
|
|
37
|
+
case 'store':
|
|
38
|
+
this.router.post(prefix, [controller, 'store']).name(`${name}.store`)
|
|
39
|
+
break
|
|
40
|
+
case 'show':
|
|
41
|
+
this.router.get(`${prefix}/:${param}`, [controller, 'show']).name(`${name}.show`)
|
|
42
|
+
break
|
|
43
|
+
case 'edit':
|
|
44
|
+
this.router.get(`${prefix}/:${param}/edit`, [controller, 'edit']).name(`${name}.edit`)
|
|
45
|
+
break
|
|
46
|
+
case 'update':
|
|
47
|
+
this.router.match(['PUT', 'PATCH'], `${prefix}/:${param}`, [controller, 'update']).name(`${name}.update`)
|
|
48
|
+
break
|
|
49
|
+
case 'destroy':
|
|
50
|
+
this.router.delete(`${prefix}/:${param}`, [controller, 'destroy']).name(`${name}.destroy`)
|
|
51
|
+
break
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private singularize(name: string): string {
|
|
57
|
+
// Simple singularization for common cases
|
|
58
|
+
const last = name.split('/').pop() ?? name
|
|
59
|
+
if (last.endsWith('ies')) return last.slice(0, -3) + 'y'
|
|
60
|
+
if (last.endsWith('ses') || last.endsWith('xes') || last.endsWith('zes')) return last.slice(0, -2)
|
|
61
|
+
if (last.endsWith('s') && !last.endsWith('ss')) return last.slice(0, -1)
|
|
62
|
+
return last
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HttpMethod, RouteAction, RouterRoute } from '../contracts/Router.ts'
|
|
2
|
+
|
|
3
|
+
export class Route implements RouterRoute {
|
|
4
|
+
public routeName?: string
|
|
5
|
+
public middlewareList: string[] = []
|
|
6
|
+
public wheres: Record<string, RegExp> = {}
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
public readonly methods: HttpMethod[],
|
|
10
|
+
public readonly path: string,
|
|
11
|
+
public readonly action: RouteAction,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
name(name: string): this {
|
|
15
|
+
this.routeName = name
|
|
16
|
+
return this
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
middleware(...middleware: string[]): this {
|
|
20
|
+
this.middlewareList.push(...middleware)
|
|
21
|
+
return this
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
where(param: string, pattern: string | RegExp): this {
|
|
25
|
+
this.wheres[param] = typeof pattern === 'string' ? new RegExp(pattern) : pattern
|
|
26
|
+
return this
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
whereNumber(param: string): this {
|
|
30
|
+
return this.where(param, /^\d+$/)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
whereAlpha(param: string): this {
|
|
34
|
+
return this.where(param, /^[a-zA-Z]+$/)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
whereUuid(param: string): this {
|
|
38
|
+
return this.where(param, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { HttpMethod } from '../contracts/Router.ts'
|
|
2
|
+
import { Route } from './Route.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stores all registered routes, indexed by HTTP method for fast lookup.
|
|
6
|
+
*/
|
|
7
|
+
export class RouteCollection {
|
|
8
|
+
private routes = new Map<HttpMethod, Route[]>()
|
|
9
|
+
private namedRoutes = new Map<string, Route>()
|
|
10
|
+
|
|
11
|
+
add(route: Route): void {
|
|
12
|
+
const methods = Array.isArray(route.methods) ? route.methods : [route.methods]
|
|
13
|
+
for (const method of methods) {
|
|
14
|
+
if (!this.routes.has(method)) this.routes.set(method, [])
|
|
15
|
+
this.routes.get(method)!.push(route)
|
|
16
|
+
}
|
|
17
|
+
if (route.routeName) {
|
|
18
|
+
this.namedRoutes.set(route.routeName, route)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getByMethod(method: HttpMethod): Route[] {
|
|
23
|
+
return this.routes.get(method) ?? []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getByName(name: string): Route | undefined {
|
|
27
|
+
return this.namedRoutes.get(name)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Update named route index when a name is set after route registration. */
|
|
31
|
+
indexName(route: Route): void {
|
|
32
|
+
if (route.routeName) {
|
|
33
|
+
this.namedRoutes.set(route.routeName, route)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
all(): Route[] {
|
|
38
|
+
const seen = new Set<Route>()
|
|
39
|
+
const result: Route[] = []
|
|
40
|
+
for (const routes of this.routes.values()) {
|
|
41
|
+
for (const route of routes) {
|
|
42
|
+
if (!seen.has(route)) {
|
|
43
|
+
seen.add(route)
|
|
44
|
+
result.push(route)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Route } from './Route.ts'
|
|
2
|
+
|
|
3
|
+
export interface MatchResult {
|
|
4
|
+
route: Route
|
|
5
|
+
params: Record<string, any>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Matches a URL path against a route pattern.
|
|
10
|
+
*
|
|
11
|
+
* Supported syntax:
|
|
12
|
+
* - Static segments: /users/profile
|
|
13
|
+
* - Required params: /users/:id
|
|
14
|
+
* - Optional params: /posts/:slug?
|
|
15
|
+
* - Wildcard (end only): /files/*
|
|
16
|
+
*/
|
|
17
|
+
export class RouteMatcher {
|
|
18
|
+
static match(route: Route, pathname: string): MatchResult | null {
|
|
19
|
+
const params: Record<string, any> = {}
|
|
20
|
+
const routeSegments = route.path.split('/').filter(Boolean)
|
|
21
|
+
const pathSegments = pathname.split('/').filter(Boolean)
|
|
22
|
+
|
|
23
|
+
let routeIdx = 0
|
|
24
|
+
let pathIdx = 0
|
|
25
|
+
|
|
26
|
+
while (routeIdx < routeSegments.length) {
|
|
27
|
+
const seg = routeSegments[routeIdx]!
|
|
28
|
+
|
|
29
|
+
// Wildcard — matches the rest
|
|
30
|
+
if (seg === '*') {
|
|
31
|
+
params['*'] = pathSegments.slice(pathIdx).join('/')
|
|
32
|
+
return this.checkConstraints(route, params) ? { route, params } : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Optional param
|
|
36
|
+
if (seg.startsWith(':') && seg.endsWith('?')) {
|
|
37
|
+
const name = seg.slice(1, -1)
|
|
38
|
+
if (pathIdx < pathSegments.length) {
|
|
39
|
+
params[name] = pathSegments[pathIdx]
|
|
40
|
+
pathIdx++
|
|
41
|
+
} else {
|
|
42
|
+
params[name] = undefined
|
|
43
|
+
}
|
|
44
|
+
routeIdx++
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Required param
|
|
49
|
+
if (seg.startsWith(':')) {
|
|
50
|
+
const name = seg.slice(1)
|
|
51
|
+
if (pathIdx >= pathSegments.length) return null
|
|
52
|
+
params[name] = pathSegments[pathIdx]
|
|
53
|
+
pathIdx++
|
|
54
|
+
routeIdx++
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Static segment
|
|
59
|
+
if (pathIdx >= pathSegments.length || pathSegments[pathIdx] !== seg) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pathIdx++
|
|
64
|
+
routeIdx++
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// All route segments consumed — path must also be fully consumed
|
|
68
|
+
if (pathIdx !== pathSegments.length) return null
|
|
69
|
+
|
|
70
|
+
// Check constraints
|
|
71
|
+
if (!this.checkConstraints(route, params)) return null
|
|
72
|
+
|
|
73
|
+
// Coerce numeric params
|
|
74
|
+
for (const [key, regex] of Object.entries(route.wheres)) {
|
|
75
|
+
if (regex.source === '^\\d+$' && params[key] !== undefined) {
|
|
76
|
+
params[key] = Number(params[key])
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { route, params }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private static checkConstraints(route: Route, params: Record<string, any>): boolean {
|
|
84
|
+
for (const [param, pattern] of Object.entries(route.wheres)) {
|
|
85
|
+
const value = params[param]
|
|
86
|
+
if (value !== undefined && !pattern.test(String(value))) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
}
|