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,153 @@
1
+ import type { MiddlewareDef, PreParseContext, PreParseHook } from '../types'
2
+ import type { MaybePromise } from './_auth'
3
+
4
+ import { TooManyRequestsError } from '../types'
5
+
6
+ /** All a rejection knows about itself, and all an `errorHandler` is given. */
7
+ export type RateLimitInfo = {
8
+ /** Bucket the request was accounted to — whatever `key` returned. */
9
+ key: string
10
+ /** Requests allowed per window, as configured. */
11
+ limit: number
12
+ /** Whole seconds until the request would be allowed through, at least `1`. */
13
+ retryAfter: number
14
+ }
15
+
16
+ export type RateLimitConfig = {
17
+ /** Requests allowed per `window`, and the burst a client may spend at once. */
18
+ limit: number
19
+ /** Seconds the bucket takes to refill completely. Fractional values are allowed. */
20
+ window: number
21
+ /**
22
+ * Bucket a request is accounted to. Defaults to `ctx.clientAddress`, which is
23
+ * the socket peer unless [`trustProxy`](https://galbe.dev/documentation/configuration#trustproxy)
24
+ * is configured — set it, or every client behind your proxy shares one bucket.
25
+ *
26
+ * Returning nothing exempts the request: that is how an allow-list, an
27
+ * internal caller or an authenticated tier opts out.
28
+ */
29
+ key?: (ctx: PreParseContext) => string | undefined | null
30
+ /**
31
+ * Maximum number of buckets held in memory. Default `10000`. Reaching it
32
+ * evicts refilled buckets first, then the least recently created one.
33
+ */
34
+ maxKeys?: number
35
+ /** Emit the `RateLimit-*` response headers. Default `true`. */
36
+ headers?: boolean
37
+ /**
38
+ * Replaces the default `429`. Return a `Response` to answer the request, or
39
+ * nothing to fall back to the default `429`; throwing takes the usual error
40
+ * handler path. A `Response` of your own carries no `Retry-After` unless you
41
+ * set one.
42
+ */
43
+ errorHandler?: (info: RateLimitInfo, ctx: PreParseContext) => MaybePromise<Response | void>
44
+ }
45
+
46
+ type Bucket = { tokens: number; updated: number }
47
+
48
+ const DEFAULT_MAX_KEYS = 10_000
49
+ /** Buckets examined per eviction: bounded work, so one request cannot pay for a full scan. */
50
+ const SWEEP = 16
51
+
52
+ /**
53
+ * #### rateLimit
54
+ * Caps how often one client may call the routes it covers, as a token bucket:
55
+ * every key gets `limit` tokens refilled smoothly over `window` seconds, a
56
+ * request spends one, and a request that finds none is answered **429** with a
57
+ * `Retry-After`. Spending them all at once is allowed — the burst a client may
58
+ * take is the limit itself.
59
+ *
60
+ * It runs in the [`beforeParse`](https://galbe.dev/documentation/middleware#before-parsing)
61
+ * slot, so a throttled request is rejected **before its body is read**.
62
+ *
63
+ * Counters live in this process's memory, so each instance is its own limiter
64
+ * and each replica of your app enforces the limit on its own — `n` replicas
65
+ * mean `n` × `limit`. It also only covers **routed** paths: a flood against URLs
66
+ * that match no route never reaches a middleware. Neither is a reason to skip
67
+ * it, but a public-facing service wants a limiter at the edge as well.
68
+ *
69
+ * ---
70
+ * @example
71
+ * ```typescript
72
+ * import { rateLimit } from 'galbe/middlewares'
73
+ *
74
+ * // 100 requests per minute per client, everywhere
75
+ * galbe.middleware(rateLimit({ limit: 100, window: 60 }))
76
+ *
77
+ * // a tighter bucket on top, for one subtree — each instance counts on its own
78
+ * galbe.middleware('/auth/*', rateLimit({ limit: 5, window: 60 }))
79
+ *
80
+ * // per account rather than per address, with signed-in users exempt from it
81
+ * galbe.middleware('/api/*', rateLimit({
82
+ * limit: 1000,
83
+ * window: 3600,
84
+ * key: ctx => ctx.state.apiKey?.accountId
85
+ * }))
86
+ * ```
87
+ * @param config - see {@link RateLimitConfig}
88
+ */
89
+ export const rateLimit = (config: RateLimitConfig): MiddlewareDef<{}> => {
90
+ const { limit, window } = config
91
+ if (!Number.isFinite(limit) || limit < 1) throw new SyntaxError('rateLimit: limit must be at least 1')
92
+ if (!Number.isFinite(window) || window <= 0) throw new SyntaxError('rateLimit: window must be a positive number')
93
+ const maxKeys = config.maxKeys ?? DEFAULT_MAX_KEYS
94
+ if (!Number.isInteger(maxKeys) || maxKeys < 1) throw new SyntaxError('rateLimit: maxKeys must be a positive integer')
95
+ const keyOf = config.key ?? ((ctx: PreParseContext) => ctx.clientAddress)
96
+ const rate = limit / window // tokens per second
97
+ const buckets = new Map<string, Bucket>()
98
+ // monotonic: a clock adjustment must not hand out tokens or freeze a bucket
99
+ const seconds = () => performance.now() / 1000
100
+
101
+ /**
102
+ * Keeps the store bounded, the same discipline as the router cache — a flood
103
+ * of distinct keys must never grow it without limit. Refilled buckets go
104
+ * first, as they say nothing a fresh one would not; only when the sweep frees
105
+ * none does the oldest bucket go.
106
+ */
107
+ const evict = (now: number) => {
108
+ let swept = 0
109
+ let freed = false
110
+ for (const [key, bucket] of buckets) {
111
+ if (swept++ >= SWEEP) break
112
+ if (bucket.tokens + (now - bucket.updated) * rate >= limit) {
113
+ buckets.delete(key)
114
+ freed = true
115
+ }
116
+ }
117
+ if (!freed) buckets.delete(buckets.keys().next().value as string)
118
+ }
119
+
120
+ /** Refills the key's bucket up to `now`, then spends a token if there is one. */
121
+ const consume = (key: string, now: number) => {
122
+ let bucket = buckets.get(key)
123
+ if (bucket) bucket.tokens = Math.min(limit, bucket.tokens + (now - bucket.updated) * rate)
124
+ else {
125
+ if (buckets.size >= maxKeys) evict(now)
126
+ buckets.set(key, (bucket = { tokens: limit, updated: now }))
127
+ }
128
+ bucket.updated = now
129
+ const allowed = bucket.tokens >= 1
130
+ if (allowed) bucket.tokens -= 1
131
+ return { allowed, tokens: bucket.tokens }
132
+ }
133
+
134
+ const beforeParse: PreParseHook = async ctx => {
135
+ const key = keyOf(ctx)
136
+ if (!key) return
137
+ const { allowed, tokens } = consume(key, seconds())
138
+ if (config.headers !== false) {
139
+ ctx.set.headers['ratelimit-limit'] = String(limit)
140
+ ctx.set.headers['ratelimit-remaining'] = String(Math.floor(tokens))
141
+ ctx.set.headers['ratelimit-reset'] = String(Math.ceil((limit - tokens) / rate))
142
+ }
143
+ if (allowed) return
144
+ // a bucket below one token needs that fraction of a second back; HTTP
145
+ // counts Retry-After in whole seconds, so it always asks for at least one
146
+ const retryAfter = Math.ceil((1 - tokens) / rate)
147
+ const handled = await config.errorHandler?.({ key, limit, retryAfter }, ctx)
148
+ if (handled) return handled
149
+ throw new TooManyRequestsError(undefined, { 'retry-after': String(retryAfter) })
150
+ }
151
+
152
+ return { beforeParse }
153
+ }
@@ -0,0 +1,94 @@
1
+ import type { MiddlewareDef, PreParseHook } from '../types'
2
+ import type { STOptional, STString } from '../schema'
3
+
4
+ import { $T } from '../index'
5
+
6
+ /**
7
+ * What an id may be made of: enough for UUIDs, ULIDs, nanoids and W3C trace
8
+ * ids, and nothing that could break a header or a log line. An inbound id that
9
+ * does not match is replaced rather than rejected — a malformed trace header is
10
+ * not the caller's request failing.
11
+ */
12
+ const ID = /^[\w.:-]{1,128}$/
13
+
14
+ export type RequestIdConfig<N extends string = 'x-request-id', T extends boolean = true> = {
15
+ /** Header the id travels in, inbound and outbound. Default `x-request-id`. */
16
+ header?: N
17
+ /**
18
+ * Reuse a well-formed id sent by the caller, so one trace spans the services
19
+ * it passes through. Default `true`. Set it to `false` at the edge of a
20
+ * public API, where the id is the caller's to choose and yours to distrust.
21
+ */
22
+ trustHeader?: T
23
+ /**
24
+ * Makes an id when there is none to reuse. Default `crypto.randomUUID()`.
25
+ * It must satisfy the same shape an inbound id does, since it ends up in a
26
+ * response header and in your logs.
27
+ */
28
+ generate?: () => string
29
+ /** `ctx.state` key the id is stored under. Default `requestId`. */
30
+ stateHolder?: string
31
+ }
32
+
33
+ /** The header contract a trusting `requestId` instance imposes; a distrusting one reads nothing and declares nothing. */
34
+ export type RequestIdFragment<N extends string, T extends boolean> = T extends false
35
+ ? {}
36
+ : { headers: Record<N, STOptional<STString>> }
37
+
38
+ /**
39
+ * #### requestId
40
+ * Gives every request an id — the caller's, when it sent a usable one, and a
41
+ * fresh UUID otherwise — puts it on `ctx.state.requestId` and echoes it in the
42
+ * response header. That id is what ties a log line, a trace and a support
43
+ * ticket to one another.
44
+ *
45
+ * It runs in the [`beforeParse`](https://galbe.dev/documentation/middleware#before-parsing)
46
+ * slot so the id exists before anything can reject the request: a `400` from
47
+ * validation or a `401` from an auth middleware registered after it carries the
48
+ * header too. Register it first for that reason.
49
+ *
50
+ * ---
51
+ * @example
52
+ * ```typescript
53
+ * import { requestId } from 'galbe/middlewares'
54
+ *
55
+ * galbe.middleware(requestId())
56
+ *
57
+ * galbe.get('/orders', ctx => {
58
+ * log.info({ id: ctx.state.requestId }, 'listing orders')
59
+ * return orders()
60
+ * })
61
+ * ```
62
+ * @param config - see {@link RequestIdConfig}
63
+ */
64
+ export const requestId = <N extends string = 'x-request-id', T extends boolean = true>(
65
+ config: RequestIdConfig<N, T> = {}
66
+ ): MiddlewareDef<RequestIdFragment<N, T>> => {
67
+ const header: string = config.header ?? 'x-request-id'
68
+ const stateHolder = config.stateHolder ?? 'requestId'
69
+ const generate = config.generate ?? (() => crypto.randomUUID())
70
+ const trusted = config.trustHeader !== false
71
+
72
+ const beforeParse: PreParseHook = ctx => {
73
+ const inbound = trusted ? ctx.request.headers.get(header) : null
74
+ let id: string
75
+ if (inbound && ID.test(inbound)) id = inbound
76
+ else {
77
+ id = generate()
78
+ // `generate` is caller code and the value becomes a response header:
79
+ // name the culprit here rather than let the Headers constructor reject it
80
+ // with a type error three frames away
81
+ if (typeof id !== 'string' || !ID.test(id))
82
+ throw new Error(
83
+ `requestId: generate() must return at most 128 characters of [A-Za-z0-9_.:-], got ${JSON.stringify(id)?.slice(0, 64)}`
84
+ )
85
+ }
86
+ ctx.state[stateHolder] = id
87
+ ctx.set.headers[header] = id
88
+ }
89
+
90
+ return {
91
+ beforeParse,
92
+ ...(trusted ? { schema: { headers: { [header]: $T.optional($T.string()) } } } : {}),
93
+ } as MiddlewareDef<RequestIdFragment<N, T>>
94
+ }
@@ -0,0 +1,86 @@
1
+ import type { Context, MiddlewareDef, PreParseHook, ResponseHook } from '../types'
2
+
3
+ export type TimingConfig = {
4
+ /** Name of the measurement, a `Server-Timing` token. Default `total`. */
5
+ name?: string
6
+ /** Label shown next to the measurement in a browser's network panel. */
7
+ description?: string
8
+ /** Decimal places kept in the reported duration. Default `1`. */
9
+ precision?: number
10
+ /** Response header the measurement is appended to. Default `server-timing`. */
11
+ header?: string
12
+ /**
13
+ * Leaves a request unmeasured. It runs once the request is done, so the
14
+ * response status is readable and a filter can keep only the slow or the
15
+ * failing ones.
16
+ */
17
+ skip?: (ctx: Context) => boolean
18
+ }
19
+
20
+ /** `Server-Timing` names are tokens: anything else would end the entry early. */
21
+ const token = (name: string) => name.replace(/[^\w-]/g, '-')
22
+
23
+ /**
24
+ * #### timing
25
+ * Reports how long the server spent on a request, in the
26
+ * [`Server-Timing`](https://developer.mozilla.org/docs/Web/HTTP/Headers/Server-Timing)
27
+ * header — the number a browser shows in its network panel, and the one a
28
+ * caller cannot measure itself, since its own clock also counts the network.
29
+ *
30
+ * The entry is **appended**, so measurements a route added to the same header
31
+ * survive alongside it. It measures the whole request: the clock starts in the
32
+ * `beforeParse` slot and the entry is written once a `Response` exists, so a
33
+ * request rejected before the hook chain — a `400` from validation, a `401`
34
+ * from an auth middleware — is measured too. A request that matched no route is
35
+ * not: a middleware runs per route, never for a `404`.
36
+ *
37
+ * The header is public, and so is what it says about your internals. Keep the
38
+ * names generic on a public API, or restrict it to development with `skip`.
39
+ *
40
+ * ---
41
+ * @example
42
+ * ```typescript
43
+ * import { timing } from 'galbe/middlewares'
44
+ *
45
+ * galbe.middleware(timing())
46
+ * // → Server-Timing: total;dur=12.4
47
+ *
48
+ * galbe.middleware('/api/*', timing({ name: 'api', description: 'handler', precision: 2 }))
49
+ * // → Server-Timing: api;dur=12.41;desc="handler"
50
+ * ```
51
+ * @param config - see {@link TimingConfig}
52
+ */
53
+ export const timing = (config: TimingConfig = {}): MiddlewareDef<{}> => {
54
+ const precision = config.precision ?? 1
55
+ if (!Number.isInteger(precision) || precision < 0 || precision > 6)
56
+ throw new SyntaxError('timing: precision must be an integer between 0 and 6')
57
+ const description = config.description
58
+ if (description !== undefined && /["\\\x00-\x1f\x7f]/.test(description))
59
+ throw new SyntaxError('timing: description must not contain quotes, backslashes or control characters')
60
+ const header = token(config.header ?? 'server-timing').toLowerCase()
61
+ const name = token(config.name ?? 'total')
62
+ const suffix = description ? `;desc="${description}"` : ''
63
+
64
+ // the clock lives beside the request rather than on `ctx.state`, which is a
65
+ // public `Record<string, any>`: an internal marker has no business in a dump
66
+ // of it, and two instances on one route keep their own
67
+ const starts = new WeakMap<object, number>()
68
+
69
+ const beforeParse: PreParseHook = ctx => {
70
+ starts.set(ctx, performance.now())
71
+ }
72
+
73
+ const afterHandle: ResponseHook = (response, ctx) => {
74
+ const start = starts.get(ctx)
75
+ // the post slot also runs for a request answered before the pre slot did —
76
+ // a plugin that threw while routing — where there is no clock to read
77
+ if (typeof start !== 'number' || config.skip?.(ctx)) return
78
+ const entry = `${name};dur=${(performance.now() - start).toFixed(precision)}${suffix}`
79
+ // append to whatever the response already carries, so a measurement a route
80
+ // added to the same header survives alongside this one
81
+ const measured = response.headers.get(header)
82
+ response.headers.set(header, measured ? `${measured}, ${entry}` : entry)
83
+ }
84
+
85
+ return { beforeParse, afterHandle }
86
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * #### galbe/middlewares
3
+ * Built-in middlewares: request contracts, security metadata and hooks, ready
4
+ * to register. Each one is a plain {@link MiddlewareDef}, so it is accepted
5
+ * everywhere a hook is — `galbe.middleware`, `galbe.group`, or the default
6
+ * export of a `*.middleware.ts` file.
7
+ *
8
+ * Every middleware is also importable on its own path (`galbe/middlewares/jwt`)
9
+ * for apps that would rather not load the whole set.
10
+ *
11
+ * ---
12
+ * @example
13
+ * ```typescript
14
+ * import { jwt } from 'galbe/middlewares'
15
+ *
16
+ * galbe.middleware('/api/*', jwt({ key: Bun.env.JWT_SECRET! }))
17
+ * ```
18
+ */
19
+ export { AuthError } from './middlewares/_auth'
20
+ export type { AuthErrorCode, AuthErrorHandler } from './middlewares/_auth'
21
+
22
+ export { jwt, JwtError, signJwt } from './middlewares/jwt'
23
+ export type {
24
+ JwtAlgorithm,
25
+ JwtConfig,
26
+ JwtErrorCode,
27
+ JwtFragment,
28
+ JwtKey,
29
+ JwtPayload,
30
+ JwtSignOptions,
31
+ JwtSource,
32
+ } from './middlewares/jwt'
33
+
34
+ export { bearer } from './middlewares/bearer'
35
+ export type { BearerConfig, BearerFragment } from './middlewares/bearer'
36
+
37
+ export { apiKey } from './middlewares/apiKey'
38
+ export type { ApiKeyConfig, ApiKeyFragment } from './middlewares/apiKey'
39
+
40
+ export { basicAuth } from './middlewares/basicAuth'
41
+ export type { BasicAuthConfig, BasicAuthFragment } from './middlewares/basicAuth'
42
+
43
+ export { rateLimit } from './middlewares/rateLimit'
44
+ export type { RateLimitConfig, RateLimitInfo } from './middlewares/rateLimit'
45
+
46
+ export { requestId } from './middlewares/requestId'
47
+ export type { RequestIdConfig, RequestIdFragment } from './middlewares/requestId'
48
+
49
+ export { logger } from './middlewares/logger'
50
+ export type { LogEntry, LoggerConfig } from './middlewares/logger'
51
+
52
+ export { timing } from './middlewares/timing'
53
+ export type { TimingConfig } from './middlewares/timing'