@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.
- package/README.md +58 -0
- package/dist/app/app.d.ts +17 -1
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +46 -10
- package/dist/app/app.js.map +1 -1
- package/dist/di/container.d.ts +7 -0
- package/dist/di/container.d.ts.map +1 -1
- package/dist/di/container.js +24 -1
- package/dist/di/container.js.map +1 -1
- package/dist/di/graph.d.ts +2 -2
- package/dist/di/graph.d.ts.map +1 -1
- package/dist/di/graph.js +20 -10
- package/dist/di/graph.js.map +1 -1
- package/dist/http/arguments.d.ts +20 -2
- package/dist/http/arguments.d.ts.map +1 -1
- package/dist/http/arguments.js +29 -0
- package/dist/http/arguments.js.map +1 -1
- package/dist/http/body.d.ts +28 -0
- package/dist/http/body.d.ts.map +1 -1
- package/dist/http/body.js +96 -1
- package/dist/http/body.js.map +1 -1
- package/dist/http/context-key.d.ts +33 -0
- package/dist/http/context-key.d.ts.map +1 -0
- package/dist/http/context-key.js +37 -0
- package/dist/http/context-key.js.map +1 -0
- package/dist/http/context.d.ts +37 -3
- package/dist/http/context.d.ts.map +1 -1
- package/dist/http/context.js +63 -5
- package/dist/http/context.js.map +1 -1
- package/dist/http/cookies.d.ts +49 -0
- package/dist/http/cookies.d.ts.map +1 -0
- package/dist/http/cookies.js +77 -0
- package/dist/http/cookies.js.map +1 -0
- package/dist/http/cors.d.ts +41 -0
- package/dist/http/cors.d.ts.map +1 -0
- package/dist/http/cors.js +97 -0
- package/dist/http/cors.js.map +1 -0
- package/dist/http/index.d.ts +8 -2
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +4 -1
- package/dist/http/index.js.map +1 -1
- package/dist/http/middleware.d.ts +56 -0
- package/dist/http/middleware.d.ts.map +1 -0
- package/dist/http/middleware.js +83 -0
- package/dist/http/middleware.js.map +1 -0
- package/dist/http/parsers.d.ts +3 -0
- package/dist/http/parsers.d.ts.map +1 -1
- package/dist/http/parsers.js +2 -1
- package/dist/http/parsers.js.map +1 -1
- package/dist/http/routes.d.ts +10 -1
- package/dist/http/routes.d.ts.map +1 -1
- package/dist/http/routes.js +4 -1
- package/dist/http/routes.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jwt/algorithms.d.ts +19 -0
- package/dist/jwt/algorithms.d.ts.map +1 -0
- package/dist/jwt/algorithms.js +60 -0
- package/dist/jwt/algorithms.js.map +1 -0
- package/dist/jwt/errors.d.ts +24 -0
- package/dist/jwt/errors.d.ts.map +1 -0
- package/dist/jwt/errors.js +14 -0
- package/dist/jwt/errors.js.map +1 -0
- package/dist/jwt/guard.d.ts +30 -0
- package/dist/jwt/guard.d.ts.map +1 -0
- package/dist/jwt/guard.js +61 -0
- package/dist/jwt/guard.js.map +1 -0
- package/dist/jwt/index.d.ts +14 -0
- package/dist/jwt/index.d.ts.map +1 -0
- package/dist/jwt/index.js +10 -0
- package/dist/jwt/index.js.map +1 -0
- package/dist/jwt/module.d.ts +36 -0
- package/dist/jwt/module.d.ts.map +1 -0
- package/dist/jwt/module.js +38 -0
- package/dist/jwt/module.js.map +1 -0
- package/dist/jwt/service.d.ts +66 -0
- package/dist/jwt/service.d.ts.map +1 -0
- package/dist/jwt/service.js +138 -0
- package/dist/jwt/service.js.map +1 -0
- package/dist/logger/console-logger.d.ts +36 -0
- package/dist/logger/console-logger.d.ts.map +1 -0
- package/dist/logger/console-logger.js +117 -0
- package/dist/logger/console-logger.js.map +1 -0
- package/dist/logger/index.d.ts +9 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +7 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/logger.d.ts +55 -0
- package/dist/logger/logger.d.ts.map +1 -0
- package/dist/logger/logger.js +66 -0
- package/dist/logger/logger.js.map +1 -0
- package/dist/routing/types.d.ts +1 -1
- package/dist/routing/types.d.ts.map +1 -1
- package/package.json +6 -12
- package/src/app/app.ts +77 -13
- package/src/di/container.ts +38 -2
- package/src/di/graph.ts +26 -11
- package/src/http/arguments.ts +42 -2
- package/src/http/body.ts +142 -1
- package/src/http/context-key.ts +35 -0
- package/src/http/context.ts +94 -8
- package/src/http/cookies.ts +96 -0
- package/src/http/cors.ts +143 -0
- package/src/http/index.ts +8 -2
- package/src/http/middleware.ts +124 -0
- package/src/http/parsers.ts +2 -1
- package/src/http/routes.ts +17 -2
- package/src/index.ts +1 -0
- package/src/jwt/algorithms.ts +113 -0
- package/src/jwt/errors.ts +30 -0
- package/src/jwt/guard.ts +74 -0
- package/src/jwt/index.ts +14 -0
- package/src/jwt/module.ts +58 -0
- package/src/jwt/service.ts +216 -0
- package/src/logger/console-logger.ts +163 -0
- package/src/logger/index.ts +9 -0
- package/src/logger/logger.ts +96 -0
- 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
|
+
}
|
package/src/routing/types.ts
CHANGED
|
@@ -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:
|
|
37
|
+
export type Handler = (req: Bun.BunRequest, params: Params) => Response | Promise<Response>
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Route definition.
|