@mantiq/core 0.0.1

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