@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,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,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
|
+
}
|