@stravigor/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import EncryptionManager from './encryption_manager.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Encryption helper — the primary API for all cryptographic operations.
|
|
5
|
+
*
|
|
6
|
+
* Uses the configured APP_KEY for symmetric encryption and HMAC signing.
|
|
7
|
+
* Password hashing uses argon2id via Bun.password (no key needed).
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* import { encrypt } from '@stravigor/core/encryption'
|
|
11
|
+
*
|
|
12
|
+
* // Encrypt & decrypt strings
|
|
13
|
+
* const encrypted = encrypt.encrypt('sensitive data')
|
|
14
|
+
* const decrypted = encrypt.decrypt(encrypted)
|
|
15
|
+
*
|
|
16
|
+
* // Seal & unseal objects (JSON + encryption)
|
|
17
|
+
* const token = encrypt.seal({ userId: 123 })
|
|
18
|
+
* const data = encrypt.unseal<{ userId: number }>(token)
|
|
19
|
+
*
|
|
20
|
+
* // Password hashing
|
|
21
|
+
* const hash = await encrypt.hash('password123')
|
|
22
|
+
* const valid = await encrypt.verify('password123', hash)
|
|
23
|
+
*
|
|
24
|
+
* // HMAC signing
|
|
25
|
+
* const sig = encrypt.sign('payload')
|
|
26
|
+
* const ok = encrypt.verifySignature('payload', sig)
|
|
27
|
+
*/
|
|
28
|
+
export const encrypt = {
|
|
29
|
+
/**
|
|
30
|
+
* Encrypt a string using AES-256-GCM. Returns a base64url-encoded payload.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* const encrypted = encrypt.encrypt('my secret')
|
|
34
|
+
* // Store `encrypted` in the database — it's a safe, opaque string
|
|
35
|
+
*/
|
|
36
|
+
encrypt(plaintext: string): string {
|
|
37
|
+
return EncryptionManager.encrypt(plaintext)
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decrypt a payload encrypted with `encrypt()`.
|
|
42
|
+
* Supports key rotation — tries previous keys if the current key fails.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const original = encrypt.decrypt(encrypted) // 'my secret'
|
|
46
|
+
*/
|
|
47
|
+
decrypt(payload: string): string {
|
|
48
|
+
return EncryptionManager.decrypt(payload)
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Encrypt and serialize an object. Perfect for tamper-proof cookies or tokens.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* const token = encrypt.seal({ userId: 123, role: 'admin' })
|
|
56
|
+
* // Send `token` to the client — it's encrypted and tamper-proof
|
|
57
|
+
*/
|
|
58
|
+
seal(data: unknown): string {
|
|
59
|
+
return EncryptionManager.seal(data)
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decrypt and deserialize an object sealed with `seal()`.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const data = encrypt.unseal<{ userId: number }>(token)
|
|
67
|
+
* console.log(data.userId) // 123
|
|
68
|
+
*/
|
|
69
|
+
unseal<T = unknown>(payload: string): T {
|
|
70
|
+
return EncryptionManager.unseal<T>(payload)
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Hash a password using argon2id. Returns an encoded hash string.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* const hash = await encrypt.hash(formData.password)
|
|
78
|
+
* await db.sql`UPDATE users SET password = ${hash} WHERE id = ${userId}`
|
|
79
|
+
*/
|
|
80
|
+
hash(password: string): Promise<string> {
|
|
81
|
+
return EncryptionManager.hash(password)
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Verify a password against a hash. Works with argon2id and bcrypt.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* const valid = await encrypt.verify(formData.password, user.password)
|
|
89
|
+
* if (!valid) throw new Error('Invalid credentials')
|
|
90
|
+
*/
|
|
91
|
+
verify(password: string, hash: string): Promise<boolean> {
|
|
92
|
+
return EncryptionManager.verify(password, hash)
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create an HMAC-SHA256 signature. Returns a hex string.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* const sig = encrypt.sign(`${webhookId}:${timestamp}:${body}`)
|
|
100
|
+
* response.headers.set('X-Signature', sig)
|
|
101
|
+
*/
|
|
102
|
+
sign(data: string): string {
|
|
103
|
+
return EncryptionManager.sign(data)
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verify an HMAC-SHA256 signature (timing-safe).
|
|
108
|
+
* Supports key rotation — tries previous keys if the current key fails.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* const valid = encrypt.verifySignature(body, req.headers.get('X-Signature')!)
|
|
112
|
+
* if (!valid) return new Response('Invalid signature', { status: 401 })
|
|
113
|
+
*/
|
|
114
|
+
verifySignature(data: string, signature: string): boolean {
|
|
115
|
+
return EncryptionManager.verifySignature(data, signature)
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* SHA-256 hash. Returns a hex string.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* const checksum = encrypt.sha256(fileContents)
|
|
123
|
+
*/
|
|
124
|
+
sha256(data: string): string {
|
|
125
|
+
return EncryptionManager.sha256(data)
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* SHA-512 hash. Returns a hex string.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* const hash = encrypt.sha512(data)
|
|
133
|
+
*/
|
|
134
|
+
sha512(data: string): string {
|
|
135
|
+
return EncryptionManager.sha512(data)
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generate a random hex string. Default: 32 bytes → 64 hex chars.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* const apiKey = encrypt.random() // 64-char hex (32 bytes)
|
|
143
|
+
* const shortToken = encrypt.random(16) // 32-char hex (16 bytes)
|
|
144
|
+
*/
|
|
145
|
+
random(bytes: number = 32): string {
|
|
146
|
+
return EncryptionManager.random(bytes)
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Generate raw random bytes.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* const iv = encrypt.randomBytes(12)
|
|
154
|
+
*/
|
|
155
|
+
randomBytes(bytes: number = 32): Uint8Array {
|
|
156
|
+
return EncryptionManager.randomBytes(bytes)
|
|
157
|
+
},
|
|
158
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** A function that handles an event payload. */
|
|
2
|
+
export type Listener<T = any> = (payload: T) => void | Promise<void>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory event bus — publish/subscribe with async support.
|
|
6
|
+
*
|
|
7
|
+
* All methods are static. No DI, no database, no configuration required.
|
|
8
|
+
* Listeners are awaited in parallel when an event is emitted.
|
|
9
|
+
* If any listener throws, the remaining listeners still execute
|
|
10
|
+
* and the first error is re-thrown after all complete.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* Emitter.on<{ user: User }>('user.registered', async ({ user }) => {
|
|
14
|
+
* await sendWelcomeEmail(user)
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* await Emitter.emit('user.registered', { user })
|
|
18
|
+
*/
|
|
19
|
+
export default class Emitter {
|
|
20
|
+
private static _listeners = new Map<string, Set<Listener>>()
|
|
21
|
+
private static _once = new WeakSet<Listener>()
|
|
22
|
+
|
|
23
|
+
/** Register a listener for an event. */
|
|
24
|
+
static on<T = any>(event: string, listener: Listener<T>): void {
|
|
25
|
+
let set = Emitter._listeners.get(event)
|
|
26
|
+
if (!set) {
|
|
27
|
+
set = new Set()
|
|
28
|
+
Emitter._listeners.set(event, set)
|
|
29
|
+
}
|
|
30
|
+
set.add(listener)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Register a listener that is automatically removed after its first invocation. */
|
|
34
|
+
static once<T = any>(event: string, listener: Listener<T>): void {
|
|
35
|
+
Emitter._once.add(listener)
|
|
36
|
+
Emitter.on(event, listener)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Remove a specific listener for an event. */
|
|
40
|
+
static off<T = any>(event: string, listener: Listener<T>): void {
|
|
41
|
+
const set = Emitter._listeners.get(event)
|
|
42
|
+
if (!set) return
|
|
43
|
+
set.delete(listener)
|
|
44
|
+
if (set.size === 0) Emitter._listeners.delete(event)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Remove all listeners for a specific event, or all listeners entirely. */
|
|
48
|
+
static removeAllListeners(event?: string): void {
|
|
49
|
+
if (event) {
|
|
50
|
+
Emitter._listeners.delete(event)
|
|
51
|
+
} else {
|
|
52
|
+
Emitter._listeners.clear()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Emit an event. All registered listeners are invoked in parallel.
|
|
58
|
+
*
|
|
59
|
+
* If any listener throws, the other listeners still execute.
|
|
60
|
+
* After all listeners settle, the first error is re-thrown.
|
|
61
|
+
*/
|
|
62
|
+
static async emit<T = any>(event: string, payload?: T): Promise<void> {
|
|
63
|
+
const set = Emitter._listeners.get(event)
|
|
64
|
+
if (!set || set.size === 0) return
|
|
65
|
+
|
|
66
|
+
const listeners = [...set]
|
|
67
|
+
|
|
68
|
+
// Remove one-time listeners before calling (avoids re-entrancy issues)
|
|
69
|
+
for (const fn of listeners) {
|
|
70
|
+
if (Emitter._once.has(fn)) {
|
|
71
|
+
Emitter._once.delete(fn)
|
|
72
|
+
set.delete(fn)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (set.size === 0) Emitter._listeners.delete(event)
|
|
76
|
+
|
|
77
|
+
const results = await Promise.allSettled(
|
|
78
|
+
listeners.map(fn => {
|
|
79
|
+
try {
|
|
80
|
+
return fn(payload as T)
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return Promise.reject(err)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const firstError = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
|
|
88
|
+
if (firstError) throw firstError.reason
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Return the number of listeners registered for an event. */
|
|
92
|
+
static listenerCount(event: string): number {
|
|
93
|
+
return Emitter._listeners.get(event)?.size ?? 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Clear all state. Intended for test teardown. */
|
|
97
|
+
static reset(): void {
|
|
98
|
+
Emitter._listeners.clear()
|
|
99
|
+
Emitter._once = new WeakSet()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { StravError } from './strav_error.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when a framework service is used before being configured
|
|
5
|
+
* or when an unknown driver/store/transport is requested.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* throw new ConfigurationError('CacheManager not configured. Resolve it through the container first.')
|
|
9
|
+
*/
|
|
10
|
+
export class ConfigurationError extends StravError {}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Thrown by `findOrFail` / `firstOrFail` when a model record is not found.
|
|
14
|
+
* The ExceptionHandler renders this as a 404.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* throw new ModelNotFoundError('User', id)
|
|
18
|
+
*/
|
|
19
|
+
export class ModelNotFoundError extends StravError {
|
|
20
|
+
constructor(
|
|
21
|
+
public readonly model: string,
|
|
22
|
+
public readonly id?: unknown
|
|
23
|
+
) {
|
|
24
|
+
super(
|
|
25
|
+
id !== undefined
|
|
26
|
+
? `${model} with ID ${id} not found`
|
|
27
|
+
: `${model} not found`
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Thrown on database operation failures (migrations, queries).
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* throw new DatabaseError(`Migration ${version} failed: ${cause}`)
|
|
37
|
+
*/
|
|
38
|
+
export class DatabaseError extends StravError {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Thrown on encryption/decryption/signing failures.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* throw new EncryptionError('Decryption failed: invalid payload or key.')
|
|
45
|
+
*/
|
|
46
|
+
export class EncryptionError extends StravError {}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Thrown on view template compilation or rendering failures.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* throw new TemplateError(`Unclosed expression at line ${line}`)
|
|
53
|
+
*/
|
|
54
|
+
export class TemplateError extends StravError {}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Thrown when an external service (AI provider, mail transport, webhook) returns an error.
|
|
58
|
+
* The ExceptionHandler renders this as a 502.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* throw new ExternalServiceError('Anthropic', 429, 'Rate limited')
|
|
62
|
+
*/
|
|
63
|
+
export class ExternalServiceError extends StravError {
|
|
64
|
+
constructor(
|
|
65
|
+
public readonly service: string,
|
|
66
|
+
public readonly statusCode?: number,
|
|
67
|
+
public readonly responseBody?: string
|
|
68
|
+
) {
|
|
69
|
+
super(
|
|
70
|
+
statusCode
|
|
71
|
+
? `${service} error (${statusCode}): ${responseBody ?? 'unknown'}`
|
|
72
|
+
: `${service} error: ${responseBody ?? 'unknown'}`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type Context from '../http/context.ts'
|
|
2
|
+
import { HttpException, ValidationError, RateLimitError } from './http_exception.ts'
|
|
3
|
+
import { ModelNotFoundError, ConfigurationError, ExternalServiceError } from './errors.ts'
|
|
4
|
+
|
|
5
|
+
/** Converts an error into an HTTP Response. */
|
|
6
|
+
export type RenderFn = (error: any, ctx?: Context) => Response
|
|
7
|
+
|
|
8
|
+
/** Reports an error (logging, external services, etc.). */
|
|
9
|
+
export type ReportFn = (error: Error, ctx?: Context) => void
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Converts thrown errors into HTTP responses.
|
|
13
|
+
*
|
|
14
|
+
* Register with the router to catch all unhandled exceptions:
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* import { ExceptionHandler } from '@stravigor/core/exceptions'
|
|
18
|
+
*
|
|
19
|
+
* const handler = new ExceptionHandler(config.get('app.env') === 'local')
|
|
20
|
+
* handler.report((error, ctx) => logger.error(error.message, { path: ctx?.path }))
|
|
21
|
+
* router.useExceptionHandler(handler)
|
|
22
|
+
*/
|
|
23
|
+
export class ExceptionHandler {
|
|
24
|
+
private renderers = new Map<Function, RenderFn>()
|
|
25
|
+
private reporters: ReportFn[] = []
|
|
26
|
+
|
|
27
|
+
constructor(private isDev = false) {}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register a custom renderer for a specific error class.
|
|
31
|
+
* Custom renderers take precedence over built-in rendering.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* handler.render(PaymentError, (error) => {
|
|
35
|
+
* return Response.json({ error: error.message, code: error.code }, { status: 402 })
|
|
36
|
+
* })
|
|
37
|
+
*/
|
|
38
|
+
render<T extends Error>(
|
|
39
|
+
errorClass: new (...args: any[]) => T,
|
|
40
|
+
fn: (error: T, ctx?: Context) => Response
|
|
41
|
+
): this {
|
|
42
|
+
this.renderers.set(errorClass, fn as RenderFn)
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register a reporter. Runs for every error before rendering.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* handler.report((error, ctx) => {
|
|
51
|
+
* logger.error(error.message, { path: ctx?.path, stack: error.stack })
|
|
52
|
+
* })
|
|
53
|
+
*/
|
|
54
|
+
report(fn: ReportFn): this {
|
|
55
|
+
this.reporters.push(fn)
|
|
56
|
+
return this
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Convert a thrown error into an HTTP Response. */
|
|
60
|
+
handle(error: unknown, ctx?: Context): Response {
|
|
61
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
62
|
+
|
|
63
|
+
// Run reporters
|
|
64
|
+
for (const reporter of this.reporters) {
|
|
65
|
+
try {
|
|
66
|
+
reporter(err, ctx)
|
|
67
|
+
} catch {
|
|
68
|
+
// Reporters must not throw
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check custom renderers (walk the prototype chain)
|
|
73
|
+
let proto = err.constructor
|
|
74
|
+
while (proto && proto !== Object) {
|
|
75
|
+
const renderer = this.renderers.get(proto)
|
|
76
|
+
if (renderer) return renderer(err, ctx)
|
|
77
|
+
proto = Object.getPrototypeOf(proto)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Built-in rendering
|
|
81
|
+
if (err instanceof ValidationError) {
|
|
82
|
+
return json({ error: err.message, errors: err.errors }, err.status)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (err instanceof RateLimitError) {
|
|
86
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
87
|
+
if (err.retryAfter !== undefined) {
|
|
88
|
+
headers['Retry-After'] = String(err.retryAfter)
|
|
89
|
+
}
|
|
90
|
+
return new Response(JSON.stringify({ error: err.message }), {
|
|
91
|
+
status: err.status,
|
|
92
|
+
headers,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (err instanceof HttpException) {
|
|
97
|
+
return json({ error: err.message }, err.status)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (err instanceof ModelNotFoundError) {
|
|
101
|
+
return json({ error: err.message }, 404)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (err instanceof ConfigurationError) {
|
|
105
|
+
// Always show config errors — they're developer mistakes
|
|
106
|
+
return json({ error: err.message }, 500)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (err instanceof ExternalServiceError) {
|
|
110
|
+
return json({ error: 'Service unavailable' }, 502)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Unknown error
|
|
114
|
+
if (this.isDev) {
|
|
115
|
+
return json({ error: err.message, stack: err.stack }, 500)
|
|
116
|
+
}
|
|
117
|
+
return json({ error: 'Internal Server Error' }, 500)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function json(data: unknown, status: number): Response {
|
|
122
|
+
return new Response(JSON.stringify(data), {
|
|
123
|
+
status,
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
})
|
|
126
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { HttpException, ValidationError } from './http_exception.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Throw an HTTP exception. Caught by the ExceptionHandler and rendered
|
|
5
|
+
* as a JSON error response.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { abort } from '@stravigor/core/exceptions'
|
|
9
|
+
*
|
|
10
|
+
* const project = await Project.find(id)
|
|
11
|
+
* if (!project) abort(404, 'Project not found')
|
|
12
|
+
*
|
|
13
|
+
* abort(403, 'You cannot access this resource')
|
|
14
|
+
*
|
|
15
|
+
* // Validation errors with structured field data
|
|
16
|
+
* abort(422, { email: ['Required'], name: ['Too short'] })
|
|
17
|
+
*/
|
|
18
|
+
export function abort(status: 422, errors: Record<string, string[]>): never
|
|
19
|
+
export function abort(status: number, message?: string): never
|
|
20
|
+
export function abort(status: number, messageOrErrors?: string | Record<string, string[]>): never {
|
|
21
|
+
if (status === 422 && typeof messageOrErrors === 'object') {
|
|
22
|
+
throw new ValidationError(messageOrErrors)
|
|
23
|
+
}
|
|
24
|
+
throw new HttpException(status, typeof messageOrErrors === 'string' ? messageOrErrors : undefined)
|
|
25
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { StravError } from './strav_error.ts'
|
|
2
|
+
|
|
3
|
+
const STATUS_MESSAGES: Record<number, string> = {
|
|
4
|
+
400: 'Bad Request',
|
|
5
|
+
401: 'Unauthenticated',
|
|
6
|
+
403: 'Forbidden',
|
|
7
|
+
404: 'Not Found',
|
|
8
|
+
409: 'Conflict',
|
|
9
|
+
422: 'Validation Failed',
|
|
10
|
+
429: 'Too Many Requests',
|
|
11
|
+
500: 'Internal Server Error',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* HTTP-aware exception. Throw from handlers or middleware to produce
|
|
16
|
+
* an error response with the given status code.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* throw new HttpException(403, 'You cannot access this resource')
|
|
20
|
+
*/
|
|
21
|
+
export class HttpException extends StravError {
|
|
22
|
+
constructor(
|
|
23
|
+
public readonly status: number,
|
|
24
|
+
message?: string,
|
|
25
|
+
public readonly body?: unknown
|
|
26
|
+
) {
|
|
27
|
+
super(message ?? STATUS_MESSAGES[status] ?? `HTTP Error ${status}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 400 Bad Request.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* throw new BadRequestError('Malformed JSON in request body')
|
|
36
|
+
*/
|
|
37
|
+
export class BadRequestError extends HttpException {
|
|
38
|
+
constructor(message?: string) {
|
|
39
|
+
super(400, message)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 401 Unauthenticated.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* throw new AuthenticationError()
|
|
48
|
+
* throw new AuthenticationError('Token expired')
|
|
49
|
+
*/
|
|
50
|
+
export class AuthenticationError extends HttpException {
|
|
51
|
+
constructor(message?: string) {
|
|
52
|
+
super(401, message)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 403 Forbidden.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* throw new AuthorizationError('You do not own this resource')
|
|
61
|
+
*/
|
|
62
|
+
export class AuthorizationError extends HttpException {
|
|
63
|
+
constructor(message?: string) {
|
|
64
|
+
super(403, message)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 404 Not Found.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* throw new NotFoundError('Project not found')
|
|
73
|
+
*/
|
|
74
|
+
export class NotFoundError extends HttpException {
|
|
75
|
+
constructor(message?: string) {
|
|
76
|
+
super(404, message)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 409 Conflict.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* throw new ConflictError('A project with this slug already exists')
|
|
85
|
+
*/
|
|
86
|
+
export class ConflictError extends HttpException {
|
|
87
|
+
constructor(message?: string) {
|
|
88
|
+
super(409, message)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 422 Validation Failed — carries structured field errors.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* throw new ValidationError({ email: ['Required'], name: ['Too short'] })
|
|
97
|
+
*/
|
|
98
|
+
export class ValidationError extends HttpException {
|
|
99
|
+
constructor(
|
|
100
|
+
public readonly errors: Record<string, string[]>,
|
|
101
|
+
message?: string
|
|
102
|
+
) {
|
|
103
|
+
super(422, message ?? 'Validation Failed', errors)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 429 Too Many Requests — optionally carries a retry-after value.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* throw new RateLimitError(60) // retry after 60 seconds
|
|
112
|
+
*/
|
|
113
|
+
export class RateLimitError extends HttpException {
|
|
114
|
+
constructor(public readonly retryAfter?: number, message?: string) {
|
|
115
|
+
super(429, message)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 500 Internal Server Error.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* throw new ServerError('Unexpected state in payment processor')
|
|
124
|
+
*/
|
|
125
|
+
export class ServerError extends HttpException {
|
|
126
|
+
constructor(message?: string) {
|
|
127
|
+
super(500, message)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { StravError } from './strav_error.ts'
|
|
2
|
+
export {
|
|
3
|
+
HttpException,
|
|
4
|
+
BadRequestError,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
AuthorizationError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ServerError,
|
|
12
|
+
} from './http_exception.ts'
|
|
13
|
+
export {
|
|
14
|
+
ConfigurationError,
|
|
15
|
+
ModelNotFoundError,
|
|
16
|
+
DatabaseError,
|
|
17
|
+
EncryptionError,
|
|
18
|
+
TemplateError,
|
|
19
|
+
ExternalServiceError,
|
|
20
|
+
} from './errors.ts'
|
|
21
|
+
export { ExceptionHandler } from './exception_handler.ts'
|
|
22
|
+
export { abort } from './helpers.ts'
|
|
23
|
+
export type { RenderFn, ReportFn } from './exception_handler.ts'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base error class for all Strav framework errors.
|
|
3
|
+
*
|
|
4
|
+
* Enables `instanceof StravError` to catch any framework error.
|
|
5
|
+
*/
|
|
6
|
+
export class StravError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message)
|
|
9
|
+
this.name = this.constructor.name
|
|
10
|
+
}
|
|
11
|
+
}
|