@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,280 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HttpMethod,
|
|
3
|
+
RouteAction,
|
|
4
|
+
RouteDefinition,
|
|
5
|
+
RouteGroupOptions,
|
|
6
|
+
RouteMatch,
|
|
7
|
+
Router as RouterContract,
|
|
8
|
+
RouterRoute,
|
|
9
|
+
} from '../contracts/Router.ts'
|
|
10
|
+
import type { Constructor } from '../contracts/Container.ts'
|
|
11
|
+
import type { MantiqRequest } from '../contracts/Request.ts'
|
|
12
|
+
import type { EventDispatcher } from '../contracts/EventDispatcher.ts'
|
|
13
|
+
import { Route } from './Route.ts'
|
|
14
|
+
import { RouteCollection } from './RouteCollection.ts'
|
|
15
|
+
import { RouteMatcher } from './RouteMatcher.ts'
|
|
16
|
+
import { ResourceRegistrar } from './ResourceRegistrar.ts'
|
|
17
|
+
import { NotFoundError } from '../errors/NotFoundError.ts'
|
|
18
|
+
import { HttpError } from '../errors/HttpError.ts'
|
|
19
|
+
import { MantiqError } from '../errors/MantiqError.ts'
|
|
20
|
+
import { ConfigRepository } from '../config/ConfigRepository.ts'
|
|
21
|
+
import { RouteMatched } from './events.ts'
|
|
22
|
+
|
|
23
|
+
export class RouterImpl implements RouterContract {
|
|
24
|
+
private collection = new RouteCollection()
|
|
25
|
+
private registrar = new ResourceRegistrar(this)
|
|
26
|
+
private modelBindings = new Map<string, Constructor<any>>()
|
|
27
|
+
private customBindings = new Map<string, (value: string) => Promise<any>>()
|
|
28
|
+
private controllerRegistry = new Map<string, Constructor<any>>()
|
|
29
|
+
|
|
30
|
+
/** Optional event dispatcher. Set by @mantiq/events when installed. */
|
|
31
|
+
static _dispatcher: EventDispatcher | null = null
|
|
32
|
+
|
|
33
|
+
/** Stack of active group option frames */
|
|
34
|
+
private groupStack: RouteGroupOptions[] = []
|
|
35
|
+
|
|
36
|
+
constructor(private readonly config?: ConfigRepository) {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register controller classes for string-based route actions.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* router.controllers({
|
|
43
|
+
* AuthController,
|
|
44
|
+
* HomeController,
|
|
45
|
+
* UserController,
|
|
46
|
+
* })
|
|
47
|
+
*
|
|
48
|
+
* // Then in routes:
|
|
49
|
+
* router.get('/login', 'AuthController@login')
|
|
50
|
+
* router.get('/', 'HomeController@index')
|
|
51
|
+
*/
|
|
52
|
+
controllers(map: Record<string, Constructor<any>>): void {
|
|
53
|
+
for (const [name, ctor] of Object.entries(map)) {
|
|
54
|
+
this.controllerRegistry.set(name, ctor)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── HTTP method registration ───────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
get(path: string, action: RouteAction): RouterRoute {
|
|
61
|
+
return this.addRoute(['GET'], path, action)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
post(path: string, action: RouteAction): RouterRoute {
|
|
65
|
+
return this.addRoute(['POST'], path, action)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
put(path: string, action: RouteAction): RouterRoute {
|
|
69
|
+
return this.addRoute(['PUT'], path, action)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
patch(path: string, action: RouteAction): RouterRoute {
|
|
73
|
+
return this.addRoute(['PATCH'], path, action)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
delete(path: string, action: RouteAction): RouterRoute {
|
|
77
|
+
return this.addRoute(['DELETE'], path, action)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
options(path: string, action: RouteAction): RouterRoute {
|
|
81
|
+
return this.addRoute(['OPTIONS'], path, action)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
match(methods: HttpMethod[], path: string, action: RouteAction): RouterRoute {
|
|
85
|
+
return this.addRoute(methods, path, action)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
any(path: string, action: RouteAction): RouterRoute {
|
|
89
|
+
return this.addRoute(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], path, action)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Resource routes ────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
resource(name: string, controller: Constructor<any>): void {
|
|
95
|
+
this.registrar.register(name, controller, false)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
apiResource(name: string, controller: Constructor<any>): void {
|
|
99
|
+
this.registrar.register(name, controller, true)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Groups ────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
group(options: RouteGroupOptions, callback: (router: RouterContract) => void): void {
|
|
105
|
+
this.groupStack.push(options)
|
|
106
|
+
callback(this)
|
|
107
|
+
this.groupStack.pop()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── URL generation ─────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
url(name: string, params: Record<string, any> = {}, absolute = false): string {
|
|
113
|
+
const route = this.collection.getByName(name)
|
|
114
|
+
if (!route) {
|
|
115
|
+
throw new MantiqError(`Route '${name}' not found.`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let path = route.path
|
|
119
|
+
const usedParams = new Set<string>()
|
|
120
|
+
|
|
121
|
+
// Replace :param segments
|
|
122
|
+
path = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)(\?)?/g, (_, paramName: string, optional: string) => {
|
|
123
|
+
if (params[paramName] !== undefined) {
|
|
124
|
+
usedParams.add(paramName)
|
|
125
|
+
return encodeURIComponent(String(params[paramName]))
|
|
126
|
+
}
|
|
127
|
+
if (optional) return ''
|
|
128
|
+
throw new MantiqError(
|
|
129
|
+
`Missing required parameter '${paramName}' for route '${name}'.`,
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Append remaining params as query string
|
|
134
|
+
const remaining = Object.entries(params)
|
|
135
|
+
.filter(([k]) => !usedParams.has(k))
|
|
136
|
+
if (remaining.length > 0) {
|
|
137
|
+
path += '?' + new URLSearchParams(
|
|
138
|
+
Object.fromEntries(remaining.map(([k, v]) => [k, String(v)])),
|
|
139
|
+
).toString()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (absolute) {
|
|
143
|
+
const base = this.config?.get('app.url', 'http://localhost:3000') ?? 'http://localhost:3000'
|
|
144
|
+
return `${base}${path}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return path
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Route matching ─────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
resolve(request: MantiqRequest): RouteMatch {
|
|
153
|
+
const method = request.method() as HttpMethod
|
|
154
|
+
const pathname = request.path()
|
|
155
|
+
|
|
156
|
+
// Try to match against routes for this method
|
|
157
|
+
const candidates = this.collection.getByMethod(method)
|
|
158
|
+
|
|
159
|
+
for (const route of candidates) {
|
|
160
|
+
const result = RouteMatcher.match(route, pathname)
|
|
161
|
+
if (result) {
|
|
162
|
+
RouterImpl._dispatcher?.emit(new RouteMatched(route.routeName, route.action, request))
|
|
163
|
+
return {
|
|
164
|
+
action: route.action,
|
|
165
|
+
params: result.params,
|
|
166
|
+
middleware: route.middlewareList,
|
|
167
|
+
routeName: route.routeName,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check if path exists under a different method → 405
|
|
173
|
+
const allMethods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
|
174
|
+
const allowedMethods: HttpMethod[] = []
|
|
175
|
+
|
|
176
|
+
for (const m of allMethods) {
|
|
177
|
+
if (m === method) continue
|
|
178
|
+
const others = this.collection.getByMethod(m)
|
|
179
|
+
for (const route of others) {
|
|
180
|
+
if (RouteMatcher.match(route, pathname)) {
|
|
181
|
+
allowedMethods.push(m)
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (allowedMethods.length > 0) {
|
|
188
|
+
throw new HttpError(405, 'Method Not Allowed', {
|
|
189
|
+
Allow: allowedMethods.join(', '),
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
throw new NotFoundError(`No route found for ${method} ${pathname}`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
routes(): RouteDefinition[] {
|
|
197
|
+
return this.collection.all().map((r) => ({
|
|
198
|
+
method: r.methods.length === 1 ? r.methods[0]! : r.methods,
|
|
199
|
+
path: r.path,
|
|
200
|
+
action: r.action,
|
|
201
|
+
name: r.routeName,
|
|
202
|
+
middleware: r.middlewareList,
|
|
203
|
+
wheres: r.wheres,
|
|
204
|
+
}))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Model bindings ─────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
model(param: string, model: Constructor<any>): void {
|
|
210
|
+
this.modelBindings.set(param, model)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
bind(param: string, resolver: (value: string) => Promise<any>): void {
|
|
214
|
+
this.customBindings.set(param, resolver)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Private helpers ────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
private addRoute(methods: HttpMethod[], path: string, action: RouteAction): Route {
|
|
220
|
+
const mergedPath = this.mergePath(path)
|
|
221
|
+
const resolvedAction = this.resolveAction(action)
|
|
222
|
+
const route = new Route(methods, mergedPath, resolvedAction)
|
|
223
|
+
|
|
224
|
+
// Apply group middleware
|
|
225
|
+
const groupMiddleware = this.groupStack.flatMap((g) => g.middleware ?? [])
|
|
226
|
+
if (groupMiddleware.length > 0) route.middleware(...groupMiddleware)
|
|
227
|
+
|
|
228
|
+
// Always wrap name() so the collection's name index is updated when .name() is called
|
|
229
|
+
// after add() (which is the normal usage: router.get(...).name('foo'))
|
|
230
|
+
const namePrefix = this.groupStack.map((g) => g.as ?? '').join('')
|
|
231
|
+
const originalName = route.name.bind(route)
|
|
232
|
+
route.name = (n: string) => {
|
|
233
|
+
originalName(namePrefix + n)
|
|
234
|
+
this.collection.indexName(route)
|
|
235
|
+
return route
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.collection.add(route)
|
|
239
|
+
return route
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Resolve a string action like 'AuthController@login' to [Constructor, method].
|
|
244
|
+
*/
|
|
245
|
+
private resolveAction(action: RouteAction): Exclude<RouteAction, string> {
|
|
246
|
+
if (typeof action !== 'string') return action
|
|
247
|
+
|
|
248
|
+
const [controllerName, method] = action.split('@')
|
|
249
|
+
if (!controllerName || !method) {
|
|
250
|
+
throw new MantiqError(
|
|
251
|
+
`Invalid route action string '${action}'. Expected format: 'ControllerName@method'.`,
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Check namespace prefix from group stack
|
|
256
|
+
const namespace = this.groupStack
|
|
257
|
+
.map((g) => g.namespace ?? '')
|
|
258
|
+
.filter(Boolean)
|
|
259
|
+
.join('/')
|
|
260
|
+
|
|
261
|
+
const fullName = namespace ? `${namespace}/${controllerName}` : controllerName
|
|
262
|
+
const Controller = this.controllerRegistry.get(fullName)
|
|
263
|
+
?? this.controllerRegistry.get(controllerName)
|
|
264
|
+
|
|
265
|
+
if (!Controller) {
|
|
266
|
+
throw new MantiqError(
|
|
267
|
+
`Controller '${controllerName}' not found. Register it with router.controllers({ ${controllerName} }).`,
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return [Controller, method]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private mergePath(path: string): string {
|
|
275
|
+
const prefixes = this.groupStack.map((g) => g.prefix ?? '').filter(Boolean)
|
|
276
|
+
if (prefixes.length === 0) return path
|
|
277
|
+
const prefix = prefixes.join('')
|
|
278
|
+
return prefix + (path.startsWith('/') ? path : `/${path}`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Event } from '../contracts/EventDispatcher.ts'
|
|
2
|
+
import type { RouteAction } from '../contracts/Router.ts'
|
|
3
|
+
import type { MantiqRequest } from '../contracts/Request.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fired when the router successfully matches a request to a route.
|
|
7
|
+
*/
|
|
8
|
+
export class RouteMatched extends Event {
|
|
9
|
+
constructor(
|
|
10
|
+
/** The matched route name (if named). */
|
|
11
|
+
public readonly routeName: string | undefined,
|
|
12
|
+
/** The matched route action. */
|
|
13
|
+
public readonly action: RouteAction,
|
|
14
|
+
/** The request that was matched. */
|
|
15
|
+
public readonly request: MantiqRequest,
|
|
16
|
+
) {
|
|
17
|
+
super()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SessionHandler, SessionConfig } from '../contracts/Session.ts'
|
|
2
|
+
import type { DriverManager } from '../contracts/DriverManager.ts'
|
|
3
|
+
import { MemorySessionHandler } from './handlers/MemorySessionHandler.ts'
|
|
4
|
+
import { FileSessionHandler } from './handlers/FileSessionHandler.ts'
|
|
5
|
+
import { CookieSessionHandler } from './handlers/CookieSessionHandler.ts'
|
|
6
|
+
|
|
7
|
+
const SESSION_DEFAULTS: SessionConfig = {
|
|
8
|
+
driver: 'memory',
|
|
9
|
+
lifetime: 120,
|
|
10
|
+
cookie: 'mantiq_session',
|
|
11
|
+
path: '/',
|
|
12
|
+
secure: false,
|
|
13
|
+
httpOnly: true,
|
|
14
|
+
sameSite: 'Lax',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Multi-driver session manager (Laravel-style).
|
|
19
|
+
*
|
|
20
|
+
* Built-in drivers: memory, file, cookie.
|
|
21
|
+
* Custom drivers via `extend()`.
|
|
22
|
+
*/
|
|
23
|
+
export class SessionManager implements DriverManager<SessionHandler> {
|
|
24
|
+
private readonly config: SessionConfig
|
|
25
|
+
private readonly drivers = new Map<string, SessionHandler>()
|
|
26
|
+
private readonly customCreators = new Map<string, () => SessionHandler>()
|
|
27
|
+
|
|
28
|
+
constructor(config?: Partial<SessionConfig>) {
|
|
29
|
+
this.config = { ...SESSION_DEFAULTS, ...config }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── DriverManager ───────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
driver(name?: string): SessionHandler {
|
|
35
|
+
const driverName = name ?? this.getDefaultDriver()
|
|
36
|
+
|
|
37
|
+
if (!this.drivers.has(driverName)) {
|
|
38
|
+
this.drivers.set(driverName, this.createDriver(driverName))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return this.drivers.get(driverName)!
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
extend(name: string, factory: () => SessionHandler): void {
|
|
45
|
+
this.customCreators.set(name, factory)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getDefaultDriver(): string {
|
|
49
|
+
return this.config.driver
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Config access ───────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
getConfig(): Readonly<SessionConfig> {
|
|
55
|
+
return this.config
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Internal ────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
private createDriver(name: string): SessionHandler {
|
|
61
|
+
const custom = this.customCreators.get(name)
|
|
62
|
+
if (custom) return custom()
|
|
63
|
+
|
|
64
|
+
switch (name) {
|
|
65
|
+
case 'memory':
|
|
66
|
+
return new MemorySessionHandler()
|
|
67
|
+
case 'file':
|
|
68
|
+
return new FileSessionHandler(this.config.files ?? '/tmp/mantiq-sessions')
|
|
69
|
+
case 'cookie':
|
|
70
|
+
return new CookieSessionHandler()
|
|
71
|
+
default:
|
|
72
|
+
throw new Error(`Unsupported session driver: ${name}. Use extend() to register custom drivers.`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { SessionHandler } from '../contracts/Session.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session store — holds key/value data for one session.
|
|
5
|
+
* Reads from and writes to a SessionHandler (driver).
|
|
6
|
+
*/
|
|
7
|
+
export class SessionStore {
|
|
8
|
+
private id: string
|
|
9
|
+
private attributes: Record<string, unknown> = {}
|
|
10
|
+
private started = false
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly name: string,
|
|
14
|
+
private readonly handler: SessionHandler,
|
|
15
|
+
id?: string,
|
|
16
|
+
) {
|
|
17
|
+
this.id = id ?? SessionStore.generateId()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Start the session — loads data from the handler.
|
|
22
|
+
*/
|
|
23
|
+
async start(): Promise<boolean> {
|
|
24
|
+
const data = await this.handler.read(this.id)
|
|
25
|
+
|
|
26
|
+
if (data) {
|
|
27
|
+
try {
|
|
28
|
+
this.attributes = JSON.parse(data)
|
|
29
|
+
} catch {
|
|
30
|
+
this.attributes = {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.started = true
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Save the session — writes data to the handler.
|
|
40
|
+
*/
|
|
41
|
+
async save(): Promise<void> {
|
|
42
|
+
await this.handler.write(this.id, JSON.stringify(this.attributes))
|
|
43
|
+
this.started = false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Getters & setters ───────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
get<T = unknown>(key: string, defaultValue?: T): T {
|
|
49
|
+
return (this.attributes[key] as T) ?? (defaultValue as T)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
put(key: string, value: unknown): void {
|
|
53
|
+
this.attributes[key] = value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
has(key: string): boolean {
|
|
57
|
+
return key in this.attributes
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
forget(key: string): void {
|
|
61
|
+
delete this.attributes[key]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pull<T = unknown>(key: string, defaultValue?: T): T {
|
|
65
|
+
const value = this.get<T>(key, defaultValue)
|
|
66
|
+
this.forget(key)
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
all(): Record<string, unknown> {
|
|
71
|
+
return { ...this.attributes }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
replace(attributes: Record<string, unknown>): void {
|
|
75
|
+
this.attributes = { ...this.attributes, ...attributes }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
flush(): void {
|
|
79
|
+
this.attributes = {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Flash data ──────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
flash(key: string, value: unknown): void {
|
|
85
|
+
this.put(key, value)
|
|
86
|
+
const newFlash = this.get<string[]>('_flash.new', [])
|
|
87
|
+
if (!newFlash.includes(key)) newFlash.push(key)
|
|
88
|
+
this.put('_flash.new', newFlash)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
reflash(): void {
|
|
92
|
+
const old = this.get<string[]>('_flash.old', [])
|
|
93
|
+
const newFlash = this.get<string[]>('_flash.new', [])
|
|
94
|
+
this.put('_flash.new', [...new Set([...newFlash, ...old])])
|
|
95
|
+
this.put('_flash.old', [])
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
keep(...keys: string[]): void {
|
|
99
|
+
const old = this.get<string[]>('_flash.old', [])
|
|
100
|
+
const newFlash = this.get<string[]>('_flash.new', [])
|
|
101
|
+
const toKeep = keys.length > 0 ? keys : old
|
|
102
|
+
this.put('_flash.new', [...new Set([...newFlash, ...toKeep])])
|
|
103
|
+
this.put('_flash.old', old.filter((k) => !toKeep.includes(k)))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Age the flash data — called at the end of each request.
|
|
108
|
+
* Moves "new" flash keys to "old", and removes previously old keys.
|
|
109
|
+
*/
|
|
110
|
+
ageFlashData(): void {
|
|
111
|
+
const old = this.get<string[]>('_flash.old', [])
|
|
112
|
+
for (const key of old) {
|
|
113
|
+
this.forget(key)
|
|
114
|
+
}
|
|
115
|
+
this.put('_flash.old', this.get<string[]>('_flash.new', []))
|
|
116
|
+
this.put('_flash.new', [])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── CSRF Token ──────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
token(): string {
|
|
122
|
+
if (!this.has('_token')) {
|
|
123
|
+
this.regenerateToken()
|
|
124
|
+
}
|
|
125
|
+
return this.get<string>('_token')!
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
regenerateToken(): void {
|
|
129
|
+
const bytes = new Uint8Array(40)
|
|
130
|
+
crypto.getRandomValues(bytes)
|
|
131
|
+
let token = ''
|
|
132
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
133
|
+
token += bytes[i]!.toString(16).padStart(2, '0')
|
|
134
|
+
}
|
|
135
|
+
this.put('_token', token)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Session ID management ──────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
getId(): string {
|
|
141
|
+
return this.id
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setId(id: string): void {
|
|
145
|
+
this.id = id
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getName(): string {
|
|
149
|
+
return this.name
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
isStarted(): boolean {
|
|
153
|
+
return this.started
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Regenerate the session ID (e.g. after login to prevent fixation).
|
|
158
|
+
*/
|
|
159
|
+
async regenerate(destroy = false): Promise<void> {
|
|
160
|
+
if (destroy) {
|
|
161
|
+
await this.handler.destroy(this.id)
|
|
162
|
+
}
|
|
163
|
+
this.id = SessionStore.generateId()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Invalidate + regenerate: flush all data AND get a new ID.
|
|
168
|
+
*/
|
|
169
|
+
async invalidate(): Promise<void> {
|
|
170
|
+
this.flush()
|
|
171
|
+
await this.regenerate(true)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Return the serialized session data (for cookie driver).
|
|
176
|
+
*/
|
|
177
|
+
serialize(): string {
|
|
178
|
+
return JSON.stringify(this.attributes)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Static helpers ──────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
static generateId(): string {
|
|
184
|
+
const bytes = new Uint8Array(20)
|
|
185
|
+
crypto.getRandomValues(bytes)
|
|
186
|
+
let id = ''
|
|
187
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
188
|
+
id += bytes[i]!.toString(16).padStart(2, '0')
|
|
189
|
+
}
|
|
190
|
+
return id
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SessionHandler } from '../../contracts/Session.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cookie-based session handler.
|
|
5
|
+
* Session data is stored entirely in the cookie (encrypted by the middleware).
|
|
6
|
+
* No server-side storage needed — ideal for stateless deployments.
|
|
7
|
+
*
|
|
8
|
+
* Size limit: ~4KB per cookie. Suitable for small session payloads.
|
|
9
|
+
*/
|
|
10
|
+
export class CookieSessionHandler implements SessionHandler {
|
|
11
|
+
private data = new Map<string, string>()
|
|
12
|
+
|
|
13
|
+
async read(sessionId: string): Promise<string> {
|
|
14
|
+
return this.data.get(sessionId) ?? ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async write(sessionId: string, data: string): Promise<void> {
|
|
18
|
+
this.data.set(sessionId, data)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async destroy(sessionId: string): Promise<void> {
|
|
22
|
+
this.data.delete(sessionId)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async gc(_maxLifetimeSeconds: number): Promise<void> {
|
|
26
|
+
// No-op — cookie expiration is handled by the browser
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the raw data for the session (used by StartSession to write into cookie).
|
|
31
|
+
*/
|
|
32
|
+
getDataForCookie(sessionId: string): string {
|
|
33
|
+
return this.data.get(sessionId) ?? ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Seed session data from cookie value (called before session.start()).
|
|
38
|
+
*/
|
|
39
|
+
setDataFromCookie(sessionId: string, data: string): void {
|
|
40
|
+
this.data.set(sessionId, data)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { SessionHandler } from '../../contracts/Session.ts'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File-based session handler. Each session is a JSON file.
|
|
7
|
+
* Survives process restarts. Good for single-server deployments.
|
|
8
|
+
*/
|
|
9
|
+
export class FileSessionHandler implements SessionHandler {
|
|
10
|
+
private readonly directory: string
|
|
11
|
+
private initialized = false
|
|
12
|
+
|
|
13
|
+
constructor(directory: string) {
|
|
14
|
+
this.directory = directory
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async read(sessionId: string): Promise<string> {
|
|
18
|
+
await this.ensureDirectory()
|
|
19
|
+
const file = Bun.file(this.path(sessionId))
|
|
20
|
+
|
|
21
|
+
if (!(await file.exists())) return ''
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return await file.text()
|
|
25
|
+
} catch {
|
|
26
|
+
return ''
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async write(sessionId: string, data: string): Promise<void> {
|
|
31
|
+
await this.ensureDirectory()
|
|
32
|
+
await Bun.write(this.path(sessionId), data)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async destroy(sessionId: string): Promise<void> {
|
|
36
|
+
try {
|
|
37
|
+
await rm(this.path(sessionId))
|
|
38
|
+
} catch {
|
|
39
|
+
// File might not exist — that's fine
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async gc(maxLifetimeSeconds: number): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
const files = await readdir(this.directory)
|
|
46
|
+
const cutoff = Date.now() - maxLifetimeSeconds * 1000
|
|
47
|
+
|
|
48
|
+
await Promise.all(
|
|
49
|
+
files
|
|
50
|
+
.filter((f) => f.endsWith('.session'))
|
|
51
|
+
.map(async (f) => {
|
|
52
|
+
const filePath = join(this.directory, f)
|
|
53
|
+
try {
|
|
54
|
+
const s = await stat(filePath)
|
|
55
|
+
if (s.mtimeMs < cutoff) {
|
|
56
|
+
await rm(filePath)
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Ignore stat errors
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
} catch {
|
|
64
|
+
// Directory might not exist
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private path(sessionId: string): string {
|
|
69
|
+
// Validate session ID to prevent directory traversal
|
|
70
|
+
const safe = sessionId.replace(/[^a-f0-9]/g, '')
|
|
71
|
+
return join(this.directory, `${safe}.session`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async ensureDirectory(): Promise<void> {
|
|
75
|
+
if (this.initialized) return
|
|
76
|
+
await mkdir(this.directory, { recursive: true })
|
|
77
|
+
this.initialized = true
|
|
78
|
+
}
|
|
79
|
+
}
|