@bakery-framework/core 2.1.2 → 2.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/src/startup.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { networkInterfaces } from 'node:os'
2
2
  import { Bakery } from './core/bakery'
3
3
  import { getConfigLoadError } from './core/config'
4
+ import { getFrameworkVersion } from './core/context'
4
5
  import { resolvePort } from './core/port'
5
6
  import {
6
7
  ApiErrorHandler,
@@ -51,6 +52,19 @@ export function setupPlugins(): Promise<void> {
51
52
  return pluginSetup
52
53
  }
53
54
 
55
+ /**
56
+ * `bakery --build`: every plugin's `build` hook, after their setup. Returns
57
+ * which plugins built and which failed.
58
+ */
59
+ export async function buildPlugins(): Promise<{
60
+ built: string[]
61
+ failed: string[]
62
+ }> {
63
+ await setupPlugins()
64
+ const { PluginHooks } = await import('./core/plugins')
65
+ return await PluginHooks.build()
66
+ }
67
+
54
68
  /**
55
69
  * Test seam, in the family of `__setTestConfig` / `__setTestDb`. The memo above
56
70
  * is process-wide by design, which is exactly what a test asserting "once"
@@ -106,6 +120,10 @@ export async function runStartupBanner(): Promise<void> {
106
120
 
107
121
  const isThreadWorker = import.meta.env.THREAD_WORKER
108
122
  if (!isThreadWorker || import.meta.env.THREAD_ID === '0') {
123
+ // The framework's version, from core's own manifest: not the app's,
124
+ // which is what `<cwd>/package.json` would have said.
125
+ serveLog.BAKERY_VERSION({ version: getFrameworkVersion() })
126
+
109
127
  if (isThreadWorker) {
110
128
  serveLog.THREAD_STARTED({ id: import.meta.env.THREAD_ID })
111
129
  } else {
@@ -7,6 +7,7 @@ export * from './escape'
7
7
  export * from './etag'
8
8
  export * from './html'
9
9
  export * from './ip'
10
+ export * from './rate-limit'
10
11
  export * from './response'
11
12
  export * from './sse'
12
13
  export * from './url'
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The shape of the `rateLimit` setting. Types only: the limiter itself runs in
3
+ * `@bakery-framework/cli` (`worker.ts`), before a request is routed, and its
4
+ * buckets live in the shared pool (`utils/shared-pool.ts`).
5
+ *
6
+ * Every bucket is a token bucket: it holds up to `max` tokens, a request
7
+ * spends one, and `refill` tokens come back each second. A request that finds
8
+ * its bucket empty is answered 429 with a `Retry-After` header.
9
+ */
10
+
11
+ /** Who a request counts against. Nothing, or `''`, falls back to the client address, then the hostname. */
12
+ export type RateLimitKey = (req: Request) => string | null | undefined
13
+
14
+ /**
15
+ * A budget for one part of the site, kept in buckets of its own.
16
+ *
17
+ * A request is charged to the rule with the longest prefix that covers its
18
+ * path, and to that rule only: it does not also spend from the top-level
19
+ * bucket. A path no rule covers spends from the top level.
20
+ */
21
+ export interface RateLimitRule {
22
+ /**
23
+ * The path the rule covers, matched by whole segments and ignoring case,
24
+ * the way routing reads a path: `/api/auth` covers `/api/auth`,
25
+ * `/api/auth/sign-in` and `/API//Auth/sign-in/`, and not `/api/authors`.
26
+ */
27
+ prefix: string
28
+ /** The largest burst one caller can make here: the bucket's size. */
29
+ max: number
30
+ /** Tokens back per second; fractions work, so `1 / 30` is one every thirty seconds. */
31
+ refill: number
32
+ /**
33
+ * Who the caller is, for this rule. When it is left out the key is the
34
+ * client address, whatever the top-level `keyBy` says: a rule usually
35
+ * guards something worth attacking (sign-in, a password reset), and an
36
+ * address cannot be multiplied by opening more sessions.
37
+ */
38
+ keyBy?: RateLimitKey
39
+ }
40
+
41
+ /** The `rateLimit` setting, when it is not `false`. */
42
+ export interface RateLimitOptions {
43
+ /** The largest burst one caller can make: the bucket's size. */
44
+ max: number
45
+ /** Tokens back per second; fractions work, so `0.5` is one every two seconds. */
46
+ refill: number
47
+ /**
48
+ * Who the caller is. Without it the key is the client address
49
+ * (`utils/http/ip.ts`), and so it is whenever this returns nothing. Return
50
+ * an account id for signed-in requests to give each account its own bucket
51
+ * behind a shared address, a school's or an office's.
52
+ */
53
+ keyBy?: RateLimitKey
54
+ /** Budgets for parts of the site. See `RateLimitRule`. */
55
+ routes?: RateLimitRule[]
56
+ }
@@ -1,11 +1,50 @@
1
1
  const HEADER_INT_COUNT = 16
2
2
  const COUNTERS_INT_COUNT = 256
3
- const RATE_LIMIT_SLOT_COUNT = 1024
3
+
4
+ /**
5
+ * Number of token buckets, one 64-bit word each (see `consumeToken`).
6
+ *
7
+ * Every rate-limit key hashes into one of these, and keys that land in the
8
+ * same slot share a budget. With N keys live, about N / slots of them share
9
+ * with someone. At 1024, which this was, a school's thousand signed-in
10
+ * accounts would put most of them in a shared bucket; at 16384 about 6% share
11
+ * one, for 128 KB. The cost of the other direction is memory every server
12
+ * process allocates at import, so it grows with a consumer, not ahead of one.
13
+ */
14
+ export const RATE_LIMIT_SLOT_COUNT = 16384
4
15
  const HEADER_BYTES = HEADER_INT_COUNT * 4
5
16
  const COUNTERS_BYTES = COUNTERS_INT_COUNT * 4
6
- const RATE_LIMIT_BYTES = RATE_LIMIT_SLOT_COUNT * 2 * 4
17
+ // Eight-byte words, so the region has to start on an eight-byte boundary: it
18
+ // does, at 1088, and a BigInt64Array view throws a RangeError if a resized
19
+ // header or counter region ever moves it off one.
20
+ const RATE_LIMIT_BYTES = RATE_LIMIT_SLOT_COUNT * 8
7
21
  const BUFFER_START_OFFSET = HEADER_BYTES + COUNTERS_BYTES + RATE_LIMIT_BYTES
8
22
 
23
+ /**
24
+ * How far ahead of now a bucket may run, in microseconds: 2^52, about 142
25
+ * years. The bucket arithmetic is done in ordinary numbers, which hold
26
+ * integers exactly up to 2^53, and microseconds since 1970 are about 2^50.7,
27
+ * so a time this far ahead still fits exactly until the year 2112. A burst
28
+ * times an interval longer than this (a million tokens at one a week) is cut
29
+ * to it, which only shrinks the bucket.
30
+ *
31
+ * Numbers, not BigInt, because every request pays for this call. Measured
32
+ * over 2M calls, two adjacent runs: 234 to 243 ns with BigInt only at the
33
+ * load and the store, 547 to 606 ns with BigInt arithmetic throughout, and
34
+ * 104 to 142 ns for the 32-bit pair this replaced.
35
+ */
36
+ const MAX_AHEAD_US = 2 ** 52
37
+
38
+ /**
39
+ * Whole microseconds per token. Whole, so every sum below is an exact
40
+ * integer and the burst boundary never depends on rounding; the cost is a
41
+ * refill rate off by under 0.05% below 1000 a second, and by more only at
42
+ * rates no per-client limit uses.
43
+ */
44
+ function tokenInterval(refillRatePerSec: number): number {
45
+ return Math.min(Math.max(Math.round(1e6 / refillRatePerSec), 1), MAX_AHEAD_US)
46
+ }
47
+
9
48
  export const COUNTER_SLOTS = {
10
49
  TOTAL_REQUESTS: 0,
11
50
  TOTAL_ERRORS: 1,
@@ -21,7 +60,7 @@ export class SharedMemoryPool {
21
60
  buffer!: SharedArrayBuffer
22
61
  header!: Int32Array
23
62
  counters!: Int32Array
24
- rateLimits!: Int32Array
63
+ rateLimits!: BigInt64Array
25
64
 
26
65
  /**
27
66
  * **The default is the layout's own size, not a megabyte.**
@@ -52,10 +91,10 @@ export class SharedMemoryPool {
52
91
  HEADER_BYTES,
53
92
  COUNTERS_INT_COUNT,
54
93
  )
55
- this.rateLimits = new Int32Array(
94
+ this.rateLimits = new BigInt64Array(
56
95
  this.buffer,
57
96
  HEADER_BYTES + COUNTERS_BYTES,
58
- RATE_LIMIT_SLOT_COUNT * 2,
97
+ RATE_LIMIT_SLOT_COUNT,
59
98
  )
60
99
  Atomics.store(this.header, 0, 0x42414b45)
61
100
  Atomics.store(this.header, 1, size)
@@ -77,12 +116,11 @@ export class SharedMemoryPool {
77
116
  HEADER_BYTES,
78
117
  COUNTERS_INT_COUNT,
79
118
  )
80
- this.rateLimits = new Int32Array(
119
+ this.rateLimits = new BigInt64Array(
81
120
  this.buffer,
82
121
  HEADER_BYTES + COUNTERS_BYTES,
83
- RATE_LIMIT_SLOT_COUNT * 2,
122
+ RATE_LIMIT_SLOT_COUNT,
84
123
  )
85
-
86
124
  }
87
125
 
88
126
  incrementCounter(slot: number, delta = 1): number {
@@ -105,68 +143,94 @@ export class SharedMemoryPool {
105
143
  return Atomics.store(this.counters, slot, value)
106
144
  }
107
145
 
146
+ /**
147
+ * Take `tokensRequested` from the bucket in `slot`, or answer `false` and
148
+ * take nothing. A bucket holds up to `maxTokens` and gains
149
+ * `refillRatePerSec` a second, fractions included: `1 / 30` is one token
150
+ * every thirty seconds.
151
+ *
152
+ * Each slot holds one number, the bucket's *theoretical arrival time* in
153
+ * microseconds since the epoch (GCRA, the generic cell rate algorithm): the
154
+ * moment it would be full again if nothing else arrived. A token is one
155
+ * `interval` of that time, and a request is admitted while granting it
156
+ * leaves the arrival time no more than `maxTokens` intervals ahead of now.
157
+ * A slot never used holds 0, and any time not ahead of now reads as full.
158
+ *
159
+ * It replaces two 32-bit words, tokens and whole seconds, which dropped the
160
+ * fraction of every refill under one token a second. A bucket refilling at
161
+ * 0.5 a second, asked once a second while empty, computed 0.5, stored 0
162
+ * and restarted its clock, so it never granted again while the client kept
163
+ * asking (measured: a grant due after 2 s did not come in 120). One word is
164
+ * also one compare-and-swap, where the pair could be torn between workers.
165
+ *
166
+ * `nowMs` is a seam for tests, in the manner of `sampleRateLimitLog`.
167
+ */
108
168
  consumeToken(
109
169
  slot: number,
110
170
  maxTokens: number,
111
171
  refillRatePerSec: number,
112
172
  tokensRequested = 1,
173
+ nowMs: number = Date.now(),
113
174
  ): boolean {
114
175
  if (slot < 0 || slot >= RATE_LIMIT_SLOT_COUNT) return false
115
- const tokenIndex = slot * 2
116
- const timeIndex = slot * 2 + 1
117
-
118
- const nowSec = Math.floor(Date.now() / 1000)
176
+ // Fail closed on limits that are not positive numbers: nothing is
177
+ // admitted, rather than BigInt() throwing out of every request or a NaN
178
+ // comparison letting every request through.
179
+ if (!(maxTokens >= 1 && refillRatePerSec > 0 && tokensRequested >= 0)) {
180
+ return false
181
+ }
119
182
 
120
- while (true) {
121
- const currentTokens = Atomics.load(this.rateLimits, tokenIndex)
122
- const lastTime = Atomics.load(this.rateLimits, timeIndex)
123
-
124
- if (lastTime === 0 && currentTokens === 0) {
125
- const initTokens = Math.max(0, maxTokens - tokensRequested)
126
- if (
127
- Atomics.compareExchange(this.rateLimits, timeIndex, 0, nowSec) === 0
128
- ) {
129
- Atomics.store(this.rateLimits, tokenIndex, initTokens)
130
- return tokensRequested <= maxTokens
131
- }
132
- continue
133
- }
183
+ const interval = tokenInterval(refillRatePerSec)
184
+ const limit = Math.min(Math.floor(maxTokens) * interval, MAX_AHEAD_US)
185
+ const cost = Math.floor(tokensRequested) * interval
186
+ if (cost > limit) return false
134
187
 
135
- let availableTokens = currentTokens
136
- const elapsed = Math.max(0, nowSec - lastTime)
137
- if (elapsed > 0) {
138
- availableTokens = Math.min(
139
- maxTokens,
140
- currentTokens + elapsed * refillRatePerSec,
141
- )
188
+ const now = Math.floor(nowMs) * 1000
189
+ for (;;) {
190
+ const raw = Atomics.load(this.rateLimits, slot)
191
+ const stored = Number(raw)
192
+ const next = (stored > now ? stored : now) + cost
193
+ if (next - now > limit) return false
194
+ if (
195
+ Atomics.compareExchange(this.rateLimits, slot, raw, BigInt(next)) ===
196
+ raw
197
+ ) {
198
+ return true
142
199
  }
200
+ }
201
+ }
143
202
 
144
- if (availableTokens < tokensRequested) {
145
- if (elapsed > 0) {
146
- Atomics.compareExchange(this.rateLimits, timeIndex, lastTime, nowSec)
147
- Atomics.compareExchange(
148
- this.rateLimits,
149
- tokenIndex,
150
- currentTokens,
151
- availableTokens,
152
- )
153
- }
154
- return false
155
- }
203
+ /**
204
+ * Give back tokens a `consumeToken` call took, never past a full bucket.
205
+ *
206
+ * For a request that turned out not to count. The limiter has to charge
207
+ * before routing, since admitting first and charging later lets a burst of
208
+ * any size through at once, and whether a request was an asset is only
209
+ * known once the handler that served it says so.
210
+ */
211
+ refundToken(
212
+ slot: number,
213
+ refillRatePerSec: number,
214
+ tokens = 1,
215
+ nowMs: number = Date.now(),
216
+ ): void {
217
+ if (slot < 0 || slot >= RATE_LIMIT_SLOT_COUNT) return
218
+ if (!(refillRatePerSec > 0 && tokens > 0)) return
156
219
 
157
- const newTokens = availableTokens - tokensRequested
220
+ const back = Math.floor(tokens) * tokenInterval(refillRatePerSec)
221
+ const now = Math.floor(nowMs) * 1000
222
+ for (;;) {
223
+ const raw = Atomics.load(this.rateLimits, slot)
224
+ const stored = Number(raw)
225
+ // Already full: there is nothing to give back, and moving the arrival
226
+ // time up to `now` would be a charge.
227
+ if (stored <= now) return
228
+ const next = Math.max(stored - back, now)
158
229
  if (
159
- Atomics.compareExchange(
160
- this.rateLimits,
161
- tokenIndex,
162
- currentTokens,
163
- newTokens,
164
- ) === currentTokens
230
+ Atomics.compareExchange(this.rateLimits, slot, raw, BigInt(next)) ===
231
+ raw
165
232
  ) {
166
- if (elapsed > 0) {
167
- Atomics.compareExchange(this.rateLimits, timeIndex, lastTime, nowSec)
168
- }
169
- return true
233
+ return
170
234
  }
171
235
  }
172
236
  }