@bakery-framework/cli 2.1.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/cli",
3
- "version": "2.1.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.1.2"
42
+ "@bakery-framework/core": "^2.2.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@bakery-framework/orm": "^2.1.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
- const decision = classifySchemaSync({
61
- force: process.argv.includes('--sync') || process.argv.includes('-s'),
62
- currentHash,
63
- storedHash: stored?.trim() || null,
64
- // Only meaningful for the default SQLite target; with a DB_URL there is
65
- // no local file to stat, and the hash (which covers DB_URL) plus `--sync`
66
- // are the levers for an externally reset database.
67
- dbMissing:
68
- !process.env.DB_URL &&
69
- !process.env.DATABASE_URL &&
70
- !(await Bun.file(`${Bakery.dataDir}/server.db`).exists()),
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 === 'skip') {
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 run() resolves: a failed or aborted sync must leave
91
- // the previous hash (or none) behind so the next boot re-syncs.
92
- if (currentHash) {
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
- await SyncService.run()
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 { getClientIp } from '@bakery-framework/core/utils/http'
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 three decisions `worker.ts`'s `fetch` makes that are pure functions of
7
- * their arguments.
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
- * Which token bucket this request spends from.
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
- rl: RateLimitConfig,
222
+ bucket: { keyBy?: RateLimitKey },
52
223
  req: Request,
53
224
  hostname: string,
54
225
  ): string {
55
- return (rl.keyBy ? rl.keyBy(req) : getClientIp(req)) || hostname
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
- * Must match `RATE_LIMIT_SLOT_COUNT` there: `consumeToken()` fails closed on an
7
- * out-of-range slot, so a value that is too large would deny every request that
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 = 1024
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 { deferredValue, Try } from '@bakery-framework/core/utils/common'
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 { isErrorResult, rateLimitKey, tooManyRequests } from './pipeline'
27
- import { rateLimitSlot, sampleRateLimitLog } from './rate-limit'
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
- deferredValue(req, 'session', Session.from)
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 key = rateLimitKey(rl, req, hostname)
158
- const slot = rateLimitSlot(key)
159
- if (!Bakery.sharedPool.consumeToken(slot, rl.max, rl.refill)) {
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
- return processResponse(resp, req)
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