@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,72 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import I18nManager from './i18n_manager.ts'
3
+ import { translate, translateChoice, interpolate } from './translator.ts'
4
+
5
+ /**
6
+ * Request-scoped locale storage.
7
+ * The i18n middleware sets the locale per-request via `localeStorage.run()`.
8
+ */
9
+ export const localeStorage = new AsyncLocalStorage<string>()
10
+
11
+ // Hardcoded English fallbacks for validation rules when i18n is not configured
12
+ const VALIDATION_DEFAULTS: Record<string, string> = {
13
+ 'validation.required': 'This field is required',
14
+ 'validation.string': 'Must be a string',
15
+ 'validation.integer': 'Must be an integer',
16
+ 'validation.number': 'Must be a number',
17
+ 'validation.boolean': 'Must be a boolean',
18
+ 'validation.min.number': 'Must be at least :min',
19
+ 'validation.min.string': 'Must be at least :min characters',
20
+ 'validation.max.number': 'Must be at most :max',
21
+ 'validation.max.string': 'Must be at most :max characters',
22
+ 'validation.email': 'Must be a valid email address',
23
+ 'validation.url': 'Must be a valid URL',
24
+ 'validation.regex': 'Invalid format',
25
+ 'validation.enum': 'Must be one of: :values',
26
+ 'validation.array': 'Must be an array',
27
+ }
28
+
29
+ /**
30
+ * Get the current locale from the request context, or the default locale.
31
+ *
32
+ * @example
33
+ * locale() // 'en'
34
+ */
35
+ export function locale(): string {
36
+ return localeStorage.getStore() ?? I18nManager.config.default
37
+ }
38
+
39
+ /**
40
+ * Translate a key with optional replacements.
41
+ * Uses the current request locale (from AsyncLocalStorage).
42
+ *
43
+ * If i18n is not configured, returns English fallbacks for validation
44
+ * keys and the raw key for everything else.
45
+ *
46
+ * @example
47
+ * t('auth.failed') // "Invalid credentials"
48
+ * t('messages.welcome', { name: 'Alice' }) // "Welcome, Alice!"
49
+ */
50
+ export function t(key: string, replacements?: Record<string, string | number>): string {
51
+ if (!I18nManager.isLoaded) {
52
+ // Fallback: return hardcoded English default or the key itself
53
+ const fallback = VALIDATION_DEFAULTS[key]
54
+ if (fallback) return replacements ? interpolate(fallback, replacements) : fallback
55
+ return key
56
+ }
57
+
58
+ return translate(locale(), key, replacements)
59
+ }
60
+
61
+ /**
62
+ * Pluralized translation based on count.
63
+ *
64
+ * @example
65
+ * choice('messages.apple', 1) // "one apple"
66
+ * choice('messages.apple', 5) // "5 apples"
67
+ */
68
+ export function choice(key: string, count: number, replacements?: Record<string, string | number>): string {
69
+ if (!I18nManager.isLoaded) return key
70
+
71
+ return translateChoice(locale(), key, count, replacements)
72
+ }
@@ -0,0 +1,155 @@
1
+ import { resolve, join, basename } from 'node:path'
2
+ import { readdirSync } from 'node:fs'
3
+ import { inject } from '../core/inject.ts'
4
+ import Configuration from '../config/configuration.ts'
5
+ import { ConfigurationError } from '../exceptions/errors.ts'
6
+ import { dotGet } from './translator.ts'
7
+ import type { I18nConfig, Messages, LocaleDetection } from './types.ts'
8
+
9
+ // Default English validation messages (loaded as base layer)
10
+ import defaultValidation from './defaults/en/validation.json'
11
+
12
+ /**
13
+ * Central i18n configuration hub.
14
+ *
15
+ * Resolved once via the DI container — reads the i18n config,
16
+ * loads translation files from disk, and provides key resolution.
17
+ *
18
+ * @example
19
+ * app.singleton(I18nManager)
20
+ * app.resolve(I18nManager)
21
+ * await I18nManager.load()
22
+ */
23
+ @inject
24
+ export default class I18nManager {
25
+ private static _config: I18nConfig
26
+ private static _translations = new Map<string, Messages>()
27
+ private static _loaded = false
28
+
29
+ constructor(config: Configuration) {
30
+ I18nManager._config = {
31
+ default: config.get('i18n.default', 'en') as string,
32
+ fallback: config.get('i18n.fallback', 'en') as string,
33
+ supported: config.get('i18n.supported', ['en']) as string[],
34
+ directory: config.get('i18n.directory', 'lang') as string,
35
+ detect: config.get('i18n.detect', ['header']) as LocaleDetection[],
36
+ }
37
+ }
38
+
39
+ static get config(): I18nConfig {
40
+ if (!I18nManager._config) {
41
+ throw new ConfigurationError('I18nManager not configured. Resolve it through the container first.')
42
+ }
43
+ return I18nManager._config
44
+ }
45
+
46
+ /** Whether translation files have been loaded. */
47
+ static get isLoaded(): boolean {
48
+ return I18nManager._loaded
49
+ }
50
+
51
+ /**
52
+ * Load all translation files from the configured directory.
53
+ * Merges built-in defaults as the base layer.
54
+ */
55
+ static async load(): Promise<void> {
56
+ // Load built-in defaults first
57
+ I18nManager.mergeMessages('en', { validation: defaultValidation as Messages })
58
+
59
+ // Scan lang/ directory
60
+ const langDir = resolve(I18nManager._config.directory)
61
+
62
+ let locales: string[]
63
+ try {
64
+ locales = readdirSync(langDir, { withFileTypes: true })
65
+ .filter(d => d.isDirectory())
66
+ .map(d => d.name)
67
+ } catch {
68
+ // No lang directory — use defaults only
69
+ I18nManager._loaded = true
70
+ return
71
+ }
72
+
73
+ for (const locale of locales) {
74
+ const localeDir = join(langDir, locale)
75
+ let files: string[]
76
+ try {
77
+ files = readdirSync(localeDir).filter(f => f.endsWith('.json'))
78
+ } catch {
79
+ continue
80
+ }
81
+
82
+ for (const file of files) {
83
+ const namespace = basename(file, '.json')
84
+ const filePath = join(localeDir, file)
85
+ const content = await Bun.file(filePath).json()
86
+ I18nManager.mergeMessages(locale, { [namespace]: content as Messages })
87
+ }
88
+ }
89
+
90
+ I18nManager._loaded = true
91
+ }
92
+
93
+ /**
94
+ * Resolve a translation key for a specific locale.
95
+ * Tries the requested locale, then the fallback locale.
96
+ * Returns null if not found in either.
97
+ */
98
+ static resolve(locale: string, key: string): string | null {
99
+ // Try requested locale
100
+ const messages = I18nManager._translations.get(locale)
101
+ if (messages) {
102
+ const value = dotGet(messages, key)
103
+ if (typeof value === 'string') return value
104
+ }
105
+
106
+ // Try fallback locale
107
+ const fallback = I18nManager._config?.fallback ?? 'en'
108
+ if (locale !== fallback) {
109
+ const fallbackMessages = I18nManager._translations.get(fallback)
110
+ if (fallbackMessages) {
111
+ const value = dotGet(fallbackMessages, key)
112
+ if (typeof value === 'string') return value
113
+ }
114
+ }
115
+
116
+ return null
117
+ }
118
+
119
+ /** Register translations at runtime (useful for testing or dynamic loading). */
120
+ static register(locale: string, messages: Messages): void {
121
+ I18nManager.mergeMessages(locale, messages)
122
+ }
123
+
124
+ /** Reset all state (for testing). */
125
+ static reset(): void {
126
+ I18nManager._translations.clear()
127
+ I18nManager._loaded = false
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Internal
132
+ // ---------------------------------------------------------------------------
133
+
134
+ private static mergeMessages(locale: string, messages: Messages): void {
135
+ const existing = I18nManager._translations.get(locale) ?? {}
136
+ I18nManager._translations.set(locale, deepMerge(existing, messages))
137
+ }
138
+ }
139
+
140
+ function deepMerge(target: Messages, source: Messages): Messages {
141
+ const result = { ...target }
142
+ for (const [key, value] of Object.entries(source)) {
143
+ if (
144
+ typeof value === 'object' &&
145
+ value !== null &&
146
+ typeof result[key] === 'object' &&
147
+ result[key] !== null
148
+ ) {
149
+ result[key] = deepMerge(result[key] as Messages, value as Messages)
150
+ } else {
151
+ result[key] = value
152
+ }
153
+ }
154
+ return result
155
+ }
@@ -0,0 +1,4 @@
1
+ export { default as I18nManager } from './i18n_manager.ts'
2
+ export { t, choice, locale, localeStorage } from './helpers.ts'
3
+ export { i18n } from './middleware.ts'
4
+ export type { I18nConfig, Messages, LocaleDetection } from './types.ts'
@@ -0,0 +1,90 @@
1
+ import type Context from '../http/context.ts'
2
+ import type { Next } from '../http/middleware.ts'
3
+ import type { Middleware } from '../http/middleware.ts'
4
+ import I18nManager from './i18n_manager.ts'
5
+ import { localeStorage } from './helpers.ts'
6
+
7
+ /**
8
+ * i18n middleware — detects the request locale and sets it for the
9
+ * duration of the request via `AsyncLocalStorage`.
10
+ *
11
+ * Detection strategies are tried in the order configured in `config/i18n.ts`.
12
+ *
13
+ * @example
14
+ * import { i18n } from '@stravigor/core/i18n'
15
+ * router.use(i18n())
16
+ */
17
+ export function i18n(): Middleware {
18
+ return (ctx: Context, next: Next) => {
19
+ const detected = detectLocale(ctx)
20
+ return localeStorage.run(detected, () => next())
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Detect locale from the request using configured strategies.
26
+ * Falls back to the default locale if no strategy matches.
27
+ */
28
+ function detectLocale(ctx: Context): string {
29
+ const config = I18nManager.config
30
+ const supported = config.supported
31
+
32
+ for (const strategy of config.detect) {
33
+ switch (strategy) {
34
+ case 'query': {
35
+ const lang = ctx.query.get('lang') ?? ctx.query.get('locale')
36
+ if (lang && supported.includes(lang)) return lang
37
+ break
38
+ }
39
+ case 'cookie': {
40
+ const cookie = ctx.cookie('locale')
41
+ if (cookie && supported.includes(cookie)) return cookie
42
+ break
43
+ }
44
+ case 'header': {
45
+ const match = parseAcceptLanguage(
46
+ ctx.headers.get('accept-language'),
47
+ supported
48
+ )
49
+ if (match) return match
50
+ break
51
+ }
52
+ }
53
+ }
54
+
55
+ return config.default
56
+ }
57
+
58
+ /**
59
+ * Parse the Accept-Language header and return the best match
60
+ * against supported locales.
61
+ *
62
+ * @example
63
+ * parseAcceptLanguage('fr-FR,fr;q=0.9,en;q=0.8', ['en', 'fr']) // 'fr'
64
+ */
65
+ export function parseAcceptLanguage(
66
+ header: string | null,
67
+ supported: string[]
68
+ ): string | null {
69
+ if (!header) return null
70
+
71
+ // Parse entries like "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7"
72
+ const entries = header
73
+ .split(',')
74
+ .map(part => {
75
+ const [tag = '', ...rest] = part.trim().split(';')
76
+ const qPart = rest.find(r => r.trim().startsWith('q='))
77
+ const q = qPart ? parseFloat(qPart.trim().slice(2)) : 1.0
78
+ return { tag: tag.trim().toLowerCase(), q: Number.isNaN(q) ? 0 : q }
79
+ })
80
+ .sort((a, b) => b.q - a.q)
81
+
82
+ // Try exact match first, then base language (e.g. 'fr-FR' → 'fr')
83
+ for (const { tag } of entries) {
84
+ if (supported.includes(tag)) return tag
85
+ const base = tag.split('-')[0]!
86
+ if (supported.includes(base)) return base
87
+ }
88
+
89
+ return null
90
+ }
@@ -0,0 +1,96 @@
1
+ import I18nManager from './i18n_manager.ts'
2
+ import type { Messages } from './types.ts'
3
+
4
+ /**
5
+ * Resolve a dot-notated key from a nested messages object.
6
+ *
7
+ * @example
8
+ * dotGet({ auth: { failed: 'Invalid' } }, 'auth.failed') // 'Invalid'
9
+ */
10
+ export function dotGet(messages: Messages, key: string): string | Messages | undefined {
11
+ const parts = key.split('.')
12
+ let current: string | Messages | undefined = messages
13
+
14
+ for (const part of parts) {
15
+ if (typeof current !== 'object' || current === null) return undefined
16
+ current = (current as Messages)[part]
17
+ }
18
+
19
+ return current
20
+ }
21
+
22
+ /**
23
+ * Replace `:key` placeholders with values from the replacements object.
24
+ *
25
+ * @example
26
+ * interpolate('Hello, :name!', { name: 'Alice' }) // 'Hello, Alice!'
27
+ */
28
+ export function interpolate(
29
+ message: string,
30
+ replacements: Record<string, string | number>
31
+ ): string {
32
+ let result = message
33
+ for (const [key, value] of Object.entries(replacements)) {
34
+ result = result.replaceAll(`:${key}`, String(value))
35
+ }
36
+ return result
37
+ }
38
+
39
+ /**
40
+ * Resolve a translation key and interpolate replacements.
41
+ * Falls back to the fallback locale, then returns the key itself.
42
+ */
43
+ export function translate(
44
+ locale: string,
45
+ key: string,
46
+ replacements?: Record<string, string | number>
47
+ ): string {
48
+ const raw = I18nManager.resolve(locale, key)
49
+ if (raw === null) return key
50
+
51
+ return replacements ? interpolate(raw, replacements) : raw
52
+ }
53
+
54
+ // Plural category order used by Intl.PluralRules
55
+ const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other'] as const
56
+
57
+ /**
58
+ * Pluralize a translation based on count.
59
+ *
60
+ * The translation value uses `|` to separate plural forms:
61
+ * - 2 forms: `"one apple|:count apples"` → `Intl.PluralRules` picks one vs other
62
+ * - 6 forms: mapped to `zero|one|two|few|many|other`
63
+ *
64
+ * `:count` is automatically added to replacements.
65
+ */
66
+ export function translateChoice(
67
+ locale: string,
68
+ key: string,
69
+ count: number,
70
+ replacements?: Record<string, string | number>
71
+ ): string {
72
+ const raw = I18nManager.resolve(locale, key)
73
+ if (raw === null) return key
74
+
75
+ const segments = raw.split('|')
76
+ const merged = { count, ...replacements }
77
+
78
+ let chosen: string
79
+
80
+ if (segments.length === 1) {
81
+ chosen = segments[0]!
82
+ } else if (segments.length === 2) {
83
+ // Simple singular/plural
84
+ const rules = new Intl.PluralRules(locale)
85
+ const category = rules.select(count)
86
+ chosen = category === 'one' ? segments[0]! : segments[1]!
87
+ } else {
88
+ // Map to plural categories
89
+ const rules = new Intl.PluralRules(locale)
90
+ const category = rules.select(count)
91
+ const index = PLURAL_CATEGORIES.indexOf(category)
92
+ chosen = segments[Math.min(index, segments.length - 1)]!
93
+ }
94
+
95
+ return interpolate(chosen.trim(), merged)
96
+ }
@@ -0,0 +1,17 @@
1
+ export interface I18nConfig {
2
+ /** Default locale, e.g. 'en'. */
3
+ default: string
4
+ /** Fallback locale when a key is missing in the current locale. */
5
+ fallback: string
6
+ /** List of supported locale codes. */
7
+ supported: string[]
8
+ /** Path to the lang/ directory (relative to cwd). */
9
+ directory: string
10
+ /** Locale detection strategies, tried in order. */
11
+ detect: LocaleDetection[]
12
+ }
13
+
14
+ export type LocaleDetection = 'header' | 'cookie' | 'query'
15
+
16
+ /** Nested translation messages — leaf values are strings. */
17
+ export type Messages = { [key: string]: string | Messages }
@@ -0,0 +1,6 @@
1
+ export { default } from './logger.ts'
2
+ export { default as Logger } from './logger.ts'
3
+ export { LogSink, type SinkConfig, type LogLevel } from './sinks/sink.ts'
4
+ export { ConsoleSink, type ConsoleSinkConfig } from './sinks/console_sink.ts'
5
+ export { FileSink, type FileSinkConfig } from './sinks/file_sink.ts'
6
+ export { requestLogger } from './request_logger.ts'
@@ -0,0 +1,100 @@
1
+ import pino from 'pino'
2
+ import Configuration from '../config/configuration.ts'
3
+ import { inject } from '../core/inject.ts'
4
+ import { LogSink, type SinkConfig } from './sinks/sink'
5
+ import { ConsoleSink } from './sinks/console_sink'
6
+ import { FileSink } from './sinks/file_sink'
7
+
8
+ type SinkConstructor = new (config: SinkConfig) => LogSink
9
+
10
+ /**
11
+ * Maps sink names used in `config/logging.ts` to their implementing classes.
12
+ * Register new sink types here so the Logger can instantiate them from config.
13
+ */
14
+ const sinkRegistry: Record<string, SinkConstructor> = {
15
+ console: ConsoleSink,
16
+ file: FileSink,
17
+ }
18
+
19
+ /**
20
+ * Structured logger backed by pino with configurable sinks.
21
+ *
22
+ * Sinks (console, file, …) are declared in `config/logging.ts` and combined
23
+ * at construction time via `pino.multistream`. Each sink can define its own
24
+ * minimum log level independently of the global level.
25
+ *
26
+ * @example
27
+ * // Resolve via DI (Configuration is injected automatically):
28
+ * container.singleton(Logger)
29
+ * const logger = container.resolve(Logger)
30
+ *
31
+ * logger.info('server started', { port: 3000 })
32
+ * logger.error('request failed', { statusCode: 500, path: '/api/users' })
33
+ */
34
+ @inject
35
+ export default class Logger {
36
+ private pino: pino.Logger
37
+
38
+ constructor(protected config: Configuration) {
39
+ const sinks = this.buildSinks()
40
+
41
+ const streams = sinks.map(sink => ({
42
+ stream: sink.createStream(),
43
+ level: sink.level as pino.Level,
44
+ }))
45
+
46
+ const level = this.config.get('logging.level', 'info') as string satisfies string
47
+
48
+ this.pino = streams.length > 0 ? pino({ level }, pino.multistream(streams)) : pino({ level })
49
+ }
50
+
51
+ /** Log at `trace` level (most verbose). */
52
+ trace(msg: string, context?: Record<string, unknown>): void {
53
+ context ? this.pino.trace(context, msg) : this.pino.trace(msg)
54
+ }
55
+
56
+ /** Log at `debug` level. */
57
+ debug(msg: string, context?: Record<string, unknown>): void {
58
+ context ? this.pino.debug(context, msg) : this.pino.debug(msg)
59
+ }
60
+
61
+ /** Log at `info` level. */
62
+ info(msg: string, context?: Record<string, unknown>): void {
63
+ context ? this.pino.info(context, msg) : this.pino.info(msg)
64
+ }
65
+
66
+ /** Log at `warn` level. */
67
+ warn(msg: string, context?: Record<string, unknown>): void {
68
+ context ? this.pino.warn(context, msg) : this.pino.warn(msg)
69
+ }
70
+
71
+ /** Log at `error` level. */
72
+ error(msg: string, context?: Record<string, unknown>): void {
73
+ context ? this.pino.error(context, msg) : this.pino.error(msg)
74
+ }
75
+
76
+ /** Log at `fatal` level (most severe). */
77
+ fatal(msg: string, context?: Record<string, unknown>): void {
78
+ context ? this.pino.fatal(context, msg) : this.pino.fatal(msg)
79
+ }
80
+
81
+ /**
82
+ * Read the `logging.sinks` configuration and instantiate every enabled sink
83
+ * whose name matches an entry in {@link sinkRegistry}.
84
+ */
85
+ private buildSinks(): LogSink[] {
86
+ const sinks: LogSink[] = []
87
+ const sinksConfig = this.config.get('logging.sinks', {}) as Record<string, SinkConfig>
88
+
89
+ for (const [name, sinkConfig] of Object.entries(sinksConfig)) {
90
+ if (!sinkConfig.enabled) continue
91
+
92
+ const SinkClass = sinkRegistry[name]
93
+ if (SinkClass) {
94
+ sinks.push(new SinkClass(sinkConfig))
95
+ }
96
+ }
97
+
98
+ return sinks
99
+ }
100
+ }
@@ -0,0 +1,19 @@
1
+ import type { Middleware } from '../http/middleware.ts'
2
+ import type Logger from './logger.ts'
3
+
4
+ export function requestLogger(logger: Logger): Middleware {
5
+ return async (ctx, next) => {
6
+ const start = performance.now()
7
+ const response = await next()
8
+ const duration = Math.round(performance.now() - start)
9
+
10
+ logger.info(`${ctx.method} ${ctx.path} ${response.status} ${duration}ms`, {
11
+ method: ctx.method,
12
+ path: ctx.path,
13
+ status: response.status,
14
+ duration,
15
+ })
16
+
17
+ return response
18
+ }
19
+ }
@@ -0,0 +1,24 @@
1
+ import build from 'pino-pretty'
2
+ import { LogSink, type SinkConfig } from './sink'
3
+
4
+ /** Configuration specific to the console sink. */
5
+ export interface ConsoleSinkConfig extends SinkConfig {
6
+ /** Enable ANSI colour output. Defaults to `true`. */
7
+ colorize?: boolean
8
+ }
9
+
10
+ /**
11
+ * Log sink that writes human-readable output to stdout via `pino-pretty`.
12
+ *
13
+ * Intended for local development; for structured JSON output in production
14
+ * consider disabling this sink and using the file sink instead.
15
+ */
16
+ export class ConsoleSink extends LogSink {
17
+ createStream() {
18
+ const config = this.config as ConsoleSinkConfig
19
+
20
+ return build({
21
+ colorize: config.colorize ?? true,
22
+ })
23
+ }
24
+ }
@@ -0,0 +1,24 @@
1
+ import { mkdirSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import pino from 'pino'
4
+ import { LogSink, type SinkConfig } from './sink'
5
+
6
+ /** Configuration specific to the file sink. */
7
+ export interface FileSinkConfig extends SinkConfig {
8
+ /** Path to the log file. Parent directories are created automatically. */
9
+ path: string
10
+ }
11
+
12
+ /**
13
+ * Log sink that writes structured JSON to a file using pino's optimised
14
+ * {@link pino.destination | SonicBoom} writer with asynchronous flushing.
15
+ */
16
+ export class FileSink extends LogSink {
17
+ createStream() {
18
+ const { path } = this.config as FileSinkConfig
19
+
20
+ mkdirSync(dirname(path), { recursive: true })
21
+
22
+ return pino.destination({ dest: path, sync: false })
23
+ }
24
+ }
@@ -0,0 +1,36 @@
1
+ import type { DestinationStream } from 'pino'
2
+
3
+ /** Supported log severity levels, from most to least verbose. */
4
+ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent'
5
+
6
+ /** Configuration object for a single log sink. */
7
+ export interface SinkConfig {
8
+ /** Whether this sink is active. Disabled sinks are skipped by the Logger. */
9
+ enabled: boolean
10
+ /** Minimum severity level this sink will accept. Defaults to `"info"`. */
11
+ level?: LogLevel
12
+ [key: string]: unknown
13
+ }
14
+
15
+ /**
16
+ * Abstract base class for log sinks.
17
+ *
18
+ * Subclasses implement {@link createStream} to provide a writable destination
19
+ * (stdout, file, network, etc.) that the Logger combines via pino multistream.
20
+ *
21
+ * To add a new sink:
22
+ * 1. Extend this class and implement `createStream()`.
23
+ * 2. Register the class in the `sinkRegistry` inside `logger.ts`.
24
+ * 3. Add a corresponding section in `config/logging.ts`.
25
+ */
26
+ export abstract class LogSink {
27
+ constructor(protected config: SinkConfig) {}
28
+
29
+ /** Create the writable destination stream for this sink. */
30
+ abstract createStream(): DestinationStream
31
+
32
+ /** Minimum log level for this sink. Defaults to `"info"` when not configured. */
33
+ get level(): LogLevel {
34
+ return this.config.level ?? 'info'
35
+ }
36
+ }