@stravigor/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. package/tsconfig.json +4 -0
@@ -0,0 +1,308 @@
1
+ import type Context from '../http/context.ts'
2
+ import { clearCookie } from '../http/cookie.ts'
3
+ import { randomHex } from '../helpers/crypto.ts'
4
+ import { extractUserId } from '../helpers/identity.ts'
5
+ import SessionManager from './session_manager.ts'
6
+
7
+ const FLASH_KEY = '_flash'
8
+ const FLASH_OLD_KEY = '_flash_old'
9
+
10
+ /**
11
+ * Unified server-side session backed by a database row and an HTTP-only cookie.
12
+ *
13
+ * Serves both anonymous visitors and authenticated users. Stores arbitrary
14
+ * key-value data in a JSONB column and supports flash data (available only
15
+ * on the next request).
16
+ *
17
+ * @example
18
+ * // Read / write data (anonymous or authenticated)
19
+ * const session = ctx.get<Session>('session')
20
+ * session.set('cart', [item])
21
+ * session.flash('success', 'Item added!')
22
+ *
23
+ * // Login
24
+ * session.authenticate(user)
25
+ * await session.regenerate()
26
+ *
27
+ * // Logout
28
+ * return Session.destroy(ctx, ctx.redirect('/login'))
29
+ */
30
+ export default class Session {
31
+ private _id: string
32
+ private _userId: string | null
33
+ private _csrfToken: string
34
+ private _data: Record<string, unknown>
35
+ private _dirty = false
36
+
37
+ constructor(
38
+ id: string,
39
+ userId: string | null,
40
+ csrfToken: string,
41
+ data: Record<string, unknown>,
42
+ readonly ipAddress: string | null,
43
+ readonly userAgent: string | null,
44
+ readonly lastActivity: Date,
45
+ readonly createdAt: Date
46
+ ) {
47
+ this._id = id
48
+ this._userId = userId
49
+ this._csrfToken = csrfToken
50
+ this._data = data
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Getters
55
+ // ---------------------------------------------------------------------------
56
+
57
+ get id(): string {
58
+ return this._id
59
+ }
60
+
61
+ get userId(): string | null {
62
+ return this._userId
63
+ }
64
+
65
+ get csrfToken(): string {
66
+ return this._csrfToken
67
+ }
68
+
69
+ get isAuthenticated(): boolean {
70
+ return this._userId !== null
71
+ }
72
+
73
+ get isDirty(): boolean {
74
+ return this._dirty
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Data bag
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /** Get a value from the session data. */
82
+ get<T = unknown>(key: string, defaultValue?: T): T {
83
+ return (this._data[key] as T) ?? (defaultValue as T)
84
+ }
85
+
86
+ /** Set a persistent session value. */
87
+ set(key: string, value: unknown): void {
88
+ this._data[key] = value
89
+ this._dirty = true
90
+ }
91
+
92
+ /** Check whether a key exists in the session data. */
93
+ has(key: string): boolean {
94
+ return key in this._data && !key.startsWith('_flash')
95
+ }
96
+
97
+ /** Remove a key from the session data. */
98
+ forget(key: string): void {
99
+ delete this._data[key]
100
+ this._dirty = true
101
+ }
102
+
103
+ /** Remove all session data (keeps the session row alive). */
104
+ flush(): void {
105
+ this._data = {}
106
+ this._dirty = true
107
+ }
108
+
109
+ /** Return all user-facing session data (excludes flash internals). */
110
+ all(): Record<string, unknown> {
111
+ const result: Record<string, unknown> = {}
112
+ for (const [key, value] of Object.entries(this._data)) {
113
+ if (!key.startsWith('_flash')) {
114
+ result[key] = value
115
+ }
116
+ }
117
+ return result
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Flash data
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /** Set flash data that will be available only on the next request. */
125
+ flash(key: string, value: unknown): void {
126
+ const bag = (this._data[FLASH_KEY] ?? {}) as Record<string, unknown>
127
+ bag[key] = value
128
+ this._data[FLASH_KEY] = bag
129
+ this._dirty = true
130
+ }
131
+
132
+ /** Get flash data set by the previous request. */
133
+ getFlash<T = unknown>(key: string): T | undefined {
134
+ const old = this._data[FLASH_OLD_KEY] as Record<string, unknown> | undefined
135
+ return old?.[key] as T | undefined
136
+ }
137
+
138
+ /** Check if there is flash data for the given key (from previous request). */
139
+ hasFlash(key: string): boolean {
140
+ const old = this._data[FLASH_OLD_KEY] as Record<string, unknown> | undefined
141
+ return old !== undefined && key in old
142
+ }
143
+
144
+ /**
145
+ * Rotate flash data for the next request cycle. Called once per request
146
+ * by the session middleware before the handler runs.
147
+ *
148
+ * Moves `_flash` → `_flash_old` (readable this request), then clears `_flash`.
149
+ * Only marks dirty if there was actually flash data to rotate.
150
+ */
151
+ ageFlash(): void {
152
+ const current = this._data[FLASH_KEY]
153
+ const old = this._data[FLASH_OLD_KEY]
154
+
155
+ if (current !== undefined || old !== undefined) {
156
+ this._data[FLASH_OLD_KEY] = current ?? {}
157
+ delete this._data[FLASH_KEY]
158
+ this._dirty = true
159
+ }
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Authentication
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /** Associate this session with a user (login). */
167
+ authenticate(user: unknown): void {
168
+ this._userId = extractUserId(user)
169
+ this._dirty = true
170
+ }
171
+
172
+ /** Disassociate the user from this session. */
173
+ clearUser(): void {
174
+ this._userId = null
175
+ this._dirty = true
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Lifecycle
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /** Whether this session has exceeded its configured lifetime. */
183
+ isExpired(): boolean {
184
+ const lifetimeMs = SessionManager.config.lifetime * 60_000
185
+ return Date.now() - this.lastActivity.getTime() > lifetimeMs
186
+ }
187
+
188
+ /** Update the last_activity timestamp to keep the session alive. */
189
+ async touch(): Promise<void> {
190
+ await SessionManager.db.sql`
191
+ UPDATE "_strav_sessions"
192
+ SET "last_activity" = NOW()
193
+ WHERE "id" = ${this._id}
194
+ `
195
+ }
196
+
197
+ /**
198
+ * Regenerate the session ID and CSRF token. Use after login to
199
+ * prevent session fixation attacks.
200
+ */
201
+ async regenerate(): Promise<void> {
202
+ const oldId = this._id
203
+ this._id = crypto.randomUUID()
204
+ this._csrfToken = randomHex(32)
205
+ this._dirty = true
206
+
207
+ await this.save()
208
+ await SessionManager.db.sql`
209
+ DELETE FROM "_strav_sessions" WHERE "id" = ${oldId}
210
+ `
211
+ }
212
+
213
+ /**
214
+ * Persist session data to the database. Uses an upsert so both new
215
+ * and existing sessions go through the same code path.
216
+ * No-op if the session has not been modified.
217
+ */
218
+ async save(): Promise<void> {
219
+ if (!this._dirty) return
220
+
221
+ const dataToSave = { ...this._data }
222
+ delete dataToSave[FLASH_OLD_KEY]
223
+
224
+ await SessionManager.db.sql`
225
+ INSERT INTO "_strav_sessions"
226
+ ("id", "user_id", "csrf_token", "data", "ip_address", "user_agent", "last_activity")
227
+ VALUES
228
+ (${this._id}, ${this._userId}, ${this._csrfToken},
229
+ ${JSON.stringify(dataToSave)}::jsonb, ${this.ipAddress}, ${this.userAgent}, NOW())
230
+ ON CONFLICT ("id") DO UPDATE SET
231
+ "user_id" = EXCLUDED."user_id",
232
+ "csrf_token" = EXCLUDED."csrf_token",
233
+ "data" = EXCLUDED."data",
234
+ "last_activity" = NOW()
235
+ `
236
+ this._dirty = false
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Static API
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /** Create a new anonymous session (not yet persisted — call save() or let the middleware handle it). */
244
+ static create(ctx: Context): Session {
245
+ const id = crypto.randomUUID()
246
+ const csrfToken = randomHex(32)
247
+ const ipAddress = ctx.header('x-forwarded-for') ?? null
248
+ const userAgent = ctx.header('user-agent') ?? null
249
+ const now = new Date()
250
+
251
+ const session = new Session(id, null, csrfToken, {}, ipAddress, userAgent, now, now)
252
+ session._dirty = true
253
+ return session
254
+ }
255
+
256
+ /** Look up a session by ID. Returns null if not found. */
257
+ static async find(id: string): Promise<Session | null> {
258
+ const rows = await SessionManager.db.sql`
259
+ SELECT * FROM "_strav_sessions" WHERE "id" = ${id} LIMIT 1
260
+ `
261
+ if (rows.length === 0) return null
262
+ return Session.hydrate(rows[0] as Record<string, unknown>)
263
+ }
264
+
265
+ /** Read the session cookie from the request and look up the session. */
266
+ static async fromRequest(ctx: Context): Promise<Session | null> {
267
+ const sessionId = ctx.cookie(SessionManager.config.cookie)
268
+ if (!sessionId) return null
269
+ return Session.find(sessionId)
270
+ }
271
+
272
+ /** Delete the session from the database and clear the cookie on the response. */
273
+ static async destroy(ctx: Context, response: Response): Promise<Response> {
274
+ const cfg = SessionManager.config
275
+ const sessionId = ctx.cookie(cfg.cookie)
276
+
277
+ if (sessionId) {
278
+ await SessionManager.db.sql`
279
+ DELETE FROM "_strav_sessions" WHERE "id" = ${sessionId}
280
+ `
281
+ }
282
+
283
+ return clearCookie(response, cfg.cookie, { path: '/' })
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Internal
288
+ // ---------------------------------------------------------------------------
289
+
290
+ private static hydrate(row: Record<string, unknown>): Session {
291
+ const rawData = row.data
292
+ const data: Record<string, unknown> =
293
+ typeof rawData === 'string'
294
+ ? JSON.parse(rawData)
295
+ : ((rawData as Record<string, unknown>) ?? {})
296
+
297
+ return new Session(
298
+ row.id as string,
299
+ (row.user_id as string) ?? null,
300
+ row.csrf_token as string,
301
+ data,
302
+ (row.ip_address as string) ?? null,
303
+ (row.user_agent as string) ?? null,
304
+ row.last_activity as Date,
305
+ row.created_at as Date
306
+ )
307
+ }
308
+ }
@@ -0,0 +1,81 @@
1
+ import { inject } from '../core/inject.ts'
2
+ import { ConfigurationError } from '../exceptions/errors.ts'
3
+ import Configuration from '../config/configuration.ts'
4
+ import Database from '../database/database.ts'
5
+
6
+ export interface SessionConfig {
7
+ cookie: string
8
+ lifetime: number
9
+ httpOnly: boolean
10
+ secure: boolean
11
+ sameSite: 'strict' | 'lax' | 'none'
12
+ }
13
+
14
+ /**
15
+ * Central session configuration hub.
16
+ *
17
+ * Resolved once via the DI container — stores the database reference
18
+ * and parsed config for Session and the session middleware.
19
+ *
20
+ * @example
21
+ * app.singleton(SessionManager)
22
+ * app.resolve(SessionManager)
23
+ * await SessionManager.ensureTable()
24
+ */
25
+ @inject
26
+ export default class SessionManager {
27
+ private static _db: Database
28
+ private static _config: SessionConfig
29
+
30
+ constructor(db: Database, config: Configuration) {
31
+ SessionManager._db = db
32
+ SessionManager._config = {
33
+ cookie: 'strav_session',
34
+ lifetime: 120,
35
+ httpOnly: true,
36
+ secure: true,
37
+ sameSite: 'lax',
38
+ ...(config.get('session', {}) as object),
39
+ }
40
+ }
41
+
42
+ static get db(): Database {
43
+ if (!SessionManager._db) {
44
+ throw new ConfigurationError('SessionManager not configured. Resolve it through the container first.')
45
+ }
46
+ return SessionManager._db
47
+ }
48
+
49
+ static get config(): SessionConfig {
50
+ return SessionManager._config
51
+ }
52
+
53
+ /** Create the sessions table if it does not exist. */
54
+ static async ensureTable(): Promise<void> {
55
+ await SessionManager.db.sql`
56
+ CREATE TABLE IF NOT EXISTS "_strav_sessions" (
57
+ "id" UUID PRIMARY KEY,
58
+ "user_id" VARCHAR(255),
59
+ "csrf_token" VARCHAR(64) NOT NULL,
60
+ "data" JSONB NOT NULL DEFAULT '{}',
61
+ "ip_address" VARCHAR(45),
62
+ "user_agent" TEXT,
63
+ "last_activity" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
64
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
65
+ )
66
+ `
67
+ }
68
+
69
+ /** Delete all expired sessions. Call periodically for housekeeping. */
70
+ static async gc(): Promise<number> {
71
+ const lifetimeMs = SessionManager.config.lifetime * 60_000
72
+ const cutoff = new Date(Date.now() - lifetimeMs)
73
+
74
+ const rows = await SessionManager.db.sql`
75
+ DELETE FROM "_strav_sessions"
76
+ WHERE "last_activity" < ${cutoff}
77
+ RETURNING "id"
78
+ `
79
+ return rows.length
80
+ }
81
+ }
@@ -0,0 +1,13 @@
1
+ export { default as Storage } from './storage.ts'
2
+ export { Upload, FileTooLargeError, InvalidFileTypeError } from './upload.ts'
3
+ export { default as StorageManager } from './storage_manager.ts'
4
+ export { default as LocalDriver } from './local_driver.ts'
5
+ export { default as S3Driver } from './s3_driver.ts'
6
+ export type {
7
+ StorageDriver,
8
+ StorageConfig,
9
+ FileStats,
10
+ LocalDriverConfig,
11
+ S3DriverConfig,
12
+ } from './types.ts'
13
+ export type { UploadResult } from './upload.ts'
@@ -0,0 +1,46 @@
1
+ import { join, extname, resolve } from 'node:path'
2
+ import { unlink } from 'node:fs/promises'
3
+ import { randomHex } from '../helpers/crypto.ts'
4
+ import type { StorageDriver, LocalDriverConfig } from './types.ts'
5
+
6
+ export default class LocalDriver implements StorageDriver {
7
+ private root: string
8
+ private baseUrl: string
9
+
10
+ constructor(config: LocalDriverConfig) {
11
+ this.root = resolve(config.root)
12
+ this.baseUrl = config.baseUrl
13
+ }
14
+
15
+ async put(directory: string, file: File, name?: string): Promise<string> {
16
+ const ext = extname(file.name)
17
+ const filename = name ?? `${randomHex(8)}${ext}`
18
+ const relativePath = join(directory, filename)
19
+ const fullPath = join(this.root, relativePath)
20
+
21
+ await Bun.write(fullPath, file)
22
+
23
+ return relativePath
24
+ }
25
+
26
+ async get(path: string): Promise<Blob | null> {
27
+ const file = Bun.file(join(this.root, path))
28
+ if (!(await file.exists())) return null
29
+ return file
30
+ }
31
+
32
+ async exists(path: string): Promise<boolean> {
33
+ return Bun.file(join(this.root, path)).exists()
34
+ }
35
+
36
+ async delete(path: string): Promise<void> {
37
+ const fullPath = join(this.root, path)
38
+ if (await Bun.file(fullPath).exists()) {
39
+ await unlink(fullPath)
40
+ }
41
+ }
42
+
43
+ url(path: string): string {
44
+ return `${this.baseUrl}/${path}`
45
+ }
46
+ }
@@ -0,0 +1,51 @@
1
+ import { S3Client } from 'bun'
2
+ import { extname } from 'node:path'
3
+ import { randomHex } from '../helpers/crypto.ts'
4
+ import type { StorageDriver, S3DriverConfig } from './types.ts'
5
+
6
+ export default class S3Driver implements StorageDriver {
7
+ private client: S3Client
8
+ private cdnUrl?: string
9
+
10
+ constructor(config: S3DriverConfig) {
11
+ this.cdnUrl = config.baseUrl ?? undefined
12
+
13
+ this.client = new S3Client({
14
+ accessKeyId: config.accessKeyId,
15
+ secretAccessKey: config.secretAccessKey,
16
+ region: config.region,
17
+ endpoint: config.endpoint ?? undefined,
18
+ bucket: config.bucket,
19
+ })
20
+ }
21
+
22
+ async put(directory: string, file: File, name?: string): Promise<string> {
23
+ const ext = extname(file.name)
24
+ const filename = name ?? `${randomHex(8)}${ext}`
25
+ const key = `${directory}/${filename}`
26
+
27
+ const s3File = this.client.file(key)
28
+ await s3File.write(file)
29
+
30
+ return key
31
+ }
32
+
33
+ async get(path: string): Promise<Blob | null> {
34
+ const s3File = this.client.file(path)
35
+ if (!(await s3File.exists())) return null
36
+ return new Blob([await s3File.arrayBuffer()])
37
+ }
38
+
39
+ async exists(path: string): Promise<boolean> {
40
+ return this.client.file(path).exists()
41
+ }
42
+
43
+ async delete(path: string): Promise<void> {
44
+ await this.client.file(path).delete()
45
+ }
46
+
47
+ url(path: string, expiresIn = 3600): string {
48
+ if (this.cdnUrl) return `${this.cdnUrl}/${path}`
49
+ return this.client.file(path).presign({ expiresIn })
50
+ }
51
+ }
@@ -0,0 +1,43 @@
1
+ import StorageManager from './storage_manager.ts'
2
+
3
+ /**
4
+ * Static file storage API.
5
+ *
6
+ * Delegates all operations to the configured driver (local or S3).
7
+ *
8
+ * @example
9
+ * const path = await Storage.put('avatars', avatarFile)
10
+ * const url = Storage.url(path)
11
+ * await Storage.delete(path)
12
+ */
13
+ export default class Storage {
14
+ /** Store a file with a random filename. */
15
+ static put(directory: string, file: File): Promise<string> {
16
+ return StorageManager.driver.put(directory, file)
17
+ }
18
+
19
+ /** Store a file with a custom filename. */
20
+ static putAs(directory: string, file: File, name: string): Promise<string> {
21
+ return StorageManager.driver.put(directory, file, name)
22
+ }
23
+
24
+ /** Retrieve file content, or null if not found. */
25
+ static get(path: string): Promise<Blob | null> {
26
+ return StorageManager.driver.get(path)
27
+ }
28
+
29
+ /** Check if a file exists. */
30
+ static exists(path: string): Promise<boolean> {
31
+ return StorageManager.driver.exists(path)
32
+ }
33
+
34
+ /** Delete a file. */
35
+ static delete(path: string): Promise<void> {
36
+ return StorageManager.driver.delete(path)
37
+ }
38
+
39
+ /** Generate a URL for the file. */
40
+ static url(path: string, expiresIn?: number): string {
41
+ return StorageManager.driver.url(path, expiresIn)
42
+ }
43
+ }
@@ -0,0 +1,59 @@
1
+ import { inject } from '../core/inject.ts'
2
+ import Configuration from '../config/configuration.ts'
3
+ import { ConfigurationError } from '../exceptions/errors.ts'
4
+ import LocalDriver from './local_driver.ts'
5
+ import S3Driver from './s3_driver.ts'
6
+ import type { StorageDriver, StorageConfig } from './types.ts'
7
+
8
+ /**
9
+ * Central storage configuration hub.
10
+ *
11
+ * Resolved once via the DI container — reads the storage config
12
+ * and initializes the appropriate driver.
13
+ *
14
+ * @example
15
+ * app.singleton(StorageManager)
16
+ * app.resolve(StorageManager)
17
+ */
18
+ @inject
19
+ export default class StorageManager {
20
+ private static _driver: StorageDriver
21
+ private static _config: StorageConfig
22
+
23
+ constructor(config: Configuration) {
24
+ const driverName = config.get('storage.default', 'local') as string
25
+
26
+ StorageManager._config = {
27
+ default: driverName as 'local' | 's3',
28
+ local: {
29
+ root: 'storage',
30
+ baseUrl: '/storage',
31
+ ...(config.get('storage.local', {}) as object),
32
+ },
33
+ s3: {
34
+ bucket: '',
35
+ region: 'us-east-1',
36
+ accessKeyId: '',
37
+ secretAccessKey: '',
38
+ ...(config.get('storage.s3', {}) as object),
39
+ },
40
+ }
41
+
42
+ if (driverName === 's3') {
43
+ StorageManager._driver = new S3Driver(StorageManager._config.s3)
44
+ } else {
45
+ StorageManager._driver = new LocalDriver(StorageManager._config.local)
46
+ }
47
+ }
48
+
49
+ static get driver(): StorageDriver {
50
+ if (!StorageManager._driver) {
51
+ throw new ConfigurationError('StorageManager not configured. Resolve it through the container first.')
52
+ }
53
+ return StorageManager._driver
54
+ }
55
+
56
+ static get config(): StorageConfig {
57
+ return StorageManager._config
58
+ }
59
+ }
@@ -0,0 +1,42 @@
1
+ export interface StorageDriver {
2
+ /** Store a file and return its relative path/key. */
3
+ put(directory: string, file: File, name?: string): Promise<string>
4
+
5
+ /** Retrieve file content, or null if not found. */
6
+ get(path: string): Promise<Blob | null>
7
+
8
+ /** Check if a file exists. */
9
+ exists(path: string): Promise<boolean>
10
+
11
+ /** Delete a file. */
12
+ delete(path: string): Promise<void>
13
+
14
+ /** Generate a URL for the file. */
15
+ url(path: string, expiresIn?: number): string
16
+ }
17
+
18
+ export interface FileStats {
19
+ size: number
20
+ lastModified: Date
21
+ contentType?: string
22
+ }
23
+
24
+ export interface LocalDriverConfig {
25
+ root: string
26
+ baseUrl: string
27
+ }
28
+
29
+ export interface S3DriverConfig {
30
+ bucket: string
31
+ region: string
32
+ endpoint?: string | null
33
+ accessKeyId: string
34
+ secretAccessKey: string
35
+ baseUrl?: string | null
36
+ }
37
+
38
+ export interface StorageConfig {
39
+ default: 'local' | 's3'
40
+ local: LocalDriverConfig
41
+ s3: S3DriverConfig
42
+ }