@bakery-framework/cli 1.2.3 → 2.0.0-alpha.11

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": "1.2.3",
3
+ "version": "2.0.0-alpha.11",
4
4
  "description": "Bakery entry point and process supervision: mode dispatch, dev watcher, cluster.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -36,13 +36,13 @@
36
36
  "!src/tests"
37
37
  ],
38
38
  "engines": {
39
- "bun": ">=1.3.14"
39
+ "bun": ">=1.4.0"
40
40
  },
41
41
  "dependencies": {
42
- "@bakery-framework/core": "^1.0.0"
42
+ "@bakery-framework/core": "^2.0.0-alpha.11"
43
43
  },
44
44
  "peerDependencies": {
45
- "@bakery-framework/orm": "^1.0.0"
45
+ "@bakery-framework/orm": "^2.0.0-alpha.11"
46
46
  },
47
47
  "peerDependenciesMeta": {
48
48
  "@bakery-framework/orm": {
package/src/args.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Command-line argument parsing for the `bakery` bin.
3
+ *
4
+ * Split out of `index.ts` so it can be tested: that file is the mode
5
+ * dispatcher, and importing it boots a server. Nothing here reads
6
+ * `process.argv` — the caller passes the slice, which is also what lets a test
7
+ * state the argv it means.
8
+ */
9
+
10
+ /** Upper bound on the auto-detected worker count. */
11
+ export const MAX_AUTO_THREADS = 8
12
+
13
+ /** Fallback when `navigator.hardwareConcurrency` is unavailable. */
14
+ export const FALLBACK_THREADS = 4
15
+
16
+ /**
17
+ * Worker count for a bare `--threads` / `-t`, with no number after it.
18
+ *
19
+ * Capped rather than uncapped: past ~8 the workers contend on the same
20
+ * loopback accept queue and the shared pool's atomics, and a 64-core CI box
21
+ * forking 64 servers is not what the flag means.
22
+ */
23
+ export function autoThreadCount(): number {
24
+ return Math.min(
25
+ Math.max(1, navigator.hardwareConcurrency || FALLBACK_THREADS),
26
+ MAX_AUTO_THREADS,
27
+ )
28
+ }
29
+
30
+ /**
31
+ * How many cluster workers `--threads` asks for, or `null` for "no cluster".
32
+ *
33
+ * `null` and `1` are different answers and `index.ts` branches on the
34
+ * difference: `null` takes the single-process path, `1` still goes through
35
+ * `handleThreadsMaster` with one worker.
36
+ *
37
+ * Both spellings of each flag are accepted (`--threads 4`, `--threads=4`, and
38
+ * the `-t` forms). A flag with a non-numeric or absent value is not an error —
39
+ * it falls back to `autoThreadCount()`, so `--threads --dev` asks for the
40
+ * default rather than rejecting. `Math.max(1, …)` is what keeps `--threads 0`
41
+ * from requesting a cluster with no workers in it.
42
+ */
43
+ export function parseThreadsOption(args: string[]): number | null {
44
+ for (let i = 0; i < args.length; i++) {
45
+ const arg = args[i]
46
+ if (arg === '--threads' || arg === '-t') {
47
+ const next = args[i + 1]
48
+ if (next && /^\d+$/.test(next)) {
49
+ return Math.max(1, parseInt(next, 10))
50
+ }
51
+ return autoThreadCount()
52
+ }
53
+ if (arg.startsWith('--threads=') || arg.startsWith('-t=')) {
54
+ const val = arg.split('=')[1]
55
+ if (val && /^\d+$/.test(val)) {
56
+ return Math.max(1, parseInt(val, 10))
57
+ }
58
+ return autoThreadCount()
59
+ }
60
+ }
61
+ return null
62
+ }
package/src/dev.ts CHANGED
@@ -37,66 +37,6 @@ try {
37
37
  process.exit(1)
38
38
  }
39
39
 
40
- /**
41
- * Hash everything the boot-time schema sync reads: the schema source files
42
- * (resolved with the same probe order as orm/sync/load.ts — configured path,
43
- * then the `orm/` folder layout, then a root `schema.ts`) plus the DB target,
44
- * since switching `DB_URL` changes what "synced" means.
45
- *
46
- * Returns `null` for any indeterminate state — a configured path that does not
47
- * exist, an unreadable file — so `classifySchemaSync` fails closed into
48
- * re-syncing. Total absence of a schema is *not* indeterminate (it is a
49
- * supported state for the defaults) and hashes to a stable value.
50
- */
51
- async function computeSchemaHash(
52
- configured: string | undefined,
53
- ): Promise<string | null> {
54
- const { fs, Try } = await import('@bakery-framework/core/utils')
55
-
56
- const files: string[] = []
57
- const scanDir = async (dir: string) => {
58
- for await (const file of new Bun.Glob('*.ts').scan({
59
- cwd: dir,
60
- absolute: true,
61
- })) {
62
- files.push(file)
63
- }
64
- }
65
-
66
- const [error] = await Try.catch(
67
- (async () => {
68
- if (configured) {
69
- const path = fs.resolve(fs.cwd, configured)
70
- if (await fs.isDir(path)) {
71
- await scanDir(path)
72
- } else if (await Bun.file(path).exists()) {
73
- files.push(path)
74
- } else {
75
- // Configured-but-missing is SyncService's SCHEMA_NOT_FOUND case:
76
- // let the sync run and produce its proper error.
77
- throw new Error(`configured schema path not found: ${path}`)
78
- }
79
- } else if (await Bun.file(`${fs.cwd}/orm/index.ts`).exists()) {
80
- await scanDir(`${fs.cwd}/orm`)
81
- } else if (await Bun.file(`${fs.cwd}/schema.ts`).exists()) {
82
- files.push(`${fs.cwd}/schema.ts`)
83
- }
84
- })(),
85
- )
86
- if (error) return null
87
-
88
- files.sort()
89
- const hasher = new Bun.CryptoHasher('sha256')
90
- hasher.update(process.env.DB_URL || process.env.DATABASE_URL || '')
91
- for (const file of files) {
92
- hasher.update(`\0${file}\0`)
93
- const [readError, content] = await Try.catch(Bun.file(file).text())
94
- if (readError) return null
95
- hasher.update(content)
96
- }
97
- return hasher.digest('hex')
98
- }
99
-
100
40
  // The entire block is schema sync, so with no ORM there is nothing here to do.
101
41
  // Silently: a dev boot of an app that never had a database should not report
102
42
  // the absence of one on every reload.
@@ -108,6 +48,10 @@ if (hasORM()) {
108
48
  )
109
49
  const { schemaFromConfig } = await import('@bakery-framework/orm/sync/load')
110
50
  const { Try } = await import('@bakery-framework/core/utils')
51
+ // Dynamic, like every other import in this block: `schema-hash.ts` is only
52
+ // needed when the ORM is installed, and a static import would pull it (and
53
+ // its own dynamic core barrel) onto the no-ORM boot path.
54
+ const { computeSchemaHash } = await import('./schema-hash')
111
55
 
112
56
  const hashFile = `${Bakery.cacheDir}/schema-sync.hash`
113
57
  const currentHash = await computeSchemaHash(schemaFromConfig(config))
package/src/index.ts CHANGED
@@ -4,34 +4,15 @@ import '@bakery-framework/core/core/init'
4
4
  // Safe as a static import: `core/port` reads `process.env` and imports nothing,
5
5
  // so it cannot be the edge that closes core's barrel cycle.
6
6
  import { applyPortFlag } from '@bakery-framework/core/core/port'
7
+ // Safe as a static import for the same reason as `core/port`: `args.ts` imports
8
+ // nothing at all.
9
+ import { parseThreadsOption } from './args'
7
10
 
8
11
  const isDev = import.meta.env.DEV
9
12
  const isDevWorker = import.meta.env.DEV_WORKER
10
13
  const isThreadWorker = import.meta.env.THREAD_WORKER
11
14
 
12
- function getThreadsOption(): number | null {
13
- const args = process.argv.slice(2)
14
- for (let i = 0; i < args.length; i++) {
15
- const arg = args[i]
16
- if (arg === '--threads' || arg === '-t') {
17
- const next = args[i + 1]
18
- if (next && /^\d+$/.test(next)) {
19
- return Math.max(1, parseInt(next, 10))
20
- }
21
- return Math.min(Math.max(1, navigator.hardwareConcurrency || 4), 8)
22
- }
23
- if (arg.startsWith('--threads=') || arg.startsWith('-t=')) {
24
- const val = arg.split('=')[1]
25
- if (val && /^\d+$/.test(val)) {
26
- return Math.max(1, parseInt(val, 10))
27
- }
28
- return Math.min(Math.max(1, navigator.hardwareConcurrency || 4), 8)
29
- }
30
- }
31
- return null
32
- }
33
-
34
- const threadsOption = getThreadsOption()
15
+ const threadsOption = parseThreadsOption(process.argv.slice(2))
35
16
 
36
17
  // Before any mode takes over, and before the config is read: `applyPortFlag`
37
18
  // writes `process.env.PORT`, which is what the worker, the startup banner and
@@ -0,0 +1,69 @@
1
+ import { is } from '@bakery-framework/core/utils/common'
2
+ import { getClientIp } from '@bakery-framework/core/utils/http'
3
+ import { retryAfterSeconds } from './rate-limit'
4
+
5
+ /**
6
+ * The three decisions `worker.ts`'s `fetch` makes that are pure functions of
7
+ * their arguments.
8
+ *
9
+ * They were inline in the `Bun.serve` callback, which is unreachable from a
10
+ * test: `worker.ts` calls `Bun.serve` at module scope, so importing it binds a
11
+ * port. Nothing about them is server-specific, so they live here and the
12
+ * callback calls them — the serve options are otherwise unchanged.
13
+ */
14
+
15
+ /** The `rateLimit` config with the `false` (disabled) arm removed. */
16
+ export type RateLimitConfig = Exclude<ProcessedAppConfig['rateLimit'], false>
17
+
18
+ /**
19
+ * Whether a handler's return value should be routed into the error registry.
20
+ *
21
+ * Two shapes count, and they are not interchangeable. A `Response` is an error
22
+ * by its status; anything else is an error by carrying `errorCode`, which is
23
+ * what `ErrorHandler.extractErrorData` reads.
24
+ *
25
+ * The `is.object` guard is load-bearing twice over. `'errorCode' in res` is a
26
+ * `TypeError` on a primitive, and `Try.return`'s failure sentinel is a
27
+ * `symbol` — so the guard is what keeps the rejection path from throwing
28
+ * inside the code that exists to handle throws. Note `is.object([])` is
29
+ * deliberately `true` (see CLAUDE.md); an array simply has no `errorCode`.
30
+ *
31
+ * 400 is included: `>= 400`, not `> 400`. A handler returning a bare
32
+ * `new Response(…, {status: 404})` must still reach `TSXErrorHandler` and get
33
+ * the app's error page rather than an empty body.
34
+ */
35
+ export function isErrorResult(res: unknown): boolean {
36
+ if (res instanceof Response) return res.status >= 400
37
+ return is.object(res) && 'errorCode' in res
38
+ }
39
+
40
+ /**
41
+ * Which token bucket this request spends from.
42
+ *
43
+ * The `|| hostname` is not a tidy-up. An empty key is a *valid* string that
44
+ * hashes to one fixed slot, so every client whose IP could not be determined —
45
+ * which is all of them when `Bakery.server` is not yet assigned, and any of
46
+ * them behind a proxy with `trustProxy` off — would share a single bucket and
47
+ * 429 each other. Falling back to the hostname keeps the collision at
48
+ * per-host, which is the coarsest grouping that is still meaningful.
49
+ */
50
+ export function rateLimitKey(
51
+ rl: RateLimitConfig,
52
+ req: Request,
53
+ hostname: string,
54
+ ): string {
55
+ return (rl.keyBy ? rl.keyBy(req) : getClientIp(req)) || hostname
56
+ }
57
+
58
+ /**
59
+ * The 429 a rejected request receives.
60
+ *
61
+ * `Retry-After` is whole seconds per RFC 9110 and never 0 — see
62
+ * `retryAfterSeconds`.
63
+ */
64
+ export function tooManyRequests(refill: number): Response {
65
+ return new Response('Too Many Requests', {
66
+ status: 429,
67
+ headers: { 'Retry-After': String(retryAfterSeconds(refill)) },
68
+ })
69
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Hash everything the boot-time schema sync reads: the schema source files
3
+ * (resolved with the same probe order as orm/sync/load.ts — configured path,
4
+ * then the `orm/` folder layout, then a root `schema.ts`) plus the DB target,
5
+ * since switching `DB_URL` changes what "synced" means.
6
+ *
7
+ * Returns `null` for any indeterminate state — a configured path that does not
8
+ * exist, an unreadable file — so `classifySchemaSync` fails closed into
9
+ * re-syncing. Total absence of a schema is *not* indeterminate (it is a
10
+ * supported state for the defaults) and hashes to a stable value.
11
+ *
12
+ * `base` defaults to the app's cwd, which is what `dev.ts` wants and the only
13
+ * value it passes. It is a parameter at all because `fs.cwd` is a module-level
14
+ * constant evaluated at import time, so a test cannot reach this function's
15
+ * probe any other way without `process.chdir` before the import — process-global
16
+ * state of exactly the kind convention 9 exists to keep out of the suite.
17
+ *
18
+ * The `@bakery-framework/core/utils` import stays dynamic, as it was inline in
19
+ * `dev.ts`: that barrel must not enter the module graph at `dev.ts` import time.
20
+ */
21
+ export async function computeSchemaHash(
22
+ configured: string | undefined,
23
+ base?: string,
24
+ ): Promise<string | null> {
25
+ const { fs, Try } = await import('@bakery-framework/core/utils')
26
+ const cwd = base ?? fs.cwd
27
+
28
+ const files: string[] = []
29
+ const scanDir = async (dir: string) => {
30
+ for await (const file of new Bun.Glob('*.ts').scan({
31
+ cwd: dir,
32
+ absolute: true,
33
+ })) {
34
+ files.push(file)
35
+ }
36
+ }
37
+
38
+ const [error] = await Try.catch(
39
+ (async () => {
40
+ if (configured) {
41
+ const path = fs.resolve(cwd, configured)
42
+ if (await fs.isDir(path)) {
43
+ await scanDir(path)
44
+ } else if (await Bun.file(path).exists()) {
45
+ files.push(path)
46
+ } else {
47
+ // Configured-but-missing is SyncService's SCHEMA_NOT_FOUND case:
48
+ // let the sync run and produce its proper error.
49
+ throw new Error(`configured schema path not found: ${path}`)
50
+ }
51
+ } else if (await Bun.file(`${cwd}/orm/index.ts`).exists()) {
52
+ await scanDir(`${cwd}/orm`)
53
+ } else if (await Bun.file(`${cwd}/schema.ts`).exists()) {
54
+ files.push(`${cwd}/schema.ts`)
55
+ }
56
+ })(),
57
+ )
58
+ if (error) return null
59
+
60
+ files.sort()
61
+ const hasher = new Bun.CryptoHasher('sha256')
62
+ hasher.update(process.env.DB_URL || process.env.DATABASE_URL || '')
63
+ for (const file of files) {
64
+ hasher.update(`\0${file}\0`)
65
+ const [readError, content] = await Try.catch(Bun.file(file).text())
66
+ if (readError) return null
67
+ hasher.update(content)
68
+ }
69
+ return hasher.digest('hex')
70
+ }
package/src/worker.ts CHANGED
@@ -19,15 +19,12 @@ 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, is, Try } from '@bakery-framework/core/utils/common'
23
- import { getClientIp } from '@bakery-framework/core/utils/http'
22
+ import { deferredValue, Try } from '@bakery-framework/core/utils/common'
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
- rateLimitSlot,
28
- retryAfterSeconds,
29
- sampleRateLimitLog,
30
- } from './rate-limit'
26
+ import { isErrorResult, rateLimitKey, tooManyRequests } from './pipeline'
27
+ import { rateLimitSlot, sampleRateLimitLog } from './rate-limit'
31
28
  import { runShutdownSequence } from './shutdown'
32
29
 
33
30
  /**
@@ -142,10 +139,10 @@ try {
142
139
  maxRequestBodySize: Bakery.config.maxBodySize,
143
140
 
144
141
  async fetch(req) {
145
- // Parsed once here and attached: `new URL` measures ~1.7us and the
146
- // router, body parser and proxy all want the same parse.
147
- const url = new URL(req.url)
148
- ;(req as any).__parsedUrl = url
142
+ // Memoized for the lifetime of the request: the router, body parser and
143
+ // proxy all want the same parse, and every one of them asks for it the
144
+ // same way.
145
+ const url = parsedUrl(req)
149
146
  const hostname = getHostname(req)
150
147
  const hostConfig = resolveHostConfig(hostname)
151
148
 
@@ -157,7 +154,7 @@ try {
157
154
 
158
155
  const rl = Bakery.config.rateLimit
159
156
  if (rl) {
160
- const key = (rl.keyBy ? rl.keyBy(req) : getClientIp(req)) || hostname
157
+ const key = rateLimitKey(rl, req, hostname)
161
158
  const slot = rateLimitSlot(key)
162
159
  if (!Bakery.sharedPool.consumeToken(slot, rl.max, rl.refill)) {
163
160
  // Sampled — availability under flood: stdout is effectively
@@ -171,12 +168,7 @@ try {
171
168
  serveLog.RATE_LIMITED({ ip: key })
172
169
  }
173
170
  }
174
- return new Response('Too Many Requests', {
175
- status: 429,
176
- headers: {
177
- 'Retry-After': String(retryAfterSeconds(rl.refill)),
178
- },
179
- })
171
+ return tooManyRequests(rl.refill)
180
172
  }
181
173
  }
182
174
 
@@ -186,10 +178,7 @@ try {
186
178
  async function fetchHandler() {
187
179
  const res = await handleRequest(req)
188
180
 
189
- const isResError = res instanceof Response && res.status >= 400
190
- const isObjError = is.object(res) && 'errorCode' in res
191
-
192
- if (isResError || isObjError) {
181
+ if (isErrorResult(res)) {
193
182
  Bakery.sharedPool.incrementCounter(COUNTER_SLOTS.TOTAL_ERRORS, 1)
194
183
  return await handleRequestError(path, req, res)
195
184
  }