@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/package.json +1 -1
- package/src/cache/tiered.ts +9 -0
- package/src/compiler/compiler.ts +10 -0
- package/src/core/config.ts +16 -0
- package/src/core/context.ts +8 -0
- package/src/core/index.ts +5 -0
- package/src/core/plugins.ts +24 -0
- package/src/global.d.ts +25 -14
- package/src/handlers/assets/nm.ts +5 -0
- package/src/handlers/assets/public.ts +4 -0
- package/src/handlers/assets/static.ts +5 -0
- package/src/handlers/assets/ts.ts +5 -0
- package/src/handlers/assets/virtual-asset.ts +5 -0
- package/src/handlers/core/$base.ts +28 -0
- package/src/logger/serve-log.ts +16 -0
- package/src/plugins/types.ts +8 -0
- package/src/router.ts +18 -5
- package/src/session.ts +516 -30
- package/src/startup.ts +18 -0
- package/src/utils/http/index.ts +1 -0
- package/src/utils/http/rate-limit.ts +56 -0
- package/src/utils/shared-pool.ts +120 -56
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 {
|
package/src/utils/http/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/shared-pool.ts
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
const HEADER_INT_COUNT = 16
|
|
2
2
|
const COUNTERS_INT_COUNT = 256
|
|
3
|
-
|
|
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
|
-
|
|
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!:
|
|
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
|
|
94
|
+
this.rateLimits = new BigInt64Array(
|
|
56
95
|
this.buffer,
|
|
57
96
|
HEADER_BYTES + COUNTERS_BYTES,
|
|
58
|
-
RATE_LIMIT_SLOT_COUNT
|
|
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
|
|
119
|
+
this.rateLimits = new BigInt64Array(
|
|
81
120
|
this.buffer,
|
|
82
121
|
HEADER_BYTES + COUNTERS_BYTES,
|
|
83
|
-
RATE_LIMIT_SLOT_COUNT
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
tokenIndex,
|
|
162
|
-
currentTokens,
|
|
163
|
-
newTokens,
|
|
164
|
-
) === currentTokens
|
|
230
|
+
Atomics.compareExchange(this.rateLimits, slot, raw, BigInt(next)) ===
|
|
231
|
+
raw
|
|
165
232
|
) {
|
|
166
|
-
|
|
167
|
-
Atomics.compareExchange(this.rateLimits, timeIndex, lastTime, nowSec)
|
|
168
|
-
}
|
|
169
|
-
return true
|
|
233
|
+
return
|
|
170
234
|
}
|
|
171
235
|
}
|
|
172
236
|
}
|