@bakery-framework/cli 2.1.1 → 2.2.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.
- package/package.json +3 -3
- package/src/build.ts +33 -0
- package/src/dev.ts +29 -17
- package/src/index.ts +17 -1
- package/src/pipeline.ts +207 -6
- package/src/rate-limit.ts +65 -7
- package/src/worker.ts +146 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Bakery entry point and process supervision: mode dispatch, dev watcher, cluster.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"bun": ">=1.4.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@bakery-framework/core": "^2.
|
|
42
|
+
"@bakery-framework/core": "^2.2.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@bakery-framework/orm": "^2.
|
|
45
|
+
"@bakery-framework/orm": "^2.2.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependenciesMeta": {
|
|
48
48
|
"@bakery-framework/orm": {
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { errorMsg, serveLog } from '@bakery-framework/core/logger'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `bakery --build`: the work a production start can have done ahead of it,
|
|
5
|
+
* which is whatever the app's plugins do in their `build` hook. plugin-vue
|
|
6
|
+
* bundles every page there.
|
|
7
|
+
*
|
|
8
|
+
* Production by construction: the flags make anything without `--dev`
|
|
9
|
+
* production, so the output is minified and keyed to the production cache,
|
|
10
|
+
* which `initConfig` empties first when the app or the framework has a new
|
|
11
|
+
* version. Nothing is served and no database is opened.
|
|
12
|
+
*
|
|
13
|
+
* Returns the exit code: 1 when any plugin failed, so a deploy stops there.
|
|
14
|
+
*/
|
|
15
|
+
export async function build(): Promise<number> {
|
|
16
|
+
try {
|
|
17
|
+
const { initConfig } = await import('@bakery-framework/core/core/config')
|
|
18
|
+
const { buildPlugins } = await import('@bakery-framework/core/startup')
|
|
19
|
+
|
|
20
|
+
await initConfig()
|
|
21
|
+
const { built, failed } = await buildPlugins()
|
|
22
|
+
|
|
23
|
+
if (failed.length) {
|
|
24
|
+
serveLog.BUILD_FAILED({ plugins: failed.join(', ') })
|
|
25
|
+
return 1
|
|
26
|
+
}
|
|
27
|
+
if (!built.length) serveLog.BUILD_NOTHING()
|
|
28
|
+
return 0
|
|
29
|
+
} catch (error: any) {
|
|
30
|
+
serveLog.UNHANDLED_ERR({ error: `bakery --build: ${errorMsg(error)}` })
|
|
31
|
+
return 1
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/dev.ts
CHANGED
|
@@ -57,20 +57,31 @@ if (hasORM()) {
|
|
|
57
57
|
const currentHash = await computeSchemaHash(schemaFromConfig(config))
|
|
58
58
|
const [, stored] = await Try.catch(Bun.file(hashFile).text())
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
60
|
+
// Migrations mode checks on every boot instead: a migration changes the
|
|
61
|
+
// database without touching anything the hash covers, and the check is
|
|
62
|
+
// one catalog read that changes nothing. Its mismatches are reported and
|
|
63
|
+
// the server still boots, which is what a developer mid-migration needs.
|
|
64
|
+
const migrations = (config as Partial<ProcessedAppConfig> | undefined)
|
|
65
|
+
?.migrations
|
|
66
|
+
const decision = migrations
|
|
67
|
+
? 'check'
|
|
68
|
+
: classifySchemaSync({
|
|
69
|
+
force: process.argv.includes('--sync') || process.argv.includes('-s'),
|
|
70
|
+
currentHash,
|
|
71
|
+
storedHash: stored?.trim() || null,
|
|
72
|
+
// Only meaningful for the default SQLite target; with a DB_URL there is
|
|
73
|
+
// no local file to stat, and the hash (which covers DB_URL) plus `--sync`
|
|
74
|
+
// are the levers for an externally reset database.
|
|
75
|
+
dbMissing:
|
|
76
|
+
!process.env.DB_URL &&
|
|
77
|
+
!process.env.DATABASE_URL &&
|
|
78
|
+
!(await Bun.file(`${Bakery.dataDir}/server.db`).exists()),
|
|
79
|
+
})
|
|
72
80
|
|
|
73
|
-
if (decision === '
|
|
81
|
+
if (decision === 'check') {
|
|
82
|
+
const { SyncService } = await import('@bakery-framework/orm/sync')
|
|
83
|
+
await SyncService.run()
|
|
84
|
+
} else if (decision === 'skip') {
|
|
74
85
|
serveLog.SCHEMA_SYNC_SKIP()
|
|
75
86
|
// Only on the skip path. When a sync runs it reports drift itself, from
|
|
76
87
|
// the plan it just built; here nothing else would ever look. Measured at
|
|
@@ -86,10 +97,11 @@ if (hasORM()) {
|
|
|
86
97
|
if (drift) serveLog.SCHEMA_DRIFT({ reason: drift.reason })
|
|
87
98
|
} else {
|
|
88
99
|
const { SyncService } = await import('@bakery-framework/orm/sync')
|
|
89
|
-
await SyncService.run()
|
|
90
|
-
// Recorded only after
|
|
91
|
-
// the previous hash (or none) behind so the next
|
|
92
|
-
|
|
100
|
+
const synced = await SyncService.run()
|
|
101
|
+
// Recorded only after a sync that went through: a failed, aborted or
|
|
102
|
+
// refused one must leave the previous hash (or none) behind so the next
|
|
103
|
+
// boot syncs again.
|
|
104
|
+
if (synced && currentHash) {
|
|
93
105
|
const [writeError] = await Try.catch(Bun.write(hashFile, currentHash))
|
|
94
106
|
// A failed record is tolerated silently: its only consequence is that
|
|
95
107
|
// the next boot syncs again, which is the safe direction.
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
31
31
|
console.log(`
|
|
32
32
|
Usage: bakery [--dev] [--sync] [--threads N] [--port N]
|
|
33
33
|
bakery --types
|
|
34
|
+
bakery --build
|
|
34
35
|
|
|
35
36
|
Runs the application in the current directory: the one whose
|
|
36
37
|
\`server.config.ts\` sits beside it.
|
|
@@ -42,6 +43,10 @@ Flags:
|
|
|
42
43
|
tsconfig.bakery.json) and exit, without starting a server.
|
|
43
44
|
Every dev boot does the same; a fresh clone or a CI job
|
|
44
45
|
needs it before tsc can find them.
|
|
46
|
+
--build Run each plugin's build step and exit: plugin-vue bundles
|
|
47
|
+
every page into a few content-hashed chunks, which a
|
|
48
|
+
production start then serves. Run it before starting;
|
|
49
|
+
a server keeps what it found when it started.
|
|
45
50
|
--sync, -s Run the schema sync before starting. Requires
|
|
46
51
|
@bakery-framework/orm; it is an error rather than a skip
|
|
47
52
|
when that is missing, because asking for a sync and
|
|
@@ -64,6 +69,13 @@ if (process.argv.includes('--types')) {
|
|
|
64
69
|
process.exit(await writeTypes())
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
// The same reasoning: a command that exits, and production by construction,
|
|
73
|
+
// since it is not `--dev`.
|
|
74
|
+
if (process.argv.includes('--build')) {
|
|
75
|
+
const { build } = await import('./build')
|
|
76
|
+
process.exit(await build())
|
|
77
|
+
}
|
|
78
|
+
|
|
67
79
|
// Before any mode takes over, and before the config is read: `applyPortFlag`
|
|
68
80
|
// writes `process.env.PORT`, which is what the worker, the startup banner and
|
|
69
81
|
// the dev master's advertised URL all resolve from, and what the spawned dev
|
|
@@ -97,7 +109,11 @@ if (
|
|
|
97
109
|
process.exit(1)
|
|
98
110
|
}
|
|
99
111
|
const { SyncService } = await import('@bakery-framework/orm/sync')
|
|
100
|
-
|
|
112
|
+
// False is a database that does not match what the app declares: drift the
|
|
113
|
+
// migrations check found, or a Field.Sql column classic sync refused. Both
|
|
114
|
+
// were reported; serving queries typed against the wrong schema is not
|
|
115
|
+
// the safe direction.
|
|
116
|
+
if (!(await SyncService.run())) process.exit(1)
|
|
101
117
|
}
|
|
102
118
|
try {
|
|
103
119
|
if (threadsOption !== null && !isDevWorker && !isThreadWorker && !isDev) {
|
package/src/pipeline.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import type { Handler } from '@bakery-framework/core/handlers'
|
|
1
2
|
import { is } from '@bakery-framework/core/utils/common'
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getClientIp,
|
|
5
|
+
type RateLimitKey,
|
|
6
|
+
} from '@bakery-framework/core/utils/http'
|
|
3
7
|
import { retryAfterSeconds } from './rate-limit'
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
10
|
+
* The decisions `worker.ts`'s `fetch` makes that are pure functions of their
|
|
11
|
+
* arguments.
|
|
8
12
|
*
|
|
9
13
|
* They were inline in the `Bun.serve` callback, which is unreachable from a
|
|
10
14
|
* test: `worker.ts` calls `Bun.serve` at module scope, so importing it binds a
|
|
@@ -38,7 +42,174 @@ export function isErrorResult(res: unknown): boolean {
|
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
/**
|
|
41
|
-
*
|
|
45
|
+
* One bucket a request can be charged to: the top level, or a route rule,
|
|
46
|
+
* checked and ready to match.
|
|
47
|
+
*/
|
|
48
|
+
export interface RateLimitBucket {
|
|
49
|
+
/**
|
|
50
|
+
* `''` for the top level, the rule's prefix (lower case, one slash between
|
|
51
|
+
* segments) otherwise. Prepended to every key the bucket hashes, so a client
|
|
52
|
+
* spending at `/api/auth` and the same client spending elsewhere draw from
|
|
53
|
+
* two buckets, not one.
|
|
54
|
+
*/
|
|
55
|
+
prefix: string
|
|
56
|
+
/** The prefix's segments, compared against the request's. */
|
|
57
|
+
segments: string[]
|
|
58
|
+
max: number
|
|
59
|
+
refill: number
|
|
60
|
+
keyBy?: RateLimitKey
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `rateLimit` checked once: the top-level bucket, and the rules most specific first. */
|
|
64
|
+
export interface CompiledRateLimit {
|
|
65
|
+
base: RateLimitBucket
|
|
66
|
+
routes: RateLimitBucket[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Keyed by the setting object, which is frozen with the config for the life
|
|
71
|
+
* of the process, so each host's `rateLimit` is checked once. Weak, because a
|
|
72
|
+
* test seam (`__setTestConfig`) hands in new objects freely.
|
|
73
|
+
*/
|
|
74
|
+
const compiled = new WeakMap<RateLimitConfig, CompiledRateLimit>()
|
|
75
|
+
|
|
76
|
+
/** A path's segments the way routing reads them: no empty ones, any case. */
|
|
77
|
+
function pathSegments(path: string): string[] {
|
|
78
|
+
return path.toLowerCase().split('/').filter(Boolean)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkBudget(where: string, max: unknown, refill: unknown): void {
|
|
82
|
+
if (typeof max !== 'number' || !Number.isFinite(max) || max < 1) {
|
|
83
|
+
throw new Error(`${where}.max must be a number of 1 or more, got ${max}`)
|
|
84
|
+
}
|
|
85
|
+
if (typeof refill !== 'number' || !Number.isFinite(refill) || refill <= 0) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${where}.refill must be a number above 0 (1 / 60 is one a minute), got ${refill}`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function checkKeyBy(where: string, keyBy: unknown): void {
|
|
93
|
+
if (keyBy !== undefined && typeof keyBy !== 'function') {
|
|
94
|
+
throw new Error(`${where}.keyBy must be a function`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check a `rateLimit` setting and put its rules in matching order.
|
|
100
|
+
*
|
|
101
|
+
* Throws on a setting that cannot mean anything, naming the field: a refill
|
|
102
|
+
* of 0, a prefix without its leading slash, two rules for one prefix.
|
|
103
|
+
* `worker.ts` calls this for the base config and every host before it
|
|
104
|
+
* serves, so a typo stops the boot instead of surfacing on some later
|
|
105
|
+
* request, or as a bucket that admits nothing, which is what the pool makes
|
|
106
|
+
* of a refill it cannot use.
|
|
107
|
+
*/
|
|
108
|
+
export function compileRateLimit(
|
|
109
|
+
rl: RateLimitConfig,
|
|
110
|
+
where = 'rateLimit',
|
|
111
|
+
): CompiledRateLimit {
|
|
112
|
+
const known = compiled.get(rl)
|
|
113
|
+
if (known) return known
|
|
114
|
+
|
|
115
|
+
checkBudget(where, rl.max, rl.refill)
|
|
116
|
+
checkKeyBy(where, rl.keyBy)
|
|
117
|
+
if (rl.routes !== undefined && !Array.isArray(rl.routes)) {
|
|
118
|
+
throw new Error(`${where}.routes must be an array of rules`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const seen = new Set<string>()
|
|
122
|
+
const routes: RateLimitBucket[] = (rl.routes ?? []).map((rule, i) => {
|
|
123
|
+
const at = `${where}.routes[${i}]`
|
|
124
|
+
if (typeof rule?.prefix !== 'string' || !rule.prefix.startsWith('/')) {
|
|
125
|
+
throw new Error(`${at}.prefix must be a path starting with "/"`)
|
|
126
|
+
}
|
|
127
|
+
checkBudget(at, rule.max, rule.refill)
|
|
128
|
+
checkKeyBy(at, rule.keyBy)
|
|
129
|
+
|
|
130
|
+
const segments = pathSegments(rule.prefix)
|
|
131
|
+
const prefix = `/${segments.join('/')}`
|
|
132
|
+
if (seen.has(prefix)) {
|
|
133
|
+
throw new Error(`${at}.prefix covers ${prefix}, as an earlier rule does`)
|
|
134
|
+
}
|
|
135
|
+
seen.add(prefix)
|
|
136
|
+
return {
|
|
137
|
+
prefix,
|
|
138
|
+
segments,
|
|
139
|
+
max: rule.max,
|
|
140
|
+
refill: rule.refill,
|
|
141
|
+
keyBy: rule.keyBy,
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
// Longest first, so the first rule that matches is the most specific. Two
|
|
145
|
+
// rules of one length match the same path only if they are the same
|
|
146
|
+
// prefix, which the check above refuses.
|
|
147
|
+
routes.sort((a, b) => b.segments.length - a.segments.length)
|
|
148
|
+
|
|
149
|
+
const out: CompiledRateLimit = {
|
|
150
|
+
base: {
|
|
151
|
+
prefix: '',
|
|
152
|
+
segments: [],
|
|
153
|
+
max: rl.max,
|
|
154
|
+
refill: rl.refill,
|
|
155
|
+
keyBy: rl.keyBy,
|
|
156
|
+
},
|
|
157
|
+
routes,
|
|
158
|
+
}
|
|
159
|
+
compiled.set(rl, out)
|
|
160
|
+
return out
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check the base `rateLimit` and every host's, so a bad one fails the boot.
|
|
165
|
+
* A host entry's setting reaches requests as the same object (`config.ts`
|
|
166
|
+
* merges it by reference), so what is checked here is what is served.
|
|
167
|
+
*/
|
|
168
|
+
export function checkRateLimits(config: Readonly<ProcessedAppConfig>): void {
|
|
169
|
+
if (config.rateLimit) compileRateLimit(config.rateLimit)
|
|
170
|
+
for (const [host, entry] of Object.entries(config.hosts ?? {})) {
|
|
171
|
+
if (entry?.rateLimit) {
|
|
172
|
+
compileRateLimit(entry.rateLimit, `hosts['${host}'].rateLimit`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The bucket a path is charged to: the rule with the longest prefix covering
|
|
179
|
+
* it, else the top level.
|
|
180
|
+
*
|
|
181
|
+
* Matched on segments, empty ones dropped and case folded, because that is how
|
|
182
|
+
* routing reads a path: `$routing.ts` splits on `/` and filters out the empty
|
|
183
|
+
* parts, so `/api//auth/sign-in` reaches `api/auth/sign-in.ts`. A rule matched
|
|
184
|
+
* on the raw string would let that spelling through on the top-level budget.
|
|
185
|
+
* Case is folded so a case-insensitive filesystem cannot open the same gap;
|
|
186
|
+
* where routing would refuse the spelling, charging it to the rule costs
|
|
187
|
+
* nothing.
|
|
188
|
+
*/
|
|
189
|
+
export function rateLimitBucket(
|
|
190
|
+
limit: CompiledRateLimit,
|
|
191
|
+
path: string,
|
|
192
|
+
): RateLimitBucket {
|
|
193
|
+
if (limit.routes.length === 0) return limit.base
|
|
194
|
+
|
|
195
|
+
const segments = pathSegments(path)
|
|
196
|
+
for (const rule of limit.routes) {
|
|
197
|
+
if (rule.segments.length > segments.length) continue
|
|
198
|
+
let i = 0
|
|
199
|
+
while (i < rule.segments.length && rule.segments[i] === segments[i]) i++
|
|
200
|
+
if (i === rule.segments.length) return rule
|
|
201
|
+
}
|
|
202
|
+
return limit.base
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Which token bucket this request spends from, within its rule.
|
|
207
|
+
*
|
|
208
|
+
* `keyBy` first, then the client address, then the hostname. A `keyBy` that
|
|
209
|
+
* returns nothing hands the request to its address rather than to a key it
|
|
210
|
+
* shares with every other unclassified request, which is what makes "the
|
|
211
|
+
* account when signed in, the address when not" one line:
|
|
212
|
+
* `keyBy: req => req.session.get('accountId')`.
|
|
42
213
|
*
|
|
43
214
|
* The `|| hostname` is not a tidy-up. An empty key is a *valid* string that
|
|
44
215
|
* hashes to one fixed slot, so every client whose IP could not be determined,
|
|
@@ -48,11 +219,41 @@ export function isErrorResult(res: unknown): boolean {
|
|
|
48
219
|
* per-host, which is the coarsest grouping that is still meaningful.
|
|
49
220
|
*/
|
|
50
221
|
export function rateLimitKey(
|
|
51
|
-
|
|
222
|
+
bucket: { keyBy?: RateLimitKey },
|
|
52
223
|
req: Request,
|
|
53
224
|
hostname: string,
|
|
54
225
|
): string {
|
|
55
|
-
return
|
|
226
|
+
return bucket.keyBy?.(req) || getClientIp(req) || hostname
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The string a request's bucket is hashed from: its key, behind the rule's
|
|
231
|
+
* prefix when there is one. A newline cannot reach a key from a client (HTTP
|
|
232
|
+
* refuses one in a header value, and so in a cookie), so no key can pose as
|
|
233
|
+
* another rule's.
|
|
234
|
+
*/
|
|
235
|
+
export function bucketKey(bucket: RateLimitBucket, key: string): string {
|
|
236
|
+
return bucket.prefix ? `${bucket.prefix}\n${key}` : key
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Whether a request was served as an asset: answered under 400 by a handler
|
|
241
|
+
* that says, of this request, that what it served was one. See
|
|
242
|
+
* `Handler.isAsset`. A status of 0 stands for no response at all, a
|
|
243
|
+
* WebSocket upgrade for one.
|
|
244
|
+
*/
|
|
245
|
+
export function answeredAsAsset(
|
|
246
|
+
handler: typeof Handler | undefined,
|
|
247
|
+
path: string,
|
|
248
|
+
req: Request,
|
|
249
|
+
status: number,
|
|
250
|
+
): boolean {
|
|
251
|
+
return (
|
|
252
|
+
handler !== undefined &&
|
|
253
|
+
status > 0 &&
|
|
254
|
+
status < 400 &&
|
|
255
|
+
handler.isAsset(path, req)
|
|
256
|
+
)
|
|
56
257
|
}
|
|
57
258
|
|
|
58
259
|
/**
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { LRUCache } from '@bakery-framework/core/cache/lru'
|
|
2
|
+
import { hostKey } from '@bakery-framework/core/core/bakery'
|
|
3
|
+
import { RATE_LIMIT_SLOT_COUNT } from '@bakery-framework/core/utils/shared-pool'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
|
-
* Number of token buckets in `SharedMemoryPool`'s rate-limit region
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* hashed past the end of the region. `rate-limit.test.ts` cross-checks this
|
|
9
|
-
* against a real pool rather than trusting the two constants to stay in step.
|
|
6
|
+
* Number of token buckets in `SharedMemoryPool`'s rate-limit region, taken
|
|
7
|
+
* from the pool itself. It was a second constant here with a comment asking
|
|
8
|
+
* that the two be kept in step, and `consumeToken()` fails closed on a slot
|
|
9
|
+
* past the end, so a mismatch would deny every client hashed out of range.
|
|
10
10
|
*/
|
|
11
|
-
export const RATE_LIMIT_SLOTS =
|
|
11
|
+
export const RATE_LIMIT_SLOTS = RATE_LIMIT_SLOT_COUNT
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Map a rate-limit key (client IP, or whatever `rateLimit.keyBy` returns) onto
|
|
@@ -94,3 +94,61 @@ export function __resetRateLimitLogState(): void {
|
|
|
94
94
|
export function retryAfterSeconds(refill: number): number {
|
|
95
95
|
return Math.max(1, Math.ceil(1 / refill))
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Bound for the set of URLs proven to be assets, sized like the route cache it
|
|
100
|
+
* sits beside (`HandlerMap.routeCache`): an entry per asset URL an app
|
|
101
|
+
* serves, per configured host. Past it the LRU evicts, and an evicted URL
|
|
102
|
+
* borrows a token on its next request and is proven again.
|
|
103
|
+
*/
|
|
104
|
+
export const PROVEN_ASSET_KEYS = import.meta.env.THREAD_WORKER ? 500 : 5000
|
|
105
|
+
|
|
106
|
+
/** Hashes of asset URLs some handler has served successfully. */
|
|
107
|
+
const provenAssets = new LRUCache<bigint, true>(PROVEN_ASSET_KEYS)
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What identifies a request to the proven-asset set: its host, path and query,
|
|
111
|
+
* and what the browser said it was fetching (`Sec-Fetch-Dest`).
|
|
112
|
+
*
|
|
113
|
+
* The destination is part of it because one URL can be two things: a Vue
|
|
114
|
+
* page answers its own URL with HTML for a navigation and with its script for
|
|
115
|
+
* an import. Proving the script must not make the page free. A client can
|
|
116
|
+
* send any `Sec-Fetch-Dest` it likes, but only to its own cost: a different
|
|
117
|
+
* value is a different key, and a key is proven only by a handler serving an
|
|
118
|
+
* asset for it.
|
|
119
|
+
*
|
|
120
|
+
* Hashed, because the query is the client's, and 5000 raw URLs at the length
|
|
121
|
+
* a client may send could hold tens of megabytes. A 64-bit hash collision
|
|
122
|
+
* would make one URL free on another's proof: for any one request, a chance
|
|
123
|
+
* of 5000 in 2^64, about 3 in 10^16.
|
|
124
|
+
*
|
|
125
|
+
* Must be called inside the request's `hostStore` scope, as `hostKey` is.
|
|
126
|
+
*/
|
|
127
|
+
export function assetKey(path: string, search: string, req: Request): bigint {
|
|
128
|
+
const dest = req.headers.get('sec-fetch-dest') ?? ''
|
|
129
|
+
return Bun.hash.wyhash(`${hostKey(path + search)}
|
|
130
|
+
${dest}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a handler has served this key as an asset, and still holds it. */
|
|
134
|
+
export function isProvenAsset(key: bigint): boolean {
|
|
135
|
+
return provenAssets.get(key) === true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function proveAsset(key: bigint): void {
|
|
139
|
+
provenAssets.set(key, true)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Drop a key whose request skipped the bucket and then was not served as an
|
|
144
|
+
* asset: a deleted file, a refused range, a guard that said no. The next
|
|
145
|
+
* request for it borrows a token like any other.
|
|
146
|
+
*/
|
|
147
|
+
export function forgetAsset(key: bigint): void {
|
|
148
|
+
provenAssets.delete(key)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Test seam, beside `__resetRateLimitLogState`. */
|
|
152
|
+
export function __resetProvenAssets(): void {
|
|
153
|
+
provenAssets.clear()
|
|
154
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -19,12 +19,29 @@ import {
|
|
|
19
19
|
} from '@bakery-framework/core/router'
|
|
20
20
|
import { Session } from '@bakery-framework/core/session'
|
|
21
21
|
import { runStartupBanner, setupServer } from '@bakery-framework/core/startup'
|
|
22
|
-
import {
|
|
22
|
+
import { Try } from '@bakery-framework/core/utils/common'
|
|
23
23
|
import { parsedUrl } from '@bakery-framework/core/utils/http'
|
|
24
24
|
import { COUNTER_SLOTS } from '@bakery-framework/core/utils/shared-pool'
|
|
25
25
|
import { hasORM } from './orm'
|
|
26
|
-
import {
|
|
27
|
-
|
|
26
|
+
import {
|
|
27
|
+
answeredAsAsset,
|
|
28
|
+
bucketKey,
|
|
29
|
+
checkRateLimits,
|
|
30
|
+
compileRateLimit,
|
|
31
|
+
isErrorResult,
|
|
32
|
+
type RateLimitConfig,
|
|
33
|
+
rateLimitBucket,
|
|
34
|
+
rateLimitKey,
|
|
35
|
+
tooManyRequests,
|
|
36
|
+
} from './pipeline'
|
|
37
|
+
import {
|
|
38
|
+
assetKey,
|
|
39
|
+
forgetAsset,
|
|
40
|
+
isProvenAsset,
|
|
41
|
+
proveAsset,
|
|
42
|
+
rateLimitSlot,
|
|
43
|
+
sampleRateLimitLog,
|
|
44
|
+
} from './rate-limit'
|
|
28
45
|
import { runShutdownSequence } from './shutdown'
|
|
29
46
|
|
|
30
47
|
/**
|
|
@@ -130,7 +147,109 @@ try {
|
|
|
130
147
|
process.exit(1)
|
|
131
148
|
}
|
|
132
149
|
|
|
150
|
+
/**
|
|
151
|
+
* What the limiter did with a request, for `settleRequest` to finish once the
|
|
152
|
+
* response is known: the request's asset key, whether it skipped the bucket
|
|
153
|
+
* as a proven asset, and otherwise the slot it borrowed a token from.
|
|
154
|
+
*/
|
|
155
|
+
type RateLimitTicket = {
|
|
156
|
+
asset: bigint
|
|
157
|
+
skipped: boolean
|
|
158
|
+
slot: number
|
|
159
|
+
refill: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Let a request in, or answer it 429.
|
|
164
|
+
*
|
|
165
|
+
* A URL some handler has already served as an asset skips the bucket (see
|
|
166
|
+
* `Handler.isAsset`). Anything else borrows a token before it is routed: the
|
|
167
|
+
* charge cannot wait for the response, because admitting first and charging
|
|
168
|
+
* afterwards lets a burst of any size through at once.
|
|
169
|
+
*/
|
|
170
|
+
function admitRequest(
|
|
171
|
+
rl: RateLimitConfig,
|
|
172
|
+
path: string,
|
|
173
|
+
search: string,
|
|
174
|
+
req: Request,
|
|
175
|
+
hostname: string,
|
|
176
|
+
): RateLimitTicket | Response {
|
|
177
|
+
const asset = assetKey(path, search, req)
|
|
178
|
+
if (isProvenAsset(asset)) return { asset, skipped: true, slot: -1, refill: 0 }
|
|
179
|
+
|
|
180
|
+
const bucket = rateLimitBucket(compileRateLimit(rl), path)
|
|
181
|
+
const key = rateLimitKey(bucket, req, hostname)
|
|
182
|
+
const slot = rateLimitSlot(bucketKey(bucket, key))
|
|
183
|
+
if (Bakery.sharedPool.consumeToken(slot, bucket.max, bucket.refill)) {
|
|
184
|
+
return { asset, skipped: false, slot, refill: bucket.refill }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Sampled: availability under flood: stdout is effectively synchronous on
|
|
188
|
+
// Windows, so a line per rejection replays the flood the limiter just
|
|
189
|
+
// absorbed as a logging flood. Sampled per bucket, so a client limited at
|
|
190
|
+
// sign-in and elsewhere gets a line for each.
|
|
191
|
+
const suppressed = sampleRateLimitLog(bucketKey(bucket, key))
|
|
192
|
+
if (suppressed !== null) {
|
|
193
|
+
const prefix = bucket.prefix
|
|
194
|
+
if (prefix && suppressed > 0) {
|
|
195
|
+
serveLog.RATE_LIMITED_ROUTE_SUPPRESSED({
|
|
196
|
+
ip: key,
|
|
197
|
+
prefix,
|
|
198
|
+
count: suppressed,
|
|
199
|
+
})
|
|
200
|
+
} else if (prefix) {
|
|
201
|
+
serveLog.RATE_LIMITED_ROUTE({ ip: key, prefix })
|
|
202
|
+
} else if (suppressed > 0) {
|
|
203
|
+
serveLog.RATE_LIMITED_SUPPRESSED({ ip: key, count: suppressed })
|
|
204
|
+
} else {
|
|
205
|
+
serveLog.RATE_LIMITED({ ip: key })
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return tooManyRequests(bucket.refill)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Settle the limiter's account with a request once its response is ready.
|
|
213
|
+
*
|
|
214
|
+
* Served as an asset: its URL is remembered, and a borrowed token goes back.
|
|
215
|
+
* Skipped the bucket as a proven asset and then was not one (a deleted file,
|
|
216
|
+
* a refused range, a guard that said no): the URL is forgotten and the
|
|
217
|
+
* request is charged after the fact, so a URL that has started failing costs
|
|
218
|
+
* what any other request costs. Anything else keeps the token it spent.
|
|
219
|
+
*/
|
|
220
|
+
function settleRequest(
|
|
221
|
+
ticket: RateLimitTicket,
|
|
222
|
+
rl: RateLimitConfig,
|
|
223
|
+
path: string,
|
|
224
|
+
req: Request,
|
|
225
|
+
hostname: string,
|
|
226
|
+
res: Response | undefined,
|
|
227
|
+
): void {
|
|
228
|
+
const handler = hostStore.getStore()?.handler
|
|
229
|
+
if (answeredAsAsset(handler, path, req, res?.status ?? 0)) {
|
|
230
|
+
proveAsset(ticket.asset)
|
|
231
|
+
if (!ticket.skipped)
|
|
232
|
+
Bakery.sharedPool.refundToken(ticket.slot, ticket.refill)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
if (!ticket.skipped) return
|
|
236
|
+
|
|
237
|
+
forgetAsset(ticket.asset)
|
|
238
|
+
const bucket = rateLimitBucket(compileRateLimit(rl), path)
|
|
239
|
+
const key = rateLimitKey(bucket, req, hostname)
|
|
240
|
+
Bakery.sharedPool.consumeToken(
|
|
241
|
+
rateLimitSlot(bucketKey(bucket, key)),
|
|
242
|
+
bucket.max,
|
|
243
|
+
bucket.refill,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
133
247
|
try {
|
|
248
|
+
// Before anything binds: a rate limit that cannot mean anything (a refill
|
|
249
|
+
// of 0, a prefix without its slash) stops the boot here, naming the field,
|
|
250
|
+
// in the catch below.
|
|
251
|
+
checkRateLimits(Bakery.config)
|
|
252
|
+
|
|
134
253
|
Bakery.server = Bun.serve({
|
|
135
254
|
port: PORT,
|
|
136
255
|
hostname: Bakery.config.host,
|
|
@@ -150,26 +269,30 @@ try {
|
|
|
150
269
|
const path = url.pathname
|
|
151
270
|
req.startNs = Bun.nanoseconds()
|
|
152
271
|
req.__hostname = hostname
|
|
153
|
-
|
|
272
|
+
|
|
273
|
+
// Before the limiter, whose `keyBy` may key on the account. A store
|
|
274
|
+
// that cannot be read answers the request as an error: carrying on
|
|
275
|
+
// without the session would treat every signed-in visitor as signed
|
|
276
|
+
// out for as long as the store is down.
|
|
277
|
+
const loading = Session.attach(req)
|
|
278
|
+
if (loading) {
|
|
279
|
+
const [loadError] = await Try.catch(loading)
|
|
280
|
+
if (loadError) {
|
|
281
|
+
Bakery.sharedPool.incrementCounter(COUNTER_SLOTS.TOTAL_ERRORS, 1)
|
|
282
|
+
serveLog.SESSION_LOAD_ERR({ error: errorMsg(loadError) })
|
|
283
|
+
return await processResponse(
|
|
284
|
+
await handleRequestError(path, req, loadError),
|
|
285
|
+
req,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
154
289
|
|
|
155
290
|
const rl = Bakery.config.rateLimit
|
|
291
|
+
let ticket: RateLimitTicket | null = null
|
|
156
292
|
if (rl) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// Sampled: availability under flood: stdout is effectively
|
|
161
|
-
// synchronous on Windows, so a line per rejection replays the
|
|
162
|
-
// flood the limiter just absorbed as a logging flood.
|
|
163
|
-
const suppressed = sampleRateLimitLog(key)
|
|
164
|
-
if (suppressed !== null) {
|
|
165
|
-
if (suppressed > 0) {
|
|
166
|
-
serveLog.RATE_LIMITED_SUPPRESSED({ ip: key, count: suppressed })
|
|
167
|
-
} else {
|
|
168
|
-
serveLog.RATE_LIMITED({ ip: key })
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return tooManyRequests(rl.refill)
|
|
172
|
-
}
|
|
293
|
+
const admitted = admitRequest(rl, path, url.search, req, hostname)
|
|
294
|
+
if (admitted instanceof Response) return admitted
|
|
295
|
+
ticket = admitted
|
|
173
296
|
}
|
|
174
297
|
|
|
175
298
|
Bakery.sharedPool.incrementCounter(COUNTER_SLOTS.TOTAL_REQUESTS, 1)
|
|
@@ -197,7 +320,9 @@ try {
|
|
|
197
320
|
COUNTER_SLOTS.LATENCY_SUM_MS,
|
|
198
321
|
elapsedMs,
|
|
199
322
|
)
|
|
200
|
-
|
|
323
|
+
const res = await processResponse(resp, req)
|
|
324
|
+
if (rl && ticket) settleRequest(ticket, rl, path, req, hostname, res)
|
|
325
|
+
return res
|
|
201
326
|
})
|
|
202
327
|
},
|
|
203
328
|
|