@getsoda/core 0.1.1 → 0.2.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 (115) 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 +29 -10
  5. package/dist/app/app.js.map +1 -1
  6. package/dist/di/container.d.ts +5 -0
  7. package/dist/di/container.d.ts.map +1 -1
  8. package/dist/di/container.js +15 -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 +7 -1
  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/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/jwt/algorithms.d.ts +19 -0
  55. package/dist/jwt/algorithms.d.ts.map +1 -0
  56. package/dist/jwt/algorithms.js +60 -0
  57. package/dist/jwt/algorithms.js.map +1 -0
  58. package/dist/jwt/errors.d.ts +24 -0
  59. package/dist/jwt/errors.d.ts.map +1 -0
  60. package/dist/jwt/errors.js +14 -0
  61. package/dist/jwt/errors.js.map +1 -0
  62. package/dist/jwt/guard.d.ts +30 -0
  63. package/dist/jwt/guard.d.ts.map +1 -0
  64. package/dist/jwt/guard.js +61 -0
  65. package/dist/jwt/guard.js.map +1 -0
  66. package/dist/jwt/index.d.ts +14 -0
  67. package/dist/jwt/index.d.ts.map +1 -0
  68. package/dist/jwt/index.js +10 -0
  69. package/dist/jwt/index.js.map +1 -0
  70. package/dist/jwt/module.d.ts +36 -0
  71. package/dist/jwt/module.d.ts.map +1 -0
  72. package/dist/jwt/module.js +38 -0
  73. package/dist/jwt/module.js.map +1 -0
  74. package/dist/jwt/service.d.ts +66 -0
  75. package/dist/jwt/service.d.ts.map +1 -0
  76. package/dist/jwt/service.js +138 -0
  77. package/dist/jwt/service.js.map +1 -0
  78. package/dist/logger/console-logger.d.ts +28 -0
  79. package/dist/logger/console-logger.d.ts.map +1 -0
  80. package/dist/logger/console-logger.js +87 -0
  81. package/dist/logger/console-logger.js.map +1 -0
  82. package/dist/logger/index.d.ts +9 -0
  83. package/dist/logger/index.d.ts.map +1 -0
  84. package/dist/logger/index.js +7 -0
  85. package/dist/logger/index.js.map +1 -0
  86. package/dist/logger/logger.d.ts +55 -0
  87. package/dist/logger/logger.d.ts.map +1 -0
  88. package/dist/logger/logger.js +66 -0
  89. package/dist/logger/logger.js.map +1 -0
  90. package/dist/routing/types.d.ts +1 -1
  91. package/dist/routing/types.d.ts.map +1 -1
  92. package/package.json +6 -12
  93. package/src/app/app.ts +53 -12
  94. package/src/di/container.ts +24 -2
  95. package/src/di/graph.ts +26 -11
  96. package/src/http/arguments.ts +42 -2
  97. package/src/http/body.ts +142 -1
  98. package/src/http/context-key.ts +35 -0
  99. package/src/http/context.ts +94 -8
  100. package/src/http/cookies.ts +96 -0
  101. package/src/http/cors.ts +143 -0
  102. package/src/http/index.ts +7 -1
  103. package/src/http/middleware.ts +124 -0
  104. package/src/http/parsers.ts +2 -1
  105. package/src/index.ts +1 -0
  106. package/src/jwt/algorithms.ts +113 -0
  107. package/src/jwt/errors.ts +30 -0
  108. package/src/jwt/guard.ts +74 -0
  109. package/src/jwt/index.ts +14 -0
  110. package/src/jwt/module.ts +58 -0
  111. package/src/jwt/service.ts +216 -0
  112. package/src/logger/console-logger.ts +119 -0
  113. package/src/logger/index.ts +9 -0
  114. package/src/logger/logger.ts +96 -0
  115. package/src/routing/types.ts +1 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @module
3
+ * `JwtGuard`: lets in requests carrying a valid token, the way
4
+ * `AuthGuard('jwt')` of `@nestjs/passport` does.
5
+ */
6
+
7
+ import { inject } from '../di/index.ts'
8
+ import type { Context } from '../http/context.ts'
9
+ import { ContextKey } from '../http/context-key.ts'
10
+ import type { CanActivate } from '../http/enhancers.ts'
11
+ import { UnauthorizedException } from '../http/exceptions.ts'
12
+ import { JwtError } from './errors.ts'
13
+ import { JWT_OPTIONS } from './module.ts'
14
+ import { JwtService } from './service.ts'
15
+ import type { JwtPayload } from './service.ts'
16
+
17
+ /** Payload of the token `JwtGuard` accepted: `ctx.get(JWT_PAYLOAD)` or `value(JWT_PAYLOAD)`. */
18
+ export const JWT_PAYLOAD = new ContextKey<JwtPayload>('jwtPayload')
19
+
20
+ const BEARER = /^Bearer +(\S+) *$/i
21
+
22
+ /** A 401 with the `WWW-Authenticate` challenge of RFC 6750. */
23
+ const unauthorized = (message: string, challenge: string, cause?: unknown): UnauthorizedException =>
24
+ new UnauthorizedException(message, { cause, headers: { 'www-authenticate': challenge } })
25
+
26
+ /**
27
+ * Accepts a request whose token `JwtService` verifies, read from
28
+ * `Authorization: Bearer <token>` or, without that header, from the cookie
29
+ * named by the `cookie` option of `JwtModule`. The payload goes to
30
+ * {@link JWT_PAYLOAD}; anything else is a `401`.
31
+ *
32
+ * ```ts
33
+ * @Get('me', [value(JWT_PAYLOAD)])
34
+ * @UseGuards(JwtGuard)
35
+ * me(payload: JwtPayload) {}
36
+ * ```
37
+ *
38
+ * Needs `JwtModule` imported in the controller's module, or registered as global.
39
+ */
40
+ export class JwtGuard implements CanActivate {
41
+ readonly #jwt = inject(JwtService)
42
+ readonly #cookie = inject(JWT_OPTIONS).cookie
43
+
44
+ async canActivate(ctx: Context): Promise<boolean> {
45
+ const header = ctx.header('authorization')
46
+ let token: string | undefined
47
+
48
+ if (header !== undefined) {
49
+ token = BEARER.exec(header)?.[1]
50
+
51
+ if (token === undefined) {
52
+ throw unauthorized('Authorization is not a bearer token', 'Bearer error="invalid_request"')
53
+ }
54
+ } else if (this.#cookie !== undefined) {
55
+ token = ctx.cookie(this.#cookie)
56
+ }
57
+
58
+ if (token === undefined) {
59
+ throw unauthorized('Missing token', 'Bearer')
60
+ }
61
+
62
+ try {
63
+ ctx.set(JWT_PAYLOAD, await this.#jwt.verify(token))
64
+ } catch (error) {
65
+ if (error instanceof JwtError) {
66
+ throw unauthorized(error.message, 'Bearer error="invalid_token"', error)
67
+ }
68
+
69
+ throw error
70
+ }
71
+
72
+ return true
73
+ }
74
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @module
3
+ * JSON Web Tokens: `JwtService` to sign and verify them, `JwtModule` to
4
+ * configure it, `JwtGuard` to protect routes with them.
5
+ */
6
+
7
+ export type { JwtAlgorithm, JwtKey } from './algorithms.ts'
8
+ export { JwtError } from './errors.ts'
9
+ export type { JwtErrorCode } from './errors.ts'
10
+ export { JWT_PAYLOAD, JwtGuard } from './guard.ts'
11
+ export { JWT_OPTIONS, JwtModule } from './module.ts'
12
+ export type { JwtModuleAsyncOptions, JwtModuleOptions } from './module.ts'
13
+ export { JwtService } from './service.ts'
14
+ export type { JwtOptions, JwtPayload, JwtSignOptions } from './service.ts'
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @module
3
+ * `JwtModule`: provides a configured `JwtService`, like `@nestjs/jwt`.
4
+ */
5
+
6
+ import { InjectionToken, Module, provide } from '../di/index.ts'
7
+ import type { Dependency, DependencyValues, DynamicModule, ModuleImport } from '../di/index.ts'
8
+ import { JwtService } from './service.ts'
9
+ import type { JwtOptions } from './service.ts'
10
+
11
+ export interface JwtModuleOptions extends JwtOptions {
12
+ /** Cookie `JwtGuard` reads the token from when the request has no `Authorization` header. */
13
+ readonly cookie?: string
14
+ }
15
+
16
+ export interface JwtModuleAsyncOptions<D extends readonly Dependency[]> {
17
+ /** Modules providing what `inject` names. */
18
+ readonly imports?: readonly ModuleImport[]
19
+ readonly inject?: D
20
+ readonly useFactory: (...args: DependencyValues<D>) => JwtModuleOptions | Promise<JwtModuleOptions>
21
+ /** Makes `JwtService` visible in every module. */
22
+ readonly global?: boolean
23
+ }
24
+
25
+ /** Options the module was registered with. */
26
+ export const JWT_OPTIONS = new InjectionToken<JwtModuleOptions>('JWT_OPTIONS')
27
+
28
+ const service = () => provide(JwtService, { inject: [JWT_OPTIONS], useFactory: (options) => new JwtService(options) })
29
+
30
+ /**
31
+ * ```ts
32
+ * @Module({ imports: [JwtModule.register({ secret: process.env.JWT_SECRET!, expiresIn: 900, global: true })] })
33
+ * class AppModule {}
34
+ *
35
+ * JwtModule.registerAsync({ inject: [ConfigService], useFactory: (config) => ({ secret: config.get('JWT_SECRET') }) })
36
+ * ```
37
+ */
38
+ @Module({})
39
+ export class JwtModule {
40
+ static register(options: JwtModuleOptions & { readonly global?: boolean }): DynamicModule {
41
+ return {
42
+ module: JwtModule,
43
+ global: options.global ?? false,
44
+ providers: [provide(JWT_OPTIONS, { useValue: options }), service()],
45
+ exports: [JwtService, JWT_OPTIONS],
46
+ }
47
+ }
48
+
49
+ static registerAsync<const D extends readonly Dependency[] = []>(options: JwtModuleAsyncOptions<D>): DynamicModule {
50
+ return {
51
+ module: JwtModule,
52
+ global: options.global ?? false,
53
+ imports: options.imports ?? [],
54
+ providers: [provide<JwtModuleOptions, D>(JWT_OPTIONS, { inject: options.inject, useFactory: options.useFactory }), service()],
55
+ exports: [JwtService, JWT_OPTIONS],
56
+ }
57
+ }
58
+ }
@@ -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,119 @@
1
+ /**
2
+ * @module
3
+ * Logger writing to stdout and stderr, readable for people or as JSON lines.
4
+ *
5
+ * ```
6
+ * 2026-11-13T17:20:03.120Z LOG [UsersService] User created {"id":7}
7
+ * {"time":"2026-11-13T17:20:03.120Z","level":"log","context":"UsersService","message":"User created","id":7}
8
+ * ```
9
+ *
10
+ * Like Nest's `ConsoleLogger`: a minimum level, a JSON mode for log
11
+ * collectors; `error` and `fatal` go to stderr, the rest to stdout.
12
+ */
13
+
14
+ import { Logger } from './logger.ts'
15
+ import type { LogEntry, LogLevel } from './logger.ts'
16
+
17
+ export interface ConsoleLoggerOptions {
18
+ /** Least severe level written. Defaults to `log`. */
19
+ readonly level?: LogLevel
20
+ /** One JSON object per line, for log collectors. Defaults to `false`. */
21
+ readonly json?: boolean
22
+ /** Where lines go. Defaults to stderr for `error` and `fatal`, stdout otherwise. */
23
+ readonly output?: (line: string, level: LogLevel) => void
24
+ }
25
+
26
+ const SEVERITY: Readonly<Record<LogLevel, number>> = { verbose: 0, debug: 1, log: 2, warn: 3, error: 4, fatal: 5 }
27
+
28
+ const writeToConsole = (line: string, level: LogLevel): void => {
29
+ const stream = level === 'error' || level === 'fatal' ? process.stderr : process.stdout
30
+
31
+ stream.write(`${line}\n`)
32
+ }
33
+
34
+ const serializeError = (error: Error): Record<string, unknown> => {
35
+ const serialized: Record<string, unknown> = { name: error.name, message: error.message, stack: error.stack }
36
+
37
+ if (error.cause !== undefined) {
38
+ serialized.cause = error.cause instanceof Error ? serializeError(error.cause) : error.cause
39
+ }
40
+
41
+ return serialized
42
+ }
43
+
44
+ /**
45
+ * `JSON.stringify` that never throws: bigints become strings, an object met a
46
+ * second time (a cycle) becomes `"[Circular]"`. Errors are serialized in full
47
+ * (stack and cause) for JSON lines, or as `Name: message` when their stack is
48
+ * printed on its own lines.
49
+ */
50
+ const stringify = (value: unknown, errors: 'full' | 'brief'): string => {
51
+ const seen = new WeakSet<object>()
52
+
53
+ return JSON.stringify(value, (_key, current: unknown) => {
54
+ if (typeof current === 'bigint') {
55
+ return current.toString()
56
+ }
57
+
58
+ if (current instanceof Error) {
59
+ return errors === 'full' ? serializeError(current) : `${current.name}: ${current.message}`
60
+ }
61
+
62
+ if (typeof current === 'object' && current !== null) {
63
+ if (seen.has(current)) {
64
+ return '[Circular]'
65
+ }
66
+
67
+ seen.add(current)
68
+ }
69
+
70
+ return current
71
+ })
72
+ }
73
+
74
+ export class ConsoleLogger extends Logger {
75
+ readonly #minimum: number
76
+ readonly #json: boolean
77
+ readonly #output: (line: string, level: LogLevel) => void
78
+
79
+ constructor(options: ConsoleLoggerOptions = {}) {
80
+ super()
81
+ this.#minimum = SEVERITY[options.level ?? 'log']
82
+ this.#json = options.json === true
83
+ this.#output = options.output ?? writeToConsole
84
+ }
85
+
86
+ write(entry: LogEntry): void {
87
+ if (SEVERITY[entry.level] < this.#minimum) {
88
+ return
89
+ }
90
+
91
+ const time = new Date().toISOString()
92
+
93
+ this.#output(this.#json ? this.#formatJson(entry, time) : this.#formatText(entry, time), entry.level)
94
+ }
95
+
96
+ #formatJson(entry: LogEntry, time: string): string {
97
+ // Fixed fields last: data cannot overwrite them.
98
+ return stringify({ ...entry.data, time, level: entry.level, context: entry.context, message: entry.message }, 'full')
99
+ }
100
+
101
+ #formatText(entry: LogEntry, time: string): string {
102
+ const context = entry.context === undefined ? '' : `[${entry.context}] `
103
+ let line = `${time} ${entry.level.toUpperCase().padEnd(7)} ${context}${entry.message}`
104
+ let stacks = ''
105
+
106
+ if (entry.data !== undefined) {
107
+ line += ` ${stringify(entry.data, 'brief')}`
108
+
109
+ // Stacks are read better on their own lines than inside the JSON.
110
+ for (const value of Object.values(entry.data)) {
111
+ if (value instanceof Error && value.stack !== undefined) {
112
+ stacks += `\n${value.stack}`
113
+ }
114
+ }
115
+ }
116
+
117
+ return line + stacks
118
+ }
119
+ }
@@ -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.