galbe 0.15.6 → 0.16.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.
@@ -0,0 +1,455 @@
1
+ import type { MiddlewareDef, PreParseContext } from '../types'
2
+ import type { STOptional, STString } from '../schema'
3
+ import type { AuthErrorHandler } from './_auth'
4
+
5
+ import { $T } from '../index'
6
+ import { AuthError, authHook, bearerChallenge, checkRealm, readCredential, securityMetadata } from './_auth'
7
+
8
+ export { AuthError } from './_auth'
9
+
10
+ // `none` is deliberately absent, and nothing outside this list is ever accepted.
11
+ const ALGORITHMS = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'] as const
12
+ /** JWS algorithms verifiable on `crypto.subtle`. */
13
+ export type JwtAlgorithm = (typeof ALGORITHMS)[number]
14
+ /** A JSON Web Key, structurally — `lib.dom` is not loaded and its `JsonWebKey` with it. */
15
+ export type JwtJsonWebKey = { kty?: string; alg?: string; crv?: string } & Record<string, any>
16
+ /**
17
+ * Verification key: an HMAC secret (string or bytes), a PEM-encoded SPKI public
18
+ * key, a JWK, or an already imported {@link CryptoKey}.
19
+ */
20
+ export type JwtKey = string | Uint8Array | ArrayBuffer | JwtJsonWebKey | CryptoKey
21
+ /** Where a token is read from: the `Authorization: Bearer` header, or a named cookie. */
22
+ export type JwtSource = 'bearer' | `cookie:${string}`
23
+ /** Decoded token payload — registered claims typed, everything else passed through. */
24
+ export type JwtPayload = {
25
+ iss?: string
26
+ sub?: string
27
+ aud?: string | string[]
28
+ exp?: number
29
+ nbf?: number
30
+ iat?: number
31
+ jti?: string
32
+ } & Record<string, any>
33
+ /**
34
+ * Why verification failed — the three {@link AuthError} codes plus everything
35
+ * specific to a signed token. Available to {@link JwtConfig.errorHandler};
36
+ * never sent to the client.
37
+ */
38
+ export type JwtErrorCode =
39
+ | 'missing'
40
+ | 'malformed'
41
+ | 'algorithm'
42
+ | 'signature'
43
+ | 'expired'
44
+ | 'immature'
45
+ | 'issuer'
46
+ | 'audience'
47
+ | 'subject'
48
+ | 'invalid'
49
+
50
+ /** Thrown internally on every rejection, and handed to {@link JwtConfig.errorHandler}. */
51
+ export class JwtError extends AuthError<JwtErrorCode> {
52
+ constructor(code: JwtErrorCode, message: string) {
53
+ super(code, message)
54
+ this.name = 'JwtError'
55
+ }
56
+ }
57
+
58
+ export type JwtConfig = {
59
+ /**
60
+ * Key the signature is verified against: the HMAC secret for `HS*`, the
61
+ * public key for `RS*`/`ES*`. Accepts a PEM SPKI string, a JWK, raw bytes or
62
+ * a {@link CryptoKey}. Imported once, on the first request.
63
+ */
64
+ key: JwtKey
65
+ /**
66
+ * Algorithms accepted, narrowing what the key already allows. Defaults to
67
+ * every algorithm of the key's family (`HS256`/`384`/`512` for a secret,
68
+ * `RS*` for an RSA key, the curve's `ES*` for an EC key). A token's own `alg`
69
+ * header never widens this set.
70
+ */
71
+ algorithms?: JwtAlgorithm[]
72
+ /** Where to look for the token, in order. Default `['bearer']`. */
73
+ sources?: JwtSource[]
74
+ /** `ctx.state` key the verified payload is stored under. Default `jwtPayload`. */
75
+ stateHolder?: string
76
+ /**
77
+ * Lets a request carrying **no** token through unauthenticated instead of
78
+ * answering 401 — the public half of a route that personalizes a signed-in
79
+ * caller. A token that is present but fails any check is still rejected.
80
+ */
81
+ optional?: boolean
82
+ /** Required `iss` claim — one value or a list of accepted ones. */
83
+ issuer?: string | string[]
84
+ /** Required `aud` claim: at least one of these must match the token's audience. */
85
+ audience?: string | string[]
86
+ /** Required `sub` claim. */
87
+ subject?: string
88
+ /** Seconds of clock skew tolerated on `exp` and `nbf`. Default `0`. */
89
+ clockTolerance?: number
90
+ /** Extra payload check run after the standard claims. Returning `false` rejects the request. */
91
+ validate?: (payload: JwtPayload, ctx: PreParseContext) => boolean | Promise<boolean>
92
+ /** Protection space named in the `WWW-Authenticate` challenge. Omitted by default. */
93
+ realm?: string
94
+ /**
95
+ * Replaces the default rejection. Return a `Response` to answer the request,
96
+ * or nothing to fall back to the default `401`; throwing takes the usual
97
+ * error handler path. Optional authentication is {@link JwtConfig.optional},
98
+ * not something an error handler expresses.
99
+ */
100
+ errorHandler?: AuthErrorHandler<JwtError>
101
+ /**
102
+ * Name of the OpenAPI security scheme contributed — `bearerAuth` for the
103
+ * bearer source, `cookieAuth` for a cookie one. Rename it when two instances
104
+ * coexist in one app; `false` emits no security metadata at all.
105
+ */
106
+ securityScheme?: string | false
107
+ }
108
+
109
+ /** The header contract a bearer-sourced instance imposes on every route it matches. */
110
+ export type JwtFragment = { headers: { authorization: STOptional<STString> } }
111
+
112
+ const encoder = new TextEncoder()
113
+ const decoder = new TextDecoder()
114
+ const HASHES = { '256': 'SHA-256', '384': 'SHA-384', '512': 'SHA-512' } as const
115
+ const CURVES = { '256': 'P-256', '384': 'P-384', '512': 'P-521' } as const
116
+ const PEM = /-----BEGIN ([A-Z ]+)-----([\s\S]+?)-----END/
117
+ const family = (alg: JwtAlgorithm) => alg.slice(0, 2) as 'HS' | 'RS' | 'ES'
118
+ const bits = (alg: JwtAlgorithm) => alg.slice(2) as keyof typeof HASHES
119
+
120
+ const b64ToBytes = (b64: string) => {
121
+ const bin = atob(b64.replace(/\s+/g, ''))
122
+ const bytes = new Uint8Array(bin.length)
123
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
124
+ return bytes
125
+ }
126
+ const b64uToBytes = (b64u: string) => b64ToBytes(b64u.replace(/-/g, '+').replace(/_/g, '/'))
127
+ const bytesToB64u = (bytes: Uint8Array) => {
128
+ let bin = ''
129
+ for (const byte of bytes) bin += String.fromCharCode(byte)
130
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
131
+ }
132
+ const strToB64u = (str: string) => bytesToB64u(encoder.encode(str))
133
+
134
+ // The curve is a property of an EC key, the hash a property of the operation;
135
+ // for HMAC and RSA both are fixed at import time.
136
+ const importParams = (alg: JwtAlgorithm) =>
137
+ family(alg) === 'HS'
138
+ ? { name: 'HMAC', hash: HASHES[bits(alg)] }
139
+ : family(alg) === 'RS'
140
+ ? { name: 'RSASSA-PKCS1-v1_5', hash: HASHES[bits(alg)] }
141
+ : { name: 'ECDSA', namedCurve: CURVES[bits(alg)] }
142
+ // what `crypto.subtle.sign`/`verify` take, as opposed to what the import takes
143
+ const operationParams = (alg: JwtAlgorithm) =>
144
+ family(alg) === 'ES' ? { name: 'ECDSA', hash: HASHES[bits(alg)] } : importParams(alg)
145
+
146
+ const isJwk = (key: JwtKey): key is JwtJsonWebKey =>
147
+ typeof key === 'object' && !(key instanceof CryptoKey) && !ArrayBuffer.isView(key) && !(key instanceof ArrayBuffer)
148
+
149
+ type KeyUsage = 'verify' | 'sign'
150
+ const importKey = (key: Exclude<JwtKey, CryptoKey>, alg: JwtAlgorithm, usage: KeyUsage): Promise<CryptoKey> => {
151
+ const params = importParams(alg)
152
+ if (isJwk(key)) return crypto.subtle.importKey('jwk', key, params, false, [usage])
153
+ // an HMAC secret is the raw bytes; an asymmetric key is a DER structure —
154
+ // SPKI for the public half, PKCS#8 for the private one, which is what a PEM
155
+ // label says outright and what the usage implies for bare bytes
156
+ const [, label, body] = typeof key === 'string' ? (key.match(PEM) ?? []) : []
157
+ if (label !== undefined) {
158
+ if (!/^(PUBLIC|PRIVATE) KEY$/.test(label))
159
+ throw new Error(`jwt: unsupported PEM '${label}', expected 'PUBLIC KEY' or 'PRIVATE KEY' (PKCS#8)`)
160
+ return crypto.subtle.importKey(label === 'PRIVATE KEY' ? 'pkcs8' : 'spki', b64ToBytes(body ?? ''), params, false, [
161
+ usage,
162
+ ])
163
+ }
164
+ const der = usage === 'sign' ? 'pkcs8' : 'spki'
165
+ const bytes = typeof key === 'string' ? encoder.encode(key) : key
166
+ return crypto.subtle.importKey(family(alg) === 'HS' ? 'raw' : der, bytes, params, false, [usage])
167
+ }
168
+
169
+ /**
170
+ * Which algorithms the key itself can serve. The `alg` header of an incoming
171
+ * token is attacker-controlled — the key decides what is acceptable, never the
172
+ * token, which is what closes the classic RS256→HS256 confusion.
173
+ */
174
+ const keyAlgorithms = async (key: JwtKey, usage: KeyUsage): Promise<JwtAlgorithm[]> => {
175
+ const of = (fam: 'HS' | 'RS', hash?: string) =>
176
+ hash
177
+ ? ([`${fam}${hash.slice(4)}`] as JwtAlgorithm[])
178
+ : (['256', '384', '512'].map(b => `${fam}${b}`) as JwtAlgorithm[])
179
+ const ec = (crv?: string) => [`ES${crv === 'P-384' ? '384' : crv === 'P-521' ? '512' : '256'}`] as JwtAlgorithm[]
180
+ if (key instanceof CryptoKey) {
181
+ const { name } = key.algorithm
182
+ const { hash, namedCurve } = key.algorithm as { hash?: { name?: string }; namedCurve?: string }
183
+ if (name === 'HMAC') return of('HS', hash?.name)
184
+ if (name === 'RSASSA-PKCS1-v1_5') return of('RS', hash?.name)
185
+ if (name === 'ECDSA') return ec(namedCurve)
186
+ throw new Error(`jwt: unsupported key algorithm '${name}'`)
187
+ }
188
+ if (isJwk(key)) {
189
+ if (key.alg) return [key.alg as JwtAlgorithm]
190
+ if (key.kty === 'RSA') return of('RS')
191
+ if (key.kty === 'EC') return ec(key.crv)
192
+ return of('HS')
193
+ }
194
+ // A PEM says nothing about the algorithm, so the platform's key parser
195
+ // answers instead: whichever import succeeds identifies the key's family.
196
+ if (typeof key === 'string' && PEM.test(key)) {
197
+ for (const alg of ['RS256', 'ES256', 'ES384', 'ES512'] as JwtAlgorithm[]) {
198
+ try {
199
+ await importKey(key, alg, usage)
200
+ return family(alg) === 'RS' ? of('RS') : [alg]
201
+ } catch {}
202
+ }
203
+ throw new Error('jwt: the PEM key is not a supported RSA or EC key')
204
+ }
205
+ return of('HS')
206
+ }
207
+
208
+ // Keys and their algorithm set are resolved lazily, then cached per algorithm:
209
+ // the middleware factory is synchronous and importing is not.
210
+ const keyStore = (key: JwtKey, restrict: JwtAlgorithm[] | undefined, usage: KeyUsage) => {
211
+ const keys = new Map<JwtAlgorithm, Promise<CryptoKey>>()
212
+ let algorithms: Promise<JwtAlgorithm[]> | undefined
213
+ return {
214
+ algorithms: () =>
215
+ (algorithms ??= keyAlgorithms(key, usage).then(detected => {
216
+ // a key can carry an algorithm this middleware does not verify (an
217
+ // HMAC-SHA1 CryptoKey, a JWK with an exotic `alg`): drop those here
218
+ const supported = detected.filter(a => ALGORITHMS.includes(a))
219
+ const algs = restrict ? restrict.filter(a => supported.includes(a)) : supported
220
+ if (!algs.length)
221
+ throw new Error(`jwt: no supported algorithm for the configured key${restrict ? ` among [${restrict}]` : ''}`)
222
+ return algs
223
+ })),
224
+ key: (alg: JwtAlgorithm) => {
225
+ let imported = keys.get(alg)
226
+ if (!imported)
227
+ keys.set(alg, (imported = key instanceof CryptoKey ? Promise.resolve(key) : importKey(key, alg, usage)))
228
+ return imported
229
+ },
230
+ }
231
+ }
232
+
233
+ /**
234
+ * #### jwt
235
+ * Verify-only JWT middleware. Reads a token from the `Authorization: Bearer`
236
+ * header (or a cookie), verifies its signature on `crypto.subtle`, checks the
237
+ * standard claims and puts the payload on `ctx.state.jwtPayload`.
238
+ *
239
+ * It runs in the [`beforeParse`](https://galbe.dev/documentation/middleware#before-parsing)
240
+ * slot, so an unauthenticated request is answered **401 before its body is
241
+ * read** — never a 400 that would leak the route's schema. Rejections are plain
242
+ * `401`s with a `WWW-Authenticate` challenge; the reason stays server-side, in
243
+ * the {@link JwtError} handed to `errorHandler`.
244
+ *
245
+ * The def also carries what the request contract and the spec need: matched
246
+ * routes gain an optional `authorization` header (`format: JWT`) and a
247
+ * `bearerAuth` security requirement.
248
+ *
249
+ * > Verification only — issuing tokens is not a request-time concern. Use
250
+ * > {@link signJwt} on the endpoint that hands them out.
251
+ *
252
+ * ---
253
+ * @example
254
+ * ```typescript
255
+ * import { jwt } from 'galbe/middlewares'
256
+ *
257
+ * // every /api route, verified with an HMAC secret
258
+ * galbe.middleware('/api/*', jwt({ key: Bun.env.JWT_SECRET! }))
259
+ *
260
+ * // the public key of an RS256 signer, issuer pinned, admin role required
261
+ * galbe.middleware('/admin/*', jwt({
262
+ * key: await Bun.file('public.pem').text(),
263
+ * issuer: 'https://auth.example.com',
264
+ * audience: 'my-api',
265
+ * validate: payload => payload.role === 'admin'
266
+ * }))
267
+ *
268
+ * // a token is welcome but not required
269
+ * galbe.middleware('/feed/*', jwt({ key: Bun.env.JWT_SECRET!, optional: true }))
270
+ *
271
+ * galbe.get('/api/me', ctx => ctx.state.jwtPayload.sub)
272
+ * ```
273
+ * @param config - see {@link JwtConfig}
274
+ */
275
+ export const jwt = (config: JwtConfig): MiddlewareDef<JwtFragment> => {
276
+ const sources = config.sources ?? ['bearer']
277
+ const stateHolder = config.stateHolder ?? 'jwtPayload'
278
+ const issuers = config.issuer === undefined ? undefined : [config.issuer].flat()
279
+ const audiences = config.audience === undefined ? undefined : [config.audience].flat()
280
+ const tolerance = config.clockTolerance ?? 0
281
+ const store = keyStore(config.key, config.algorithms, 'verify')
282
+ for (const source of sources)
283
+ if (source !== 'bearer' && !source.startsWith('cookie:'))
284
+ throw new SyntaxError(`jwt: invalid source '${source}', expected 'bearer' or 'cookie:<name>'`)
285
+ const realm = checkRealm('jwt', config.realm)
286
+ const bearer = sources.includes('bearer')
287
+
288
+ const read = (ctx: PreParseContext) => {
289
+ for (const source of sources) {
290
+ const token =
291
+ source === 'bearer'
292
+ ? readCredential(ctx, 'header', 'authorization', 'Bearer ')
293
+ : readCredential(ctx, 'cookie', source.slice(7))
294
+ if (token) return token
295
+ }
296
+ }
297
+
298
+ const verify = async (token: string): Promise<JwtPayload> => {
299
+ const [head, body, sig, ...rest] = token.split('.')
300
+ if (sig === undefined || rest.length) throw new JwtError('malformed', 'token is not a JWS compact serialization')
301
+ let header: any, payload: any, signature: Uint8Array
302
+ try {
303
+ header = JSON.parse(decoder.decode(b64uToBytes(head!)))
304
+ payload = JSON.parse(decoder.decode(b64uToBytes(body!)))
305
+ signature = b64uToBytes(sig)
306
+ } catch {
307
+ throw new JwtError('malformed', 'token header, payload or signature is not decodable')
308
+ }
309
+ const alg = header?.alg
310
+ const algorithms = await store.algorithms()
311
+ if (!algorithms.includes(alg)) throw new JwtError('algorithm', `algorithm '${alg}' is not accepted`)
312
+ const verified = await crypto.subtle.verify(
313
+ operationParams(alg),
314
+ await store.key(alg),
315
+ signature,
316
+ encoder.encode(`${head}.${body}`)
317
+ )
318
+ if (!verified) throw new JwtError('signature', 'signature does not match')
319
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
320
+ throw new JwtError('malformed', 'payload is not a JSON object')
321
+ return payload
322
+ }
323
+
324
+ const checkClaims = (payload: JwtPayload) => {
325
+ const now = Date.now() / 1000
326
+ if (payload.exp !== undefined && !(now - tolerance < payload.exp))
327
+ throw new JwtError('expired', 'token has expired')
328
+ if (payload.nbf !== undefined && !(now + tolerance >= payload.nbf))
329
+ throw new JwtError('immature', 'token is not valid yet')
330
+ if (issuers && !issuers.includes(payload.iss!)) throw new JwtError('issuer', `unexpected issuer '${payload.iss}'`)
331
+ if (audiences && ![payload.aud ?? []].flat().some(a => audiences.includes(a)))
332
+ throw new JwtError('audience', `unexpected audience '${payload.aud}'`)
333
+ if (config.subject !== undefined && payload.sub !== config.subject)
334
+ throw new JwtError('subject', `unexpected subject '${payload.sub}'`)
335
+ }
336
+
337
+ const beforeParse = authHook(
338
+ async ctx => {
339
+ const token = read(ctx)
340
+ if (!token) {
341
+ // optional authentication: nothing to verify is not a rejection
342
+ if (config.optional) return
343
+ throw new JwtError('missing', 'no token found in the request')
344
+ }
345
+ const payload = await verify(token)
346
+ checkClaims(payload)
347
+ if (config.validate && !(await config.validate(payload, ctx)))
348
+ throw new JwtError('invalid', 'payload rejected by validate()')
349
+ ctx.state[stateHolder] = payload
350
+ },
351
+ {
352
+ errorHandler: config.errorHandler,
353
+ // a cookie-sourced token has no challenge to answer with
354
+ challenge: bearer ? error => bearerChallenge(realm, error.code) : undefined,
355
+ }
356
+ )
357
+
358
+ // One scheme per source, so a cookie-sourced token documents as the cookie it is
359
+ const { security, securitySchemes } = securityMetadata(
360
+ sources.map(source =>
361
+ source === 'bearer'
362
+ ? { name: 'bearerAuth', scheme: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } as const }
363
+ : { name: 'cookieAuth', scheme: { type: 'apiKey', in: 'cookie', name: source.slice(7) } as const }
364
+ ),
365
+ config.securityScheme
366
+ )
367
+
368
+ // the pattern is case-insensitive because the scheme name is (RFC 9110 §11.1)
369
+ // and the hook reads it that way: a fragment that rejected `bearer <token>`
370
+ // would 400 a request its own middleware just authenticated
371
+ const authorization = $T.optional($T.string({ pattern: /^Bearer /i, format: 'JWT' }))
372
+ return {
373
+ beforeParse,
374
+ ...(bearer ? { schema: { headers: { authorization } } } : {}),
375
+ ...(security.length ? { security, securitySchemes } : {}),
376
+ }
377
+ }
378
+
379
+ export type JwtSignOptions = {
380
+ /**
381
+ * Algorithm to sign with. Defaults to the key's own — `HS256` for a secret,
382
+ * `RS256` for an RSA key, the curve's `ES*` for an EC one.
383
+ */
384
+ algorithm?: JwtAlgorithm
385
+ /** Lifetime in seconds, from now: sets `exp`. */
386
+ expiresIn?: number
387
+ /** Delay in seconds, from now, before the token becomes valid: sets `nbf`. */
388
+ notBefore?: number
389
+ /** Sets the `iss` claim. */
390
+ issuer?: string
391
+ /** Sets the `aud` claim. */
392
+ audience?: string | string[]
393
+ /** Sets the `sub` claim. */
394
+ subject?: string
395
+ /** Extra JOSE header fields, `kid` typically. `alg` is always the one signed with. */
396
+ header?: Record<string, any>
397
+ }
398
+
399
+ /**
400
+ * #### signJwt
401
+ * Sign a payload into a compact JWS token — the counterpart of {@link jwt},
402
+ * kept out of the middleware so a request path never holds a signing key.
403
+ * Signs on `crypto.subtle`, with the same key forms and algorithms `jwt`
404
+ * verifies.
405
+ *
406
+ * `iat` is set automatically, and the option shorthands (`expiresIn`,
407
+ * `issuer`, ...) fill their claims; a claim written in `payload` always wins
408
+ * over the option that would have set it.
409
+ *
410
+ * > Import the key once — `crypto.subtle.importKey(...)` — and pass the
411
+ * > `CryptoKey` when signing on a hot path: every other key form is imported
412
+ * > per call.
413
+ *
414
+ * ---
415
+ * @example
416
+ * ```typescript
417
+ * import { signJwt } from 'galbe/middlewares'
418
+ *
419
+ * galbe.post('/login', async ctx => {
420
+ * const user = await authenticate(ctx.body)
421
+ * return { token: await signJwt({ sub: user.id, role: user.role }, Bun.env.JWT_SECRET!, {
422
+ * expiresIn: 3600,
423
+ * issuer: 'https://auth.example.com'
424
+ * }) }
425
+ * })
426
+ * ```
427
+ * @param payload - claims to sign
428
+ * @param privateKey - HMAC secret, PKCS#8 PEM private key, JWK or `CryptoKey`
429
+ * @param options - see {@link JwtSignOptions}
430
+ */
431
+ export const signJwt = async (
432
+ payload: JwtPayload,
433
+ privateKey: JwtKey,
434
+ options: JwtSignOptions = {}
435
+ ): Promise<string> => {
436
+ if (privateKey instanceof CryptoKey && !privateKey.usages.includes('sign'))
437
+ throw new Error("jwt: the CryptoKey given to signJwt() was not imported with the 'sign' usage")
438
+ const store = keyStore(privateKey, options.algorithm && [options.algorithm], 'sign')
439
+ const [alg] = await store.algorithms()
440
+ const now = Math.floor(Date.now() / 1000)
441
+ const claims: JwtPayload = { iat: now }
442
+ if (options.expiresIn !== undefined) claims.exp = now + options.expiresIn
443
+ if (options.notBefore !== undefined) claims.nbf = now + options.notBefore
444
+ if (options.issuer !== undefined) claims.iss = options.issuer
445
+ if (options.audience !== undefined) claims.aud = options.audience
446
+ if (options.subject !== undefined) claims.sub = options.subject
447
+ const head = strToB64u(JSON.stringify({ typ: 'JWT', ...options.header, alg: alg! }))
448
+ const body = strToB64u(JSON.stringify({ ...claims, ...payload }))
449
+ const signature = await crypto.subtle.sign(
450
+ operationParams(alg!),
451
+ await store.key(alg!),
452
+ encoder.encode(`${head}.${body}`)
453
+ )
454
+ return `${head}.${body}.${bytesToB64u(new Uint8Array(signature))}`
455
+ }
@@ -0,0 +1,120 @@
1
+ import type { Context, MiddlewareDef, PreParseHook, ResponseHook } from '../types'
2
+
3
+ import { METHOD_COLOR } from '../util'
4
+
5
+ /** One finished request, as the logger saw it. */
6
+ export type LogEntry = {
7
+ method: string
8
+ /** Request path, **without** the query string — see {@link LoggerConfig.log}. */
9
+ path: string
10
+ status: number
11
+ /** Milliseconds from the pre-parse slot to the parsed response — body parsing and validation included. */
12
+ duration: number
13
+ /** Whatever `requestId` left on the state, when it is registered ahead of this. */
14
+ requestId?: string
15
+ /** The error the request ended on, when it ended on one. */
16
+ error?: unknown
17
+ }
18
+
19
+ export type LoggerConfig = {
20
+ /**
21
+ * Receives every finished request instead of the default console line — the
22
+ * hook into your own logger, structured or not.
23
+ *
24
+ * The entry's `path` carries no query string, as a query can hold an API key
25
+ * or a token; `ctx.request.url` has the whole thing when you want it.
26
+ */
27
+ log?: (entry: LogEntry, ctx: Context) => void
28
+ /**
29
+ * Leaves a request unlogged — health checks, asset routes. It runs once the
30
+ * request is done, so the response status is readable and a filter can keep
31
+ * only the failures.
32
+ */
33
+ skip?: (ctx: Context) => boolean
34
+ }
35
+
36
+ const RESET = '\x1b[0m'
37
+ const DIM = '\x1b[2m'
38
+ const statusColor = (status: number) =>
39
+ status >= 500 ? '\x1b[31m' : status >= 400 ? '\x1b[33m' : status >= 300 ? '\x1b[36m' : '\x1b[32m'
40
+ const paint = (color: string, text: string) => (Bun.enableANSIColors ? `${color}${text}${RESET}` : text)
41
+
42
+ const consoleLog = ({ method, path, status, duration, requestId }: LogEntry) =>
43
+ console.log(
44
+ `${paint(METHOD_COLOR[method.toLowerCase()] ?? '', method)} ${path} ` +
45
+ `${paint(statusColor(status), String(status))} ${paint(DIM, `${duration.toFixed(1)}ms`)}` +
46
+ (requestId ? ` ${paint(DIM, requestId)}` : '')
47
+ )
48
+
49
+ /** The path alone: no origin, no query string, and never a `URL` allocation on the request path. */
50
+ const pathOf = (url: string) => {
51
+ const start = url.indexOf('/', url.indexOf('://') + 3)
52
+ if (start < 0) return '/'
53
+ const query = url.indexOf('?', start)
54
+ return query < 0 ? url.slice(start) : url.slice(start, query)
55
+ }
56
+
57
+ /**
58
+ * #### logger
59
+ * Logs one line per request — method, path, status and how long it took — or
60
+ * hands the same fields to your own logger through `log`.
61
+ *
62
+ * It fills two slots: the
63
+ * [`beforeParse`](https://galbe.dev/documentation/middleware#before-parsing)
64
+ * one starts the clock, and the `afterHandle` one reports the request once a
65
+ * `Response` exists. That covers what a hook-chain log cannot see — a `400`
66
+ * from validation, a `401` from an auth middleware, a `500` — with the status
67
+ * the request actually ended on and the error it ended on.
68
+ *
69
+ * What no middleware can see is a request that matched **no route**: a `404`, a
70
+ * CORS preflight, or a request a plugin answered in `onFetch`. An access log
71
+ * covering those belongs in the plugin.
72
+ *
73
+ * ---
74
+ * @example
75
+ * ```typescript
76
+ * import { logger } from 'galbe/middlewares'
77
+ *
78
+ * // a line per request, on the console
79
+ * galbe.middleware(logger())
80
+ *
81
+ * // structured, and quiet about the health check
82
+ * galbe.middleware(logger({
83
+ * log: entry => log.info(entry),
84
+ * skip: ctx => ctx.route?.path === '/health'
85
+ * }))
86
+ * ```
87
+ * @param config - see {@link LoggerConfig}
88
+ */
89
+ export const logger = (config: LoggerConfig = {}): MiddlewareDef<{}> => {
90
+ const write = config.log ?? consoleLog
91
+ // the clock lives beside the request rather than on `ctx.state`, which is a
92
+ // public `Record<string, any>`: an internal marker has no business in a dump
93
+ // of it, and two instances on one route keep their own
94
+ const starts = new WeakMap<object, number>()
95
+
96
+ const beforeParse: PreParseHook = ctx => {
97
+ starts.set(ctx, performance.now())
98
+ }
99
+
100
+ const afterHandle: ResponseHook = (response, ctx, error) => {
101
+ const start = starts.get(ctx)
102
+ // the post slot also runs for a request answered before the pre slot did —
103
+ // a plugin that threw while routing — where there is no clock to read
104
+ if (typeof start !== 'number' || config.skip?.(ctx)) return
105
+ const id = ctx.state.requestId
106
+ write(
107
+ {
108
+ method: ctx.request.method,
109
+ path: pathOf(ctx.request.url),
110
+ status: response.status,
111
+ duration: performance.now() - start,
112
+ ...(typeof id === 'string' ? { requestId: id } : {}),
113
+ ...(error !== undefined ? { error } : {}),
114
+ },
115
+ ctx
116
+ )
117
+ }
118
+
119
+ return { beforeParse, afterHandle }
120
+ }