@getsoda/core 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +58 -0
  2. package/dist/app/app.d.ts +17 -1
  3. package/dist/app/app.d.ts.map +1 -1
  4. package/dist/app/app.js +46 -10
  5. package/dist/app/app.js.map +1 -1
  6. package/dist/di/container.d.ts +7 -0
  7. package/dist/di/container.d.ts.map +1 -1
  8. package/dist/di/container.js +24 -1
  9. package/dist/di/container.js.map +1 -1
  10. package/dist/di/graph.d.ts +2 -2
  11. package/dist/di/graph.d.ts.map +1 -1
  12. package/dist/di/graph.js +20 -10
  13. package/dist/di/graph.js.map +1 -1
  14. package/dist/http/arguments.d.ts +20 -2
  15. package/dist/http/arguments.d.ts.map +1 -1
  16. package/dist/http/arguments.js +29 -0
  17. package/dist/http/arguments.js.map +1 -1
  18. package/dist/http/body.d.ts +28 -0
  19. package/dist/http/body.d.ts.map +1 -1
  20. package/dist/http/body.js +96 -1
  21. package/dist/http/body.js.map +1 -1
  22. package/dist/http/context-key.d.ts +33 -0
  23. package/dist/http/context-key.d.ts.map +1 -0
  24. package/dist/http/context-key.js +37 -0
  25. package/dist/http/context-key.js.map +1 -0
  26. package/dist/http/context.d.ts +37 -3
  27. package/dist/http/context.d.ts.map +1 -1
  28. package/dist/http/context.js +63 -5
  29. package/dist/http/context.js.map +1 -1
  30. package/dist/http/cookies.d.ts +49 -0
  31. package/dist/http/cookies.d.ts.map +1 -0
  32. package/dist/http/cookies.js +77 -0
  33. package/dist/http/cookies.js.map +1 -0
  34. package/dist/http/cors.d.ts +41 -0
  35. package/dist/http/cors.d.ts.map +1 -0
  36. package/dist/http/cors.js +97 -0
  37. package/dist/http/cors.js.map +1 -0
  38. package/dist/http/index.d.ts +8 -2
  39. package/dist/http/index.d.ts.map +1 -1
  40. package/dist/http/index.js +4 -1
  41. package/dist/http/index.js.map +1 -1
  42. package/dist/http/middleware.d.ts +56 -0
  43. package/dist/http/middleware.d.ts.map +1 -0
  44. package/dist/http/middleware.js +83 -0
  45. package/dist/http/middleware.js.map +1 -0
  46. package/dist/http/parsers.d.ts +3 -0
  47. package/dist/http/parsers.d.ts.map +1 -1
  48. package/dist/http/parsers.js +2 -1
  49. package/dist/http/parsers.js.map +1 -1
  50. package/dist/http/routes.d.ts +10 -1
  51. package/dist/http/routes.d.ts.map +1 -1
  52. package/dist/http/routes.js +4 -1
  53. package/dist/http/routes.js.map +1 -1
  54. package/dist/index.d.ts +1 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/jwt/algorithms.d.ts +19 -0
  59. package/dist/jwt/algorithms.d.ts.map +1 -0
  60. package/dist/jwt/algorithms.js +60 -0
  61. package/dist/jwt/algorithms.js.map +1 -0
  62. package/dist/jwt/errors.d.ts +24 -0
  63. package/dist/jwt/errors.d.ts.map +1 -0
  64. package/dist/jwt/errors.js +14 -0
  65. package/dist/jwt/errors.js.map +1 -0
  66. package/dist/jwt/guard.d.ts +30 -0
  67. package/dist/jwt/guard.d.ts.map +1 -0
  68. package/dist/jwt/guard.js +61 -0
  69. package/dist/jwt/guard.js.map +1 -0
  70. package/dist/jwt/index.d.ts +14 -0
  71. package/dist/jwt/index.d.ts.map +1 -0
  72. package/dist/jwt/index.js +10 -0
  73. package/dist/jwt/index.js.map +1 -0
  74. package/dist/jwt/module.d.ts +36 -0
  75. package/dist/jwt/module.d.ts.map +1 -0
  76. package/dist/jwt/module.js +38 -0
  77. package/dist/jwt/module.js.map +1 -0
  78. package/dist/jwt/service.d.ts +66 -0
  79. package/dist/jwt/service.d.ts.map +1 -0
  80. package/dist/jwt/service.js +138 -0
  81. package/dist/jwt/service.js.map +1 -0
  82. package/dist/logger/console-logger.d.ts +36 -0
  83. package/dist/logger/console-logger.d.ts.map +1 -0
  84. package/dist/logger/console-logger.js +117 -0
  85. package/dist/logger/console-logger.js.map +1 -0
  86. package/dist/logger/index.d.ts +9 -0
  87. package/dist/logger/index.d.ts.map +1 -0
  88. package/dist/logger/index.js +7 -0
  89. package/dist/logger/index.js.map +1 -0
  90. package/dist/logger/logger.d.ts +55 -0
  91. package/dist/logger/logger.d.ts.map +1 -0
  92. package/dist/logger/logger.js +66 -0
  93. package/dist/logger/logger.js.map +1 -0
  94. package/dist/routing/types.d.ts +1 -1
  95. package/dist/routing/types.d.ts.map +1 -1
  96. package/package.json +6 -12
  97. package/src/app/app.ts +77 -13
  98. package/src/di/container.ts +38 -2
  99. package/src/di/graph.ts +26 -11
  100. package/src/http/arguments.ts +42 -2
  101. package/src/http/body.ts +142 -1
  102. package/src/http/context-key.ts +35 -0
  103. package/src/http/context.ts +94 -8
  104. package/src/http/cookies.ts +96 -0
  105. package/src/http/cors.ts +143 -0
  106. package/src/http/index.ts +8 -2
  107. package/src/http/middleware.ts +124 -0
  108. package/src/http/parsers.ts +2 -1
  109. package/src/http/routes.ts +17 -2
  110. package/src/index.ts +1 -0
  111. package/src/jwt/algorithms.ts +113 -0
  112. package/src/jwt/errors.ts +30 -0
  113. package/src/jwt/guard.ts +74 -0
  114. package/src/jwt/index.ts +14 -0
  115. package/src/jwt/module.ts +58 -0
  116. package/src/jwt/service.ts +216 -0
  117. package/src/logger/console-logger.ts +163 -0
  118. package/src/logger/index.ts +9 -0
  119. package/src/logger/logger.ts +96 -0
  120. package/src/routing/types.ts +1 -1
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @module
3
+ * Signing and verifying JSON Web Tokens (RFC 7519) with WebCrypto.
4
+ */
5
+
6
+ import { parseJson } from '../http/body.ts'
7
+ import { importKey, isHmac, isJwtAlgorithm, signBytes, verifyBytes } from './algorithms.ts'
8
+ import type { JwtAlgorithm, JwtKey } from './algorithms.ts'
9
+ import { JwtError } from './errors.ts'
10
+
11
+ /** Claims of a token: the registered ones (RFC 7519, section 4.1) and any other. */
12
+ export interface JwtPayload {
13
+ readonly iss?: string
14
+ readonly sub?: string
15
+ readonly aud?: string | readonly string[]
16
+ /** Expiration time, in seconds since the epoch. */
17
+ readonly exp?: number
18
+ /** Not before, in seconds since the epoch. */
19
+ readonly nbf?: number
20
+ /** Issued at, in seconds since the epoch. */
21
+ readonly iat?: number
22
+ readonly jti?: string
23
+ readonly [claim: string]: unknown
24
+ }
25
+
26
+ export interface JwtOptions {
27
+ /** Defaults to `HS256`. The only algorithm accepted when verifying. */
28
+ readonly algorithm?: JwtAlgorithm
29
+ /** Secret of an `HS*` algorithm. Random and at least as long as the hash: `openssl rand -base64 32`. */
30
+ readonly secret?: string
31
+ /** Key signing with an asymmetric algorithm. */
32
+ readonly privateKey?: JwtKey
33
+ /** Key verifying with an asymmetric algorithm. */
34
+ readonly publicKey?: JwtKey
35
+ /** Lifetime of signed tokens, in seconds: sets `exp`. */
36
+ readonly expiresIn?: number
37
+ /** Sets `iss` when signing; required when verifying. */
38
+ readonly issuer?: string
39
+ /** Sets `aud` when signing; when verifying, `aud` must hold one of these. */
40
+ readonly audience?: string | readonly string[]
41
+ /** Seconds of clock difference allowed when checking `exp` and `nbf`. Defaults to 0. */
42
+ readonly clockTolerance?: number
43
+ }
44
+
45
+ export interface JwtSignOptions {
46
+ /** Lifetime of this token, in seconds, instead of the configured one. */
47
+ readonly expiresIn?: number
48
+ }
49
+
50
+ const BASE64URL = /^[\w-]+$/
51
+ const TIME_CLAIMS = ['exp', 'nbf', 'iat'] as const
52
+ const encoder = new TextEncoder()
53
+ const decoder = new TextDecoder('utf-8', { fatal: true })
54
+
55
+ const encodePart = (bytes: Uint8Array): string => bytes.toBase64({ alphabet: 'base64url', omitPadding: true })
56
+
57
+ const decodeBytes = (part: string): Uint8Array<ArrayBuffer> => Uint8Array.fromBase64(part, { alphabet: 'base64url' })
58
+
59
+ /** The JSON object in a part of a token. */
60
+ const decodeObject = (part: string, name: string): Record<string, unknown> => {
61
+ let value: unknown
62
+
63
+ try {
64
+ value = parseJson(decoder.decode(decodeBytes(part)))
65
+ } catch {
66
+ value = undefined
67
+ }
68
+
69
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
70
+ throw new JwtError('malformed', `Token ${name} is not a JSON object`)
71
+ }
72
+
73
+ return value as Record<string, unknown>
74
+ }
75
+
76
+ const seconds = (): number => Math.floor(Date.now() / 1000)
77
+
78
+ /**
79
+ * Signs and verifies tokens with one algorithm and its keys, imported once.
80
+ *
81
+ * ```ts
82
+ * const jwt = new JwtService({ secret: process.env.JWT_SECRET!, expiresIn: 15 * 60 })
83
+ * const token = await jwt.sign({ sub: user.id })
84
+ * const payload = await jwt.verify(token) // throws JwtError
85
+ * ```
86
+ *
87
+ * The algorithm comes from the options, never from the token: a token signed
88
+ * otherwise, or not signed (`alg: none`), is refused. The signature is
89
+ * checked before any claim is trusted.
90
+ */
91
+ export class JwtService {
92
+ readonly #options: JwtOptions
93
+ readonly #algorithm: JwtAlgorithm
94
+ #signingKey: Promise<CryptoKey> | undefined
95
+ #verifyingKey: Promise<CryptoKey> | undefined
96
+
97
+ constructor(options: JwtOptions) {
98
+ const algorithm = options.algorithm ?? 'HS256'
99
+
100
+ if (!isJwtAlgorithm(algorithm)) {
101
+ throw new TypeError(`Unsupported JWT algorithm: ${String(algorithm)}`)
102
+ }
103
+
104
+ const hasKeys = options.privateKey !== undefined || options.publicKey !== undefined
105
+
106
+ if (isHmac(algorithm) && (options.secret === undefined || options.secret === '' || hasKeys)) {
107
+ throw new TypeError(`${algorithm} takes a non-empty secret, not keys`)
108
+ }
109
+
110
+ if (!isHmac(algorithm) && (options.secret !== undefined || !hasKeys)) {
111
+ throw new TypeError(`${algorithm} takes privateKey to sign and publicKey to verify, not a secret`)
112
+ }
113
+
114
+ this.#options = options
115
+ this.#algorithm = algorithm
116
+ }
117
+
118
+ /**
119
+ * Signs `payload`, adding `iat`, and `exp`, `iss`, `aud` from the options.
120
+ * Claims in `payload` win over the added ones.
121
+ */
122
+ async sign(payload: JwtPayload, options?: JwtSignOptions): Promise<string> {
123
+ const now = seconds()
124
+ const expiresIn = options?.expiresIn ?? this.#options.expiresIn
125
+ const { issuer, audience } = this.#options
126
+ const claims = {
127
+ iat: now,
128
+ ...(expiresIn === undefined ? {} : { exp: now + expiresIn }),
129
+ ...(issuer === undefined ? {} : { iss: issuer }),
130
+ ...(audience === undefined ? {} : { aud: audience }),
131
+ ...payload,
132
+ }
133
+ const input = `${encodePart(encoder.encode(JSON.stringify({ alg: this.#algorithm, typ: 'JWT' })))}.${encodePart(encoder.encode(JSON.stringify(claims)))}`
134
+
135
+ this.#signingKey ??= importKey(this.#key('privateKey'), this.#algorithm, 'sign')
136
+
137
+ return `${input}.${encodePart(await signBytes(this.#algorithm, await this.#signingKey, encoder.encode(input)))}`
138
+ }
139
+
140
+ /** The payload of a valid token; throws a {@link JwtError} otherwise. */
141
+ async verify<T extends JwtPayload = JwtPayload>(token: string): Promise<T> {
142
+ const parts = token.split('.')
143
+
144
+ if (parts.length !== 3 || !parts.every((part) => BASE64URL.test(part))) {
145
+ throw new JwtError('malformed', 'Token is not three base64url parts')
146
+ }
147
+
148
+ const [header, body, signature] = parts as [string, string, string]
149
+ const { alg, crit } = decodeObject(header, 'header')
150
+
151
+ if (alg !== this.#algorithm) {
152
+ throw new JwtError('algorithm', `Token is signed with ${String(alg)}, ${this.#algorithm} is expected`)
153
+ }
154
+
155
+ if (crit !== undefined) {
156
+ throw new JwtError('algorithm', 'Token has critical header extensions, which are not supported')
157
+ }
158
+
159
+ this.#verifyingKey ??= importKey(this.#key('publicKey'), this.#algorithm, 'verify')
160
+
161
+ const valid = await verifyBytes(this.#algorithm, await this.#verifyingKey, decodeBytes(signature), encoder.encode(`${header}.${body}`))
162
+
163
+ if (!valid) {
164
+ throw new JwtError('signature', 'Token signature does not match')
165
+ }
166
+
167
+ const payload = decodeObject(body, 'payload')
168
+
169
+ this.#checkClaims(payload)
170
+
171
+ return payload as T
172
+ }
173
+
174
+ #key(kind: 'privateKey' | 'publicKey'): JwtKey {
175
+ const key = isHmac(this.#algorithm) ? this.#options.secret : this.#options[kind]
176
+
177
+ if (key === undefined) {
178
+ throw new TypeError(`${this.#algorithm} needs ${kind} to ${kind === 'privateKey' ? 'sign' : 'verify'}`)
179
+ }
180
+
181
+ return key
182
+ }
183
+
184
+ #checkClaims(payload: Record<string, unknown>): void {
185
+ for (const claim of TIME_CLAIMS) {
186
+ if (payload[claim] !== undefined && !Number.isFinite(payload[claim])) {
187
+ throw new JwtError('claims', `Token claim "${claim}" is not a number`)
188
+ }
189
+ }
190
+
191
+ const { exp, nbf, iss, aud } = payload as JwtPayload
192
+ const { issuer, audience, clockTolerance = 0 } = this.#options
193
+ const now = seconds()
194
+
195
+ if (exp !== undefined && now >= exp + clockTolerance) {
196
+ throw new JwtError('expired', 'Token expired')
197
+ }
198
+
199
+ if (nbf !== undefined && now + clockTolerance < nbf) {
200
+ throw new JwtError('not-active', 'Token is not active yet')
201
+ }
202
+
203
+ if (issuer !== undefined && iss !== issuer) {
204
+ throw new JwtError('claims', `Token issuer is not "${issuer}"`)
205
+ }
206
+
207
+ if (audience !== undefined) {
208
+ const expected: readonly string[] = typeof audience === 'string' ? [audience] : audience
209
+ const actual: readonly unknown[] = typeof aud === 'string' ? [aud] : Array.isArray(aud) ? aud : []
210
+
211
+ if (!actual.some((value) => expected.includes(value as string))) {
212
+ throw new JwtError('claims', `Token audience is not ${expected.map((value) => `"${value}"`).join(' or ')}`)
213
+ }
214
+ }
215
+ }
216
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @module
3
+ * Logger writing to stdout and stderr, readable for people or as JSON lines.
4
+ *
5
+ * ```
6
+ * [Soda] 41250 - 11/14/2026, 12:22:09 AM LOG [UsersService] User created {"id":7} +3ms
7
+ * {"time":"2026-11-13T21:22:09.120Z","level":"log","context":"UsersService","message":"User created","id":7}
8
+ * ```
9
+ *
10
+ * Like Nest's `ConsoleLogger`: the same line layout, colored by level in a
11
+ * terminal, with the time since the previous line; a minimum level; a JSON
12
+ * mode for log collectors. `error` and `fatal` go to stderr, the rest to stdout.
13
+ */
14
+
15
+ import { Logger } from './logger.ts'
16
+ import type { LogEntry, LogLevel } from './logger.ts'
17
+
18
+ export interface ConsoleLoggerOptions {
19
+ /** Least severe level written. Defaults to `log`. */
20
+ readonly level?: LogLevel
21
+ /** One JSON object per line, for log collectors. Defaults to `false`. */
22
+ readonly json?: boolean
23
+ /** Where lines go. Defaults to stderr for `error` and `fatal`, stdout otherwise. */
24
+ readonly output?: (line: string, level: LogLevel) => void
25
+ /**
26
+ * Colors by level. Defaults to on when writing to a terminal without
27
+ * `NO_COLOR`, off with a custom `output`.
28
+ */
29
+ readonly colors?: boolean
30
+ /** Name starting each readable line. Defaults to `Soda`. */
31
+ readonly prefix?: string
32
+ }
33
+
34
+ const SEVERITY: Readonly<Record<LogLevel, number>> = { verbose: 0, debug: 1, log: 2, warn: 3, error: 4, fatal: 5 }
35
+
36
+ const color =
37
+ (code: string) =>
38
+ (text: string): string =>
39
+ `\u001B[${code}m${text}\u001B[0m`
40
+
41
+ const LEVEL_COLORS: Readonly<Record<LogLevel, (text: string) => string>> = {
42
+ verbose: color('96'),
43
+ debug: color('95'),
44
+ log: color('32'),
45
+ warn: color('33'),
46
+ error: color('31'),
47
+ fatal: color('1;31'),
48
+ }
49
+ const yellow = color('38;5;3')
50
+ const plain = (text: string): string => text
51
+
52
+ const dateTime = new Intl.DateTimeFormat(undefined, {
53
+ year: 'numeric',
54
+ month: '2-digit',
55
+ day: '2-digit',
56
+ hour: 'numeric',
57
+ minute: '2-digit',
58
+ second: '2-digit',
59
+ })
60
+
61
+ const writeToConsole = (line: string, level: LogLevel): void => {
62
+ const stream = level === 'error' || level === 'fatal' ? process.stderr : process.stdout
63
+
64
+ stream.write(`${line}\n`)
65
+ }
66
+
67
+ const serializeError = (error: Error): Record<string, unknown> => {
68
+ const serialized: Record<string, unknown> = { name: error.name, message: error.message, stack: error.stack }
69
+
70
+ if (error.cause !== undefined) {
71
+ serialized.cause = error.cause instanceof Error ? serializeError(error.cause) : error.cause
72
+ }
73
+
74
+ return serialized
75
+ }
76
+
77
+ /**
78
+ * `JSON.stringify` that never throws: bigints become strings, an object met a
79
+ * second time (a cycle) becomes `"[Circular]"`. Errors are serialized in full
80
+ * (stack and cause) for JSON lines, or as `Name: message` when their stack is
81
+ * printed on its own lines.
82
+ */
83
+ const stringify = (value: unknown, errors: 'full' | 'brief'): string => {
84
+ const seen = new WeakSet<object>()
85
+
86
+ return JSON.stringify(value, (_key, current: unknown) => {
87
+ if (typeof current === 'bigint') {
88
+ return current.toString()
89
+ }
90
+
91
+ if (current instanceof Error) {
92
+ return errors === 'full' ? serializeError(current) : `${current.name}: ${current.message}`
93
+ }
94
+
95
+ if (typeof current === 'object' && current !== null) {
96
+ if (seen.has(current)) {
97
+ return '[Circular]'
98
+ }
99
+
100
+ seen.add(current)
101
+ }
102
+
103
+ return current
104
+ })
105
+ }
106
+
107
+ export class ConsoleLogger extends Logger {
108
+ readonly #minimum: number
109
+ readonly #json: boolean
110
+ readonly #output: (line: string, level: LogLevel) => void
111
+ readonly #colors: boolean
112
+ readonly #prefix: string
113
+ /** When the previous line was written, for the `+3ms` at the end of the next. */
114
+ #last: number | undefined
115
+
116
+ constructor(options: ConsoleLoggerOptions = {}) {
117
+ super()
118
+ this.#minimum = SEVERITY[options.level ?? 'log']
119
+ this.#json = options.json === true
120
+ this.#output = options.output ?? writeToConsole
121
+ this.#colors = options.colors ?? (options.output === undefined && process.stdout.isTTY === true && process.env.NO_COLOR === undefined)
122
+ this.#prefix = options.prefix ?? 'Soda'
123
+ }
124
+
125
+ write(entry: LogEntry): void {
126
+ if (SEVERITY[entry.level] < this.#minimum) {
127
+ return
128
+ }
129
+
130
+ const now = Date.now()
131
+
132
+ this.#output(this.#json ? this.#formatJson(entry, now) : this.#formatText(entry, now), entry.level)
133
+ this.#last = now
134
+ }
135
+
136
+ #formatJson(entry: LogEntry, now: number): string {
137
+ const time = new Date(now).toISOString()
138
+
139
+ // Fixed fields last: data cannot overwrite them.
140
+ return stringify({ ...entry.data, time, level: entry.level, context: entry.context, message: entry.message }, 'full')
141
+ }
142
+
143
+ #formatText(entry: LogEntry, now: number): string {
144
+ const level = this.#colors ? LEVEL_COLORS[entry.level] : plain
145
+ const accent = this.#colors ? yellow : plain
146
+ const context = entry.context === undefined ? '' : accent(`[${entry.context}] `)
147
+ const since = this.#last === undefined ? '' : accent(` +${now - this.#last}ms`)
148
+ const data = entry.data === undefined ? '' : ` ${stringify(entry.data, 'brief')}`
149
+ const line =
150
+ `${level(`[${this.#prefix}] ${process.pid} - `)}${dateTime.format(now)} ${level(entry.level.toUpperCase().padStart(7))} ` +
151
+ `${context}${level(entry.message)}${data}${since}`
152
+ let stacks = ''
153
+
154
+ // Stacks are read better on their own lines than inside the JSON.
155
+ for (const value of Object.values(entry.data ?? {})) {
156
+ if (value instanceof Error && value.stack !== undefined) {
157
+ stacks += `\n${value.stack}`
158
+ }
159
+ }
160
+
161
+ return line + stacks
162
+ }
163
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @module
3
+ * Logging: the `Logger` contract and token, and the console implementation.
4
+ */
5
+
6
+ export { ConsoleLogger } from './console-logger.ts'
7
+ export type { ConsoleLoggerOptions } from './console-logger.ts'
8
+ export { LOG_LEVELS, Logger, SilentLogger } from './logger.ts'
9
+ export type { LogData, LogEntry, LogLevel } from './logger.ts'
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @module
3
+ * The logger contract.
4
+ *
5
+ * `Logger` is both the type and the DI token: the application provides one
6
+ * (a `ConsoleLogger` unless told otherwise) and any class injects it.
7
+ *
8
+ * ```ts
9
+ * class UsersService {
10
+ * readonly #logger = inject(Logger).child('UsersService')
11
+ *
12
+ * create(user: User) {
13
+ * this.#logger.log('User created', { id: user.id })
14
+ * }
15
+ * }
16
+ * ```
17
+ *
18
+ * Levels are Nest's. Unlike Nest's `Logger`, it is not a static global: each
19
+ * application has its own, and tests replace it with `overrides`.
20
+ */
21
+
22
+ /** Levels, from the most to the least verbose. */
23
+ export const LOG_LEVELS = ['verbose', 'debug', 'log', 'warn', 'error', 'fatal'] as const
24
+
25
+ export type LogLevel = (typeof LOG_LEVELS)[number]
26
+
27
+ /** Structured data of an entry; `Error` values are serialized with their stack and cause. */
28
+ export type LogData = Readonly<Record<string, unknown>>
29
+
30
+ /**
31
+ * One log entry, as a `Logger` implementation receives it.
32
+ */
33
+ export interface LogEntry {
34
+ readonly level: LogLevel
35
+ readonly message: string
36
+ readonly data: LogData | undefined
37
+ /** Where it comes from, as given to `child()`. */
38
+ readonly context: string | undefined
39
+ }
40
+
41
+ /**
42
+ * A logger. Implementations only write entries; levels and contexts are handled here.
43
+ */
44
+ export abstract class Logger {
45
+ /** Writes one entry. Called for every level: filtering is up to the implementation. */
46
+ abstract write(entry: LogEntry): void
47
+
48
+ verbose(message: string, data?: LogData): void {
49
+ this.write({ level: 'verbose', message, data, context: undefined })
50
+ }
51
+
52
+ debug(message: string, data?: LogData): void {
53
+ this.write({ level: 'debug', message, data, context: undefined })
54
+ }
55
+
56
+ log(message: string, data?: LogData): void {
57
+ this.write({ level: 'log', message, data, context: undefined })
58
+ }
59
+
60
+ warn(message: string, data?: LogData): void {
61
+ this.write({ level: 'warn', message, data, context: undefined })
62
+ }
63
+
64
+ error(message: string, data?: LogData): void {
65
+ this.write({ level: 'error', message, data, context: undefined })
66
+ }
67
+
68
+ fatal(message: string, data?: LogData): void {
69
+ this.write({ level: 'fatal', message, data, context: undefined })
70
+ }
71
+
72
+ /** A logger writing through this one, with `context` on every entry. */
73
+ child(context: string): Logger {
74
+ return new ContextLogger(this, context)
75
+ }
76
+ }
77
+
78
+ class ContextLogger extends Logger {
79
+ readonly #parent: Logger
80
+ readonly #context: string
81
+
82
+ constructor(parent: Logger, context: string) {
83
+ super()
84
+ this.#parent = parent
85
+ this.#context = context
86
+ }
87
+
88
+ write(entry: LogEntry): void {
89
+ this.#parent.write({ ...entry, context: this.#context })
90
+ }
91
+ }
92
+
93
+ /** A logger that writes nothing: `createApp(AppModule, { logger: false })`. */
94
+ export class SilentLogger extends Logger {
95
+ write(): void {}
96
+ }
@@ -34,7 +34,7 @@ export type Params = Record<string, string>
34
34
  *
35
35
  * `params` may be the very object Bun put on `req.params`: treat it as read-only.
36
36
  */
37
- export type Handler = (req: Request, params: Params) => Response | Promise<Response>
37
+ export type Handler = (req: Bun.BunRequest, params: Params) => Response | Promise<Response>
38
38
 
39
39
  /**
40
40
  * Route definition.