@bakery-framework/cli 1.0.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/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Kyle Cyrus Santos Obille
2
+
3
+ The Software is provided subject to the standard MIT License, as detailed below, with the addition of the Commons Clause v1.0.
4
+
5
+ The Commons Clause v1.0
6
+
7
+ The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
8
+
9
+ Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software.
10
+
11
+ For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice.
12
+
13
+ Standard MIT License
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software (subject to the Commons Clause condition above), and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @bakery-framework/cli
2
+
3
+ The `bakery` binary: process-mode dispatch, the dev watcher, and clustering for
4
+ [Bakery](https://github.com/obillekyle/bakery).
5
+
6
+ **Bun only.** Ships TypeScript source with no build step.
7
+
8
+ ```bash
9
+ bun add @bakery-framework/cli
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```json
15
+ {
16
+ "scripts": {
17
+ "dev": "bakery --dev",
18
+ "start": "bakery"
19
+ }
20
+ }
21
+ ```
22
+
23
+ | Command | Mode |
24
+ | --- | --- |
25
+ | `bakery --dev` | Dev server: file watcher, live reload, on-demand compile |
26
+ | `bakery` | Production |
27
+ | `bakery --threads 4` | Production cluster of 4 workers (ignored under `--dev`) |
28
+ | `bakery --sync` | Run schema sync, **then boot**. Not a standalone sync |
29
+
30
+ There is no `--port` flag. The port resolves as `PORT` → `port` in
31
+ `server.config.ts` → `3000`. A malformed `PORT` is a boot error rather than a
32
+ fallback: `PORT=3000x` exits 1 instead of binding somewhere random.
33
+
34
+ For a standalone schema sync in a deploy step, drive `SyncService` from
35
+ `@bakery-framework/orm/sync` directly — `--sync` starts a server afterwards.
36
+
37
+ ## License
38
+
39
+ MIT with the Commons Clause v1.0 — see [LICENSE](./LICENSE).
40
+
41
+ **Not an OSI-approved licence.** The Commons Clause removes the right to *sell*
42
+ the software — meaning to charge for a product or service whose value derives
43
+ substantially from it, hosting and support included. Everything else the MIT
44
+ licence grants is unchanged: use it, modify it, ship it inside your own product.
45
+ If your organisation only permits OSI-approved dependencies, this will not pass
46
+ that check.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@bakery-framework/cli",
3
+ "version": "1.0.0",
4
+ "description": "Bakery entry point and process supervision: mode dispatch, dev watcher, cluster.",
5
+ "keywords": [
6
+ "bakery",
7
+ "bun",
8
+ "cli",
9
+ "dev-server",
10
+ "hot-reload",
11
+ "cluster"
12
+ ],
13
+ "author": "obillekyle",
14
+ "license": "SEE LICENSE IN LICENSE",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/obillekyle/bakery.git",
18
+ "directory": "packages/cli"
19
+ },
20
+ "homepage": "https://github.com/obillekyle/bakery#readme",
21
+ "bugs": "https://github.com/obillekyle/bakery/issues",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "type": "module",
26
+ "main": "./src/index.ts",
27
+ "bin": {
28
+ "bakery": "./src/index.ts"
29
+ },
30
+ "exports": {
31
+ ".": "./src/index.ts"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "!src/**/*.test.ts",
36
+ "!src/tests"
37
+ ],
38
+ "engines": {
39
+ "bun": ">=1.3.14"
40
+ },
41
+ "dependencies": {
42
+ "@bakery-framework/core": "^1.0.0"
43
+ },
44
+ "peerDependencies": {
45
+ "@bakery-framework/orm": "^1.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@bakery-framework/orm": {
49
+ "optional": true
50
+ }
51
+ }
52
+ }
package/src/dev.ts ADDED
@@ -0,0 +1,158 @@
1
+ import '@bakery-framework/core/core/init'
2
+ import { errorMsg, log, serveLog } from '@bakery-framework/core/logger'
3
+ import { hasORM } from './orm'
4
+
5
+ log({
6
+ by: 'process',
7
+ msg: `Starting server (PID: ${process.pid})...`,
8
+ })
9
+ serveLog.STARTING({ mode: 'development' })
10
+
11
+ let config: unknown
12
+
13
+ try {
14
+ const { initConfig } = await import('@bakery-framework/core/core/config')
15
+ const { initImportMap, initHostImportMaps } = await import(
16
+ '@bakery-framework/core/utils/http'
17
+ )
18
+ const { setupPlugins } = await import('@bakery-framework/core/startup')
19
+ const { syncTSConfigPaths } = await import(
20
+ '@bakery-framework/core/compiler/tsconfig-sync'
21
+ )
22
+
23
+ config = await initConfig()
24
+ // Still before initImportMap(): a plugin's setup() may contribute entries.
25
+ // setupPlugins() memoises, so setupServer()'s later call is a no-op here.
26
+ await setupPlugins()
27
+ await initImportMap()
28
+ initHostImportMaps()
29
+ await syncTSConfigPaths()
30
+ } catch (error: any) {
31
+ // errorMsg() already yields the stack, so the structured line below carries
32
+ // everything the raw console.error used to duplicate.
33
+ serveLog.UNHANDLED_ERR({ error: `Config init failed: ${errorMsg(error)}` })
34
+ process.exit(1)
35
+ }
36
+
37
+ /**
38
+ * Hash everything the boot-time schema sync reads: the schema source files
39
+ * (resolved with the same probe order as orm/sync/load.ts — configured path,
40
+ * then the `orm/` folder layout, then a root `schema.ts`) plus the DB target,
41
+ * since switching `DB_URL` changes what "synced" means.
42
+ *
43
+ * Returns `null` for any indeterminate state — a configured path that does not
44
+ * exist, an unreadable file — so `classifySchemaSync` fails closed into
45
+ * re-syncing. Total absence of a schema is *not* indeterminate (it is a
46
+ * supported state for the defaults) and hashes to a stable value.
47
+ */
48
+ async function computeSchemaHash(
49
+ configured: string | undefined,
50
+ ): Promise<string | null> {
51
+ const { fs, Try } = await import('@bakery-framework/core/utils')
52
+
53
+ const files: string[] = []
54
+ const scanDir = async (dir: string) => {
55
+ for await (const file of new Bun.Glob('*.ts').scan({
56
+ cwd: dir,
57
+ absolute: true,
58
+ })) {
59
+ files.push(file)
60
+ }
61
+ }
62
+
63
+ const [error] = await Try.catch(
64
+ (async () => {
65
+ if (configured) {
66
+ const path = fs.resolve(fs.cwd, configured)
67
+ if (await fs.isDir(path)) {
68
+ await scanDir(path)
69
+ } else if (await Bun.file(path).exists()) {
70
+ files.push(path)
71
+ } else {
72
+ // Configured-but-missing is SyncService's SCHEMA_NOT_FOUND case:
73
+ // let the sync run and produce its proper error.
74
+ throw new Error(`configured schema path not found: ${path}`)
75
+ }
76
+ } else if (await Bun.file(`${fs.cwd}/orm/index.ts`).exists()) {
77
+ await scanDir(`${fs.cwd}/orm`)
78
+ } else if (await Bun.file(`${fs.cwd}/schema.ts`).exists()) {
79
+ files.push(`${fs.cwd}/schema.ts`)
80
+ }
81
+ })(),
82
+ )
83
+ if (error) return null
84
+
85
+ files.sort()
86
+ const hasher = new Bun.CryptoHasher('sha256')
87
+ hasher.update(process.env.DB_URL || process.env.DATABASE_URL || '')
88
+ for (const file of files) {
89
+ hasher.update(`\0${file}\0`)
90
+ const [readError, content] = await Try.catch(Bun.file(file).text())
91
+ if (readError) return null
92
+ hasher.update(content)
93
+ }
94
+ return hasher.digest('hex')
95
+ }
96
+
97
+ // The entire block is schema sync, so with no ORM there is nothing here to do.
98
+ // Silently: a dev boot of an app that never had a database should not report
99
+ // the absence of one on every reload.
100
+ if (hasORM()) {
101
+ try {
102
+ const { Bakery } = await import('@bakery-framework/core')
103
+ const { classifySchemaSync } = await import(
104
+ '@bakery-framework/core/compiler'
105
+ )
106
+ const { schemaFromConfig } = await import('@bakery-framework/orm/sync/load')
107
+ const { Try } = await import('@bakery-framework/core/utils')
108
+
109
+ const hashFile = `${Bakery.cacheDir}/schema-sync.hash`
110
+ const currentHash = await computeSchemaHash(schemaFromConfig(config))
111
+ const [, stored] = await Try.catch(Bun.file(hashFile).text())
112
+
113
+ const decision = classifySchemaSync({
114
+ force: process.argv.includes('--sync') || process.argv.includes('-s'),
115
+ currentHash,
116
+ storedHash: stored?.trim() || null,
117
+ // Only meaningful for the default SQLite target; with a DB_URL there is
118
+ // no local file to stat, and the hash (which covers DB_URL) plus `--sync`
119
+ // are the levers for an externally reset database.
120
+ dbMissing:
121
+ !process.env.DB_URL &&
122
+ !process.env.DATABASE_URL &&
123
+ !(await Bun.file(`${Bakery.dataDir}/server.db`).exists()),
124
+ })
125
+
126
+ if (decision === 'skip') {
127
+ serveLog.SCHEMA_SYNC_SKIP()
128
+ // Only on the skip path. When a sync runs it reports drift itself, from
129
+ // the plan it just built; here nothing else would ever look. Measured at
130
+ // 2.7ms median against apps/example (4 tables) — it is one introspection
131
+ // pass, so it grows with table count, which is why it is not on the path
132
+ // that is about to introspect anyway.
133
+ const { initDB, connection } = await import(
134
+ '@bakery-framework/orm/connection'
135
+ )
136
+ const { detectDrift } = await import('@bakery-framework/orm/sync/ledger')
137
+ await initDB()
138
+ const drift = await detectDrift(connection)
139
+ if (drift) serveLog.SCHEMA_DRIFT({ reason: drift.reason })
140
+ } else {
141
+ const { SyncService } = await import('@bakery-framework/orm/sync')
142
+ await SyncService.run()
143
+ // Recorded only after run() resolves: a failed or aborted sync must leave
144
+ // the previous hash (or none) behind so the next boot re-syncs.
145
+ if (currentHash) {
146
+ const [writeError] = await Try.catch(Bun.write(hashFile, currentHash))
147
+ // A failed record is tolerated silently: its only consequence is that
148
+ // the next boot syncs again, which is the safe direction.
149
+ void writeError
150
+ }
151
+ }
152
+ } catch (error: any) {
153
+ serveLog.UNHANDLED_ERR({ error: `Startup failed: ${errorMsg(error)}` })
154
+ process.exit(1)
155
+ }
156
+ }
157
+
158
+ await import('./worker')
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import '@bakery-framework/core/core/init'
4
+
5
+ const isDev = import.meta.env.DEV
6
+ const isDevWorker = import.meta.env.DEV_WORKER
7
+ const isThreadWorker = import.meta.env.THREAD_WORKER
8
+
9
+ function getThreadsOption(): number | null {
10
+ const args = process.argv.slice(2)
11
+ for (let i = 0; i < args.length; i++) {
12
+ const arg = args[i]
13
+ if (arg === '--threads' || arg === '-t') {
14
+ const next = args[i + 1]
15
+ if (next && /^\d+$/.test(next)) {
16
+ return Math.max(1, parseInt(next, 10))
17
+ }
18
+ return Math.min(Math.max(1, navigator.hardwareConcurrency || 4), 8)
19
+ }
20
+ if (arg.startsWith('--threads=') || arg.startsWith('-t=')) {
21
+ const val = arg.split('=')[1]
22
+ if (val && /^\d+$/.test(val)) {
23
+ return Math.max(1, parseInt(val, 10))
24
+ }
25
+ return Math.min(Math.max(1, navigator.hardwareConcurrency || 4), 8)
26
+ }
27
+ }
28
+ return null
29
+ }
30
+
31
+ const threadsOption = getThreadsOption()
32
+
33
+ if (
34
+ (process.argv.includes('--sync') || process.argv.includes('-s')) &&
35
+ !isDevWorker &&
36
+ !isThreadWorker
37
+ ) {
38
+ // The one place absence is an *error* rather than a skip. Everywhere else the
39
+ // ORM is missing because the app never wanted one; here the user typed
40
+ // `--sync`, which is a request to sync a database, and quietly doing nothing
41
+ // would look like it worked.
42
+ const { hasORM, ORM_MISSING } = await import('./orm')
43
+ if (!hasORM()) {
44
+ const { serveLog } = await import('@bakery-framework/core/logger')
45
+ serveLog.UNHANDLED_ERR({ error: `--sync: ${ORM_MISSING}` })
46
+ process.exit(1)
47
+ }
48
+ const { SyncService } = await import('@bakery-framework/orm/sync')
49
+ await SyncService.run()
50
+ }
51
+ try {
52
+ if (threadsOption !== null && !isDevWorker && !isThreadWorker && !isDev) {
53
+ const { handleThreadsMaster } = await import('./threads')
54
+ await handleThreadsMaster(threadsOption)
55
+ } else if (!isDev) {
56
+ await import('./prod')
57
+ } else if (isDevWorker || isThreadWorker) {
58
+ await import('./dev')
59
+ } else {
60
+ await import('./watcher')
61
+ }
62
+ } catch (error: any) {
63
+ // Deliberately console.error and not the structured logger: this is the
64
+ // last-resort handler around the very imports that load the logger, so
65
+ // reaching for it here could throw and mask the original failure.
66
+ console.error('Fatal unhandled error during startup:', error?.stack || error)
67
+ process.exit(1)
68
+ }
package/src/orm.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Is `@bakery-framework/orm` installed?
3
+ *
4
+ * The CLI declares it as an **optional peer**, so an app scaffolded with
5
+ * `--no-orm` never downloads it. Every use of it in this package is already an
6
+ * `await import()`, so its absence is not a module-graph problem — it is a
7
+ * question this file answers once, before anything tries.
8
+ *
9
+ * **Absence and breakage must not be conflated, and telling them apart is the
10
+ * only reason this file exists.** The obvious implementation is to wrap the
11
+ * `await import()` in a try and treat a throw as "not installed", and it is
12
+ * wrong in the direction that costs data: a database that is configured but
13
+ * unreachable, a `DB_URL` with a typo, a schema module that throws at import —
14
+ * all of those would be swallowed as "no ORM here" and the server would boot
15
+ * happily with no database and no complaint. `initDB` failing has always been
16
+ * fatal and stays fatal. Only *resolution* failing is soft.
17
+ *
18
+ * So the question is asked of the resolver, which cannot be answered wrongly by
19
+ * a broken database: either the specifier resolves to a file on disk or it does
20
+ * not.
21
+ */
22
+
23
+ let resolved: boolean | null = null
24
+
25
+ /**
26
+ * True when `@bakery-framework/orm` can be resolved from this package.
27
+ *
28
+ * Memoised: it is asked on the boot path and once more per shutdown, the answer
29
+ * cannot change within a process, and the filesystem walk is not free.
30
+ *
31
+ * `import.meta.dir` is the resolution base on purpose — it is what a real
32
+ * `import` from this module would use, so the answer matches what the `await
33
+ * import()` calls below it will actually do. Resolving from `process.cwd()`
34
+ * would ask a different question (does the *app* see it) and could disagree,
35
+ * which is the kind of near-miss that produces a "sometimes it works" report.
36
+ */
37
+ export function hasORM(): boolean {
38
+ if (resolved !== null) return resolved
39
+ try {
40
+ // The subpath, not the package root: `exports` can name one and not the
41
+ // other, and this is the one every caller here actually imports.
42
+ Bun.resolveSync('@bakery-framework/orm/connection', import.meta.dir)
43
+ resolved = true
44
+ } catch {
45
+ // Not installed. The only expected outcome of this catch, and the reason
46
+ // it is allowed to be silent — see the note above about what is *not*
47
+ // routed through here.
48
+ resolved = false
49
+ }
50
+ return resolved
51
+ }
52
+
53
+ /**
54
+ * What to print when something needs the ORM and it is not there.
55
+ *
56
+ * One string, so the instruction is identical wherever it surfaces. It names
57
+ * the package rather than describing it: the fix is a single `bun add` and the
58
+ * message should be copy-pasteable.
59
+ */
60
+ export const ORM_MISSING =
61
+ 'This needs @bakery-framework/orm, which is not installed. It is an optional peer ' +
62
+ 'dependency of the CLI, so an app scaffolded without a database does not ' +
63
+ 'carry it. Add it with `bun add @bakery-framework/orm`.'
package/src/prod.ts ADDED
@@ -0,0 +1,49 @@
1
+ import '@bakery-framework/core/core/init'
2
+ import { errorMsg, log, serveLog } from '@bakery-framework/core/logger'
3
+ import { hasORM } from './orm'
4
+
5
+ log({
6
+ by: 'process',
7
+ msg: `Starting server (PID: ${process.pid})...`,
8
+ })
9
+ serveLog.STARTING({ mode: 'production' })
10
+
11
+ try {
12
+ const { initConfig } = await import('@bakery-framework/core/core/config')
13
+ const { setupPlugins } = await import('@bakery-framework/core/startup')
14
+ const { initImportMap, initHostImportMaps } = await import(
15
+ '@bakery-framework/core/utils/http'
16
+ )
17
+
18
+ await initConfig()
19
+ // Before initImportMap() so plugin-contributed entries land in it; memoised,
20
+ // so the later setupServer() call inside worker.ts does not repeat it.
21
+ await setupPlugins()
22
+ await initImportMap()
23
+ initHostImportMaps()
24
+ } catch (error: any) {
25
+ serveLog.UNHANDLED_ERR({ error: `Config init failed: ${errorMsg(error)}` })
26
+ process.exit(1)
27
+ }
28
+
29
+ // See the same guard in worker.ts: absent is fine, present-and-broken is fatal.
30
+ if (hasORM()) {
31
+ try {
32
+ const { initDB } = await import('@bakery-framework/orm/connection')
33
+ await initDB()
34
+ } catch (error: any) {
35
+ serveLog.UNHANDLED_ERR({
36
+ error: `Database initialization failed: ${errorMsg(error)}`,
37
+ })
38
+ process.exit(1)
39
+ }
40
+ }
41
+
42
+ try {
43
+ await import('./worker')
44
+ } catch (err: any) {
45
+ serveLog.UNHANDLED_ERR({
46
+ error: `Worker initialization failed: ${errorMsg(err)}`,
47
+ })
48
+ process.exit(1)
49
+ }
@@ -0,0 +1,96 @@
1
+ import { LRUCache } from '@bakery-framework/core/cache/lru'
2
+
3
+ /**
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.
10
+ */
11
+ export const RATE_LIMIT_SLOTS = 1024
12
+
13
+ /**
14
+ * Map a rate-limit key (client IP, or whatever `rateLimit.keyBy` returns) onto
15
+ * one of the shared pool's token buckets.
16
+ *
17
+ * `Bun.hash` returns a **u64 bigint**. The original
18
+ * `Number(Bun.hash(key)) % 1024` converted first, so every bit below 2^53 was
19
+ * rounded away before the modulo ever ran: measured over 20k client IPs that
20
+ * left 100 reachable buckets out of 1024 with **83% of all keys in bucket 0**.
21
+ * Rate limiting is on by default at `{max: 100, refill: 10}`, so in practice
22
+ * most clients shared one bucket with a sustained ceiling of 10 requests per
23
+ * second — one busy client 429'd everyone else, and an ordinary page load
24
+ * (many requests inside a single refill tick) tripped it on its own.
25
+ *
26
+ * Taking the modulo in bigint space and converting the small result keeps the
27
+ * low bits, which are the only ones that matter here. `wyhash` is what plain
28
+ * `Bun.hash` already calls; naming it is what makes the return type `bigint`
29
+ * instead of `number | bigint`.
30
+ */
31
+ export function rateLimitSlot(key: string): number {
32
+ return Number(Bun.hash.wyhash(key) % BigInt(RATE_LIMIT_SLOTS))
33
+ }
34
+
35
+ /**
36
+ * How long one RATE_LIMITED log line covers for a key. The "30s" in
37
+ * `RATE_LIMITED_SUPPRESSED`'s message text states this value; keep them in
38
+ * step.
39
+ */
40
+ export const RATE_LIMIT_LOG_WINDOW_MS = 30_000
41
+
42
+ /**
43
+ * Bound for the log-sampling state below. A few hundred concurrently-flooding
44
+ * keys is plenty; past it the LRU evicts and the evicted key logs again.
45
+ */
46
+ export const RATE_LIMIT_LOG_KEYS = 512
47
+
48
+ /** Per-key `{last logged, suppressed since}` for `sampleRateLimitLog`. */
49
+ const logState = new LRUCache<string, { last: number; suppressed: number }>(
50
+ RATE_LIMIT_LOG_KEYS,
51
+ )
52
+
53
+ /**
54
+ * Whether this rejection's RATE_LIMITED line should be written at all.
55
+ *
56
+ * The constraint is availability under flood: stdout writes are effectively
57
+ * synchronous on Windows, so one log line per rejected request hands the flood
58
+ * the limiter just absorbed straight to the logger — the 429 path becomes as
59
+ * expensive as the work it was refusing. At most one line per key per
60
+ * `RATE_LIMIT_LOG_WINDOW_MS` instead.
61
+ *
62
+ * Returns `null` when the line must be suppressed, otherwise the number of
63
+ * rejections suppressed since the key's previous line (0 for a first
64
+ * offender). Bounded per convention 6 — the key derives from client-controlled
65
+ * data (IP, or `rateLimit.keyBy`), so the state lives in an LRU and an evicted
66
+ * key simply logs again as if new. Over-logging is the safe direction to be
67
+ * wrong in.
68
+ */
69
+ export function sampleRateLimitLog(
70
+ key: string,
71
+ now: number = Date.now(),
72
+ ): number | null {
73
+ const entry = logState.get(key)
74
+ if (entry && now - entry.last < RATE_LIMIT_LOG_WINDOW_MS) {
75
+ entry.suppressed++
76
+ return null
77
+ }
78
+
79
+ const suppressed = entry?.suppressed ?? 0
80
+ logState.set(key, { last: now, suppressed: 0 })
81
+ return suppressed
82
+ }
83
+
84
+ /** Test seam, in the family of `__resetTestConfig` / `__resetTestDb`. */
85
+ export function __resetRateLimitLogState(): void {
86
+ logState.clear()
87
+ }
88
+
89
+ /**
90
+ * Seconds until the bucket holds a token again, for the 429's `Retry-After`
91
+ * header. Whole seconds per RFC 9110, and never less than 1 — "0" would tell
92
+ * the client to retry immediately, which is the opposite of the point.
93
+ */
94
+ export function retryAfterSeconds(refill: number): number {
95
+ return Math.max(1, Math.ceil(1 / refill))
96
+ }
@@ -0,0 +1,152 @@
1
+ import { Bakery } from '@bakery-framework/core/core/bakery'
2
+ import { errorMsg, log, serveLog } from '@bakery-framework/core/logger'
3
+ import { Try } from '@bakery-framework/core/utils/common'
4
+ import { hasORM } from './orm'
5
+ import { FLUSH_TIMEOUT_MS } from './threads'
6
+
7
+ let running: Promise<void> | null = null
8
+
9
+ /**
10
+ * How long this process gives its own teardown before giving up on it.
11
+ *
12
+ * The same bound the cluster master applies to a worker's pre-terminate flush,
13
+ * for the same reason it states: *a wedged worker must delay shutdown, not
14
+ * prevent it*. The standalone path did not honour that — `runShutdownSequence()`
15
+ * gated `process.exit(0)` with no deadline, so one hook that never settled meant
16
+ * SIGINT never terminated the process and Ctrl-C appeared to do nothing.
17
+ */
18
+ export const SHUTDOWN_TIMEOUT_MS = FLUSH_TIMEOUT_MS
19
+
20
+ /**
21
+ * The two teardown steps that close process-wide resources. Behind a seam
22
+ * because a test that ran the real ones would close the cache database and the
23
+ * ORM connection for every test file scheduled after it — see `__setTestDb` in
24
+ * `orm/connection.ts` for the same pattern and the same reason.
25
+ */
26
+ export interface ShutdownTeardown {
27
+ closeCache: () => Promise<void> | void
28
+ closeDatabase: () => Promise<void> | void
29
+ }
30
+
31
+ const defaultTeardown: ShutdownTeardown = {
32
+ closeCache: async () => {
33
+ const { closeCacheDb } = await import('@bakery-framework/core/cache/tiered')
34
+ closeCacheDb()
35
+ },
36
+ closeDatabase: async () => {
37
+ // Nothing was ever opened, so there is nothing to close.
38
+ if (!hasORM()) return
39
+ const { closeDB } = await import('@bakery-framework/orm/connection')
40
+ await closeDB()
41
+ },
42
+ }
43
+
44
+ let testTeardown: ShutdownTeardown | null = null
45
+
46
+ /** Test seam — see `ShutdownTeardown`. */
47
+ export function __setTestTeardown(teardown: ShutdownTeardown): void {
48
+ testTeardown = teardown
49
+ }
50
+
51
+ export function __resetTestTeardown(): void {
52
+ testTeardown = null
53
+ }
54
+
55
+ /**
56
+ * Run every teardown step this process owns, once.
57
+ *
58
+ * Memoised rather than guarded by a boolean: a cluster worker can be asked to
59
+ * flush by the master *and* receive a signal, and the second caller has to wait
60
+ * for the first run to finish rather than race it or skip it.
61
+ *
62
+ * ## Order
63
+ *
64
+ * 1. `Bakery.config.onShutdown()` — the application's own hook.
65
+ * 2. `Bakery.shutdownHooks` — framework internals (tiered-cache flush, session
66
+ * prune timer).
67
+ * 3. `PluginHooks.onShutdown()` — plugins.
68
+ * 4. Resource close — the shared cache database, then the ORM connection.
69
+ *
70
+ * The app goes first because it is the only participant that can still need the
71
+ * framework intact: step 2 flushes the session/cache tier, so an app hook that
72
+ * wants to write a last session value has to run before it, not after. That is
73
+ * also the exact reverse of startup, where `runStartupBanner()` calls
74
+ * `PluginHooks.onStart()` and then `config.onStart()` last — last up, first down.
75
+ *
76
+ * Step 4 exists because steps 1–3 all still write. `cache/tiered.ts` used to
77
+ * close the shared cache database inside its own step-2 hook, which is
78
+ * registered at module-evaluation time and so runs *first* of all the framework
79
+ * hooks — before every plugin. The analytics plugin's shutdown flush binds that
80
+ * same handle, so its statements threw and its page-hit and history deltas were
81
+ * dropped on every clean stop. The ORM connection was closed by nothing at all:
82
+ * `closeDB()` had exactly one caller, the `db:sync` CLI, so a SIGINT abandoned
83
+ * a MySQL or Postgres pool mid-flight.
84
+ *
85
+ * Every step is isolated: a throwing hook is reported and the rest still run.
86
+ * A shutdown that aborts halfway loses precisely the data this sequence exists
87
+ * to save. The whole sequence is bounded by `timeoutMs` for the same reason —
88
+ * losing the tail of a flush beats never exiting.
89
+ */
90
+ export function runShutdownSequence(
91
+ timeoutMs: number = SHUTDOWN_TIMEOUT_MS,
92
+ ): Promise<void> {
93
+ running ??= withDeadline(shutdown(), timeoutMs)
94
+ return running
95
+ }
96
+
97
+ /** Test seam — see `__setTestConfig`. Clears the once-per-process memo. */
98
+ export function __resetShutdownSequence(): void {
99
+ running = null
100
+ }
101
+
102
+ async function step(what: string, fn: () => unknown): Promise<void> {
103
+ const [err] = await Try.catch(fn)
104
+ if (err) {
105
+ serveLog.UNHANDLED_ERR({ error: `Error in ${what}: ${errorMsg(err)}` })
106
+ }
107
+ }
108
+
109
+ async function withDeadline(
110
+ work: Promise<void>,
111
+ timeoutMs: number,
112
+ ): Promise<void> {
113
+ let timer: ReturnType<typeof setTimeout> | undefined
114
+ const deadline = new Promise<'timeout'>(resolve => {
115
+ timer = setTimeout(() => resolve('timeout'), timeoutMs)
116
+ })
117
+
118
+ // `shutdown()` isolates every step, so it settles rather than rejects; the
119
+ // catch is belt-and-braces against a future step escaping `step()`.
120
+ const outcome = await Promise.race([
121
+ work.then(() => 'done' as const).catch(() => 'done' as const),
122
+ deadline,
123
+ ])
124
+ clearTimeout(timer)
125
+
126
+ if (outcome === 'timeout') {
127
+ log({
128
+ level: 'warn',
129
+ msg: `[Shutdown] Teardown did not finish within ${timeoutMs}ms; exiting anyway.`,
130
+ })
131
+ }
132
+ }
133
+
134
+ async function shutdown(): Promise<void> {
135
+ await step('config.onShutdown', () => Bakery.config.onShutdown())
136
+
137
+ for (const hook of Bakery.shutdownHooks) {
138
+ await step('shutdown hook', hook)
139
+ }
140
+
141
+ // Inside the step, not above it: an `await import()` that fails throws, and
142
+ // out here that rejection escaped the sequence entirely — taking the resource
143
+ // close below with it and, in `worker.ts`, the `process.exit(0)` that follows.
144
+ await step('plugin onShutdown', async () => {
145
+ const { PluginHooks } = await import('@bakery-framework/core/core/plugins')
146
+ await PluginHooks.onShutdown()
147
+ })
148
+
149
+ const teardown = testTeardown ?? defaultTeardown
150
+ await step('cache database close', () => teardown.closeCache())
151
+ await step('database close', () => teardown.closeDatabase())
152
+ }
package/src/threads.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { errorMsg, log, serveLog } from '@bakery-framework/core/logger'
2
+ import { Try } from '@bakery-framework/core/utils/common'
3
+
4
+ /**
5
+ * How long the master waits for every worker to acknowledge its pre-terminate
6
+ * flush. Bounded on purpose: a wedged worker must delay shutdown, not prevent
7
+ * it.
8
+ */
9
+ export const FLUSH_TIMEOUT_MS = 5000
10
+
11
+ /**
12
+ * The part of `Worker` this module needs. Narrow enough that a test can supply
13
+ * a plain object, which is the only practical way to exercise the timeout — a
14
+ * real Worker that never acknowledges means spawning a real server.
15
+ */
16
+ export interface FlushTarget {
17
+ postMessage(message: any): void
18
+ addEventListener(type: 'message', listener: (event: any) => void): void
19
+ removeEventListener(type: 'message', listener: (event: any) => void): void
20
+ }
21
+
22
+ /**
23
+ * Ask every worker to flush, and resolve when they all have — or when
24
+ * `timeoutMs` elapses, whichever comes first.
25
+ *
26
+ * `worker.terminate()` is immediate: a worker's shutdown hooks never run, so
27
+ * the tiered cache's session buffer only reached disk on its own 30s interval.
28
+ * A cluster shutdown could therefore drop up to a full interval of session
29
+ * writes. Resolves `true` when every worker acknowledged, `false` when the
30
+ * deadline won; the caller terminates either way.
31
+ */
32
+ export function requestWorkerFlush(
33
+ targets: Iterable<FlushTarget>,
34
+ timeoutMs: number = FLUSH_TIMEOUT_MS,
35
+ ): Promise<boolean> {
36
+ const pending = [...targets]
37
+ if (!pending.length) return Promise.resolve(true)
38
+
39
+ return new Promise<boolean>(resolve => {
40
+ const listeners = new Map<FlushTarget, (event: any) => void>()
41
+ let remaining = pending.length
42
+ let settled = false
43
+
44
+ const finish = (flushed: boolean) => {
45
+ if (settled) return
46
+ settled = true
47
+ clearTimeout(timer)
48
+ for (const [target, listener] of listeners) {
49
+ Try(() => target.removeEventListener('message', listener))
50
+ }
51
+ listeners.clear()
52
+ // `resolve` is the enclosing Promise executor's, not a call that returns
53
+ // one; the rule cannot tell the two apart.
54
+ // biome-ignore lint/nursery/noFloatingPromises: executor resolve, not a promise
55
+ resolve(flushed)
56
+ }
57
+
58
+ const timer = setTimeout(() => finish(false), timeoutMs)
59
+
60
+ for (const target of pending) {
61
+ const listener = (event: any) => {
62
+ if (event?.data?.type !== 'SHUTDOWN_DONE') return
63
+ Try(() => target.removeEventListener('message', listener))
64
+ listeners.delete(target)
65
+ if (--remaining === 0) finish(true)
66
+ }
67
+
68
+ listeners.set(target, listener)
69
+ target.addEventListener('message', listener)
70
+ }
71
+
72
+ for (const target of pending) {
73
+ // A worker that already died throws here; that is one fewer acknowledgement
74
+ // and the deadline covers it.
75
+ Try(() => target.postMessage({ type: 'SHUTDOWN' }))
76
+ }
77
+ })
78
+ }
79
+
80
+ export const RESPAWN_BASE_DELAY_MS = 100
81
+ export const RESPAWN_MAX_DELAY_MS = 30_000
82
+ /** A worker that survives this long is healthy; its failure streak resets. */
83
+ export const RESPAWN_RESET_AFTER_MS = 60_000
84
+
85
+ /**
86
+ * How long the master waits before respawning a crashed worker: exponential
87
+ * backoff, `RESPAWN_BASE_DELAY_MS` doubling per consecutive failure up to
88
+ * `RESPAWN_MAX_DELAY_MS`. A fixed 100ms meant a worker that died during boot
89
+ * (bad DB URL, port conflict) re-ran initDB/setupServer ~10 times a second
90
+ * indefinitely. Pure — delay = f(consecutiveFailures); the caller owns the
91
+ * count and resets it after `RESPAWN_RESET_AFTER_MS` of survival.
92
+ */
93
+ export function respawnDelayMs(consecutiveFailures: number): number {
94
+ const failures = Math.max(1, Math.floor(consecutiveFailures))
95
+ // Clamp the exponent before the pow, not the product after it: 2 ** 1024 is
96
+ // already Infinity, and Math.min(Infinity, cap) would mask that.
97
+ const exponent = Math.min(failures - 1, 31)
98
+ return Math.min(RESPAWN_BASE_DELAY_MS * 2 ** exponent, RESPAWN_MAX_DELAY_MS)
99
+ }
100
+
101
+ /** Friendlier names for the platforms the clamp message will actually show. */
102
+ const PLATFORM_NAMES: Record<string, string> = {
103
+ win32: 'Windows',
104
+ darwin: 'macOS',
105
+ }
106
+
107
+ export async function handleThreadsMaster(threadCount: number) {
108
+ if (process.platform !== 'linux' && threadCount > 1) {
109
+ // Not just Windows: the multi-worker model needs kernel-level SO_REUSEPORT
110
+ // load balancing, which only Linux provides. On macOS N sockets either
111
+ // fail to bind or never receive balanced traffic — and a bind failure
112
+ // feeds the respawn loop below.
113
+ serveLog.CLUSTER_CLAMPED({
114
+ platform: PLATFORM_NAMES[process.platform] ?? process.platform,
115
+ requested: threadCount,
116
+ })
117
+ threadCount = 1
118
+ }
119
+
120
+ let Bakery: any
121
+ try {
122
+ const configMod = await import('@bakery-framework/core/core/config')
123
+ const bakeryMod = await import('@bakery-framework/core')
124
+ await configMod.initConfig()
125
+ Bakery = bakeryMod.Bakery
126
+ } catch (error: any) {
127
+ serveLog.UNHANDLED_ERR({
128
+ error: `Fatal error during master startup: ${errorMsg(error)}`,
129
+ })
130
+ process.exit(1)
131
+ }
132
+
133
+ if (threadCount === 1) {
134
+ // Covers both an explicit `--threads 1` and every non-Linux clamp above.
135
+ //
136
+ // THREAD_ID feeds the startup banner; the assignment lands because
137
+ // `core/init.ts` defines it as an accessor on `process.env` (while it was
138
+ // getter-only the assignment threw, and a Try() swallow hid it).
139
+ //
140
+ // THREAD_WORKER is deliberately NOT set. It is the flag that scales caches
141
+ // *down* for N-way memory sharing — HandlerCache 500→50, HandlerMap
142
+ // routeCache 5000→500, the tiered cache's memory tier ÷4, the SQLite page
143
+ // caches ~10x smaller — and a single worker that owns the whole process
144
+ // would get all of that for zero benefit. Setting it here made
145
+ // `--threads 1` strictly worse than plain `bun run serve`; leaving it
146
+ // unset makes this path identical to plain prod.
147
+ ;(process.env as any).THREAD_ID = '0'
148
+ await import('./prod')
149
+ await new Promise(() => {})
150
+ return
151
+ }
152
+
153
+ serveLog.STARTING_THREADS({ count: threadCount })
154
+
155
+ const workers = new Map<number, Worker>()
156
+ const workerState = new Map<
157
+ number,
158
+ { isTerminated: boolean; consecutiveFailures: number; spawnedAt: number }
159
+ >()
160
+
161
+ const spawnWorker = (id: number) => {
162
+ workerState.set(id, {
163
+ isTerminated: false,
164
+ // The streak survives the respawn — that is what makes it a streak.
165
+ consecutiveFailures: workerState.get(id)?.consecutiveFailures ?? 0,
166
+ spawnedAt: Date.now(),
167
+ })
168
+
169
+ const worker = new Worker(new URL('./worker.ts', import.meta.url).href, {
170
+ env: {
171
+ ...process.env,
172
+ THREAD_WORKER: '1',
173
+ THREAD_ID: String(id),
174
+ },
175
+ })
176
+
177
+ workers.set(id, worker)
178
+
179
+ worker.postMessage({
180
+ type: 'INIT_SHARED_POOL',
181
+ buffer: Bakery.sharedPool.buffer,
182
+ })
183
+
184
+ worker.addEventListener('error', err => {
185
+ serveLog.UNHANDLED_ERR({
186
+ error: `Worker ${id} error: ${err.message || String(err)}`,
187
+ })
188
+ })
189
+
190
+ worker.addEventListener('close', () => {
191
+ const state = workerState.get(id)
192
+ if (state?.isTerminated) return
193
+
194
+ // A worker that ran long enough to be called healthy starts a fresh
195
+ // streak; a boot-loop crash keeps doubling.
196
+ const survivedMs = Date.now() - (state?.spawnedAt ?? 0)
197
+ const failures =
198
+ survivedMs >= RESPAWN_RESET_AFTER_MS
199
+ ? 1
200
+ : (state?.consecutiveFailures ?? 0) + 1
201
+ if (state) state.consecutiveFailures = failures
202
+
203
+ const delay = respawnDelayMs(failures)
204
+ serveLog.WORKER_RESPAWN({ id, delay, failures })
205
+ workers.delete(id)
206
+ // No give-up ceiling on purpose: a supervisor that stops retrying turns
207
+ // a transient fault into a permanent outage, and choosing that trade-off
208
+ // needs an operator-facing decision (exit code, flag, alerting) this
209
+ // codebase has not made. The 30s cap keeps the retry loop cheap forever.
210
+ setTimeout(() => spawnWorker(id), delay)
211
+ })
212
+ }
213
+
214
+ for (let i = 0; i < threadCount; i++) {
215
+ spawnWorker(i)
216
+ }
217
+
218
+ async function handleShutdown(signal: string) {
219
+ log({ level: 'info', msg: `Received ${signal}, shutting down cluster...` })
220
+ serveLog.SHUTTING_DOWN()
221
+
222
+ // Mark first: a worker that exits while we wait for its flush is exiting
223
+ // because we asked it to, and must not be respawned by the close listener.
224
+ for (const id of workers.keys()) {
225
+ const state = workerState.get(id)
226
+ if (state) state.isTerminated = true
227
+ }
228
+
229
+ const flushed = await requestWorkerFlush(workers.values())
230
+ if (!flushed) {
231
+ log({
232
+ level: 'warn',
233
+ msg: `[Cluster] Some workers did not acknowledge the flush within ${FLUSH_TIMEOUT_MS}ms; terminating anyway.`,
234
+ })
235
+ }
236
+
237
+ for (const worker of workers.values()) {
238
+ worker.terminate()
239
+ }
240
+ workers.clear()
241
+
242
+ // No PluginHooks.onShutdown() here: plugins are set up per-worker, and each
243
+ // worker's runShutdownSequence (triggered by the SHUTDOWN flush above) runs
244
+ // it where the plugin state actually lives. The master never ran setup().
245
+
246
+ process.exit(0)
247
+ }
248
+
249
+ process.on('SIGINT', () => handleShutdown('SIGINT'))
250
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'))
251
+
252
+ await new Promise(() => {})
253
+ }
package/src/watcher.ts ADDED
@@ -0,0 +1,4 @@
1
+ import '@bakery-framework/core/core/init'
2
+ import { handleDevMaster } from '@bakery-framework/core/compiler'
3
+
4
+ await handleDevMaster()
package/src/worker.ts ADDED
@@ -0,0 +1,278 @@
1
+ import {
2
+ Bakery,
3
+ getHostname,
4
+ hostStore,
5
+ } from '@bakery-framework/core/core/bakery'
6
+ import {
7
+ initConfig,
8
+ resolveHostConfig,
9
+ } from '@bakery-framework/core/core/config'
10
+ import { isDevWorker } from '@bakery-framework/core/core/init'
11
+ import { resolvePort } from '@bakery-framework/core/core/port'
12
+ import type { Handler } from '@bakery-framework/core/handlers'
13
+ import { errorMsg, log, serveLog } from '@bakery-framework/core/logger'
14
+ import {
15
+ handleRequest,
16
+ handleRequestError,
17
+ processResponse,
18
+ serveWebSocket,
19
+ } from '@bakery-framework/core/router'
20
+ import { Session } from '@bakery-framework/core/session'
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'
24
+ import { COUNTER_SLOTS } from '@bakery-framework/core/utils/shared-pool'
25
+ import { hasORM } from './orm'
26
+ import {
27
+ rateLimitSlot,
28
+ retryAfterSeconds,
29
+ sampleRateLimitLog,
30
+ } from './rate-limit'
31
+ import { runShutdownSequence } from './shutdown'
32
+
33
+ /**
34
+ * How long a cluster worker holds `Bun.serve` waiting for the master's
35
+ * `INIT_SHARED_POOL` handover. Bounded because a master that never sends the
36
+ * pool must degrade the worker to its local pool, not deadlock it.
37
+ */
38
+ const SHARED_POOL_WAIT_MS = 2000
39
+
40
+ let signalSharedPoolBound: () => void = () => {}
41
+ const sharedPoolBound = new Promise<void>(resolve => {
42
+ signalSharedPoolBound = resolve
43
+ })
44
+
45
+ if (typeof self !== 'undefined' && 'addEventListener' in self) {
46
+ self.addEventListener('message', (e: any) => {
47
+ if (e.data?.type === 'INIT_SHARED_POOL' && e.data.buffer) {
48
+ // Rebinds on every send on purpose — a late or repeated handover from
49
+ // the master must still land; resolving the promise twice is a no-op.
50
+ Bakery.sharedPool.bind(e.data.buffer)
51
+ signalSharedPoolBound()
52
+ }
53
+
54
+ if (e.data?.type === 'SHUTDOWN') {
55
+ // The cluster master asking for a flush before it calls terminate().
56
+ // Deliberately no process.exit() here: inside a Worker thread that would
57
+ // take the whole cluster down, master included. We flush, we acknowledge,
58
+ // and the master terminates us — or gives up waiting and does it anyway.
59
+ void (async () => {
60
+ Bakery.server?.stop(true)
61
+ await runShutdownSequence()
62
+ Try(() => (self as any).postMessage({ type: 'SHUTDOWN_DONE' }))
63
+ })()
64
+ }
65
+ })
66
+ }
67
+
68
+ try {
69
+ // Memoised no-op on the dev/prod entry paths, which already ran it (and
70
+ // exited there if it threw). A cluster worker is spawned straight into this
71
+ // file and passes through neither entry, so this is its first call — and in
72
+ // PROD a present-but-broken server.config.ts must fail the boot here rather
73
+ // than serve the built-in defaults.
74
+ await initConfig()
75
+ } catch (error: any) {
76
+ serveLog.UNHANDLED_ERR({ error: `Config init failed: ${errorMsg(error)}` })
77
+ process.exit(1)
78
+ }
79
+
80
+ // Skipped entirely when the ORM is not installed — the app has no database and
81
+ // asked for none. Note what is *inside* the guard rather than outside it: once
82
+ // the ORM is present, a failure to initialise is still fatal, because at that
83
+ // point the app does have a database and it does not work.
84
+ if (hasORM()) {
85
+ try {
86
+ const { initDB } = await import('@bakery-framework/orm/connection')
87
+ await initDB()
88
+ } catch (error: any) {
89
+ serveLog.UNHANDLED_ERR({
90
+ error: `Database initialization failed: ${errorMsg(error)}`,
91
+ })
92
+ process.exit(1)
93
+ }
94
+ }
95
+
96
+ try {
97
+ await setupServer()
98
+ } catch (error: any) {
99
+ serveLog.UNHANDLED_ERR({ error: `Server setup failed: ${errorMsg(error)}` })
100
+ process.exit(1)
101
+ }
102
+
103
+ if (import.meta.env.THREAD_WORKER) {
104
+ // The master posts INIT_SHARED_POOL immediately after constructing this
105
+ // Worker, but the message lands on a later event-loop turn than an immediate
106
+ // Bun.serve — early requests would hit a worker-local SharedMemoryPool whose
107
+ // counters and rate-limit state bind() then silently discards. So in
108
+ // thread-worker mode only, wait for the handover before serving. Plain
109
+ // prod/dev never set THREAD_WORKER and skip this entirely.
110
+ let timer: ReturnType<typeof setTimeout> | undefined
111
+ const bound = await Promise.race([
112
+ sharedPoolBound.then(() => true),
113
+ new Promise<boolean>(resolve => {
114
+ timer = setTimeout(() => resolve(false), SHARED_POOL_WAIT_MS)
115
+ }),
116
+ ])
117
+ clearTimeout(timer)
118
+ if (!bound) {
119
+ serveLog.SHARED_POOL_TIMEOUT({ timeout: SHARED_POOL_WAIT_MS })
120
+ }
121
+ }
122
+
123
+ // Same resolver `startup.ts`'s banner and the dev master's URL use, so what we
124
+ // bind and what they advertise cannot disagree. It throws on a malformed
125
+ // `PORT` rather than handing `Bun.serve` a `NaN` it silently turns into a
126
+ // random ephemeral port — which is how `PORT=3000x` used to produce a server
127
+ // on 51570 under a banner reading `http://localhost:3000/`.
128
+ let PORT: number
129
+ try {
130
+ PORT = resolvePort(Bakery.config.port)
131
+ } catch (error: any) {
132
+ serveLog.UNHANDLED_ERR({ error: errorMsg(error) })
133
+ process.exit(1)
134
+ }
135
+
136
+ try {
137
+ Bakery.server = Bun.serve({
138
+ port: PORT,
139
+ hostname: Bakery.config.host,
140
+ reusePort:
141
+ process.platform !== 'win32' && Boolean(import.meta.env.THREAD_WORKER),
142
+ maxRequestBodySize: Bakery.config.maxBodySize,
143
+
144
+ 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
149
+ const hostname = getHostname(req)
150
+ const hostConfig = resolveHostConfig(hostname)
151
+
152
+ return hostStore.run({ config: hostConfig, hostname }, async () => {
153
+ const path = url.pathname
154
+ req.startNs = Bun.nanoseconds()
155
+ req.__hostname = hostname
156
+ deferredValue(req, 'session', Session.from)
157
+
158
+ const rl = Bakery.config.rateLimit
159
+ if (rl) {
160
+ const key = (rl.keyBy ? rl.keyBy(req) : getClientIp(req)) || hostname
161
+ const slot = rateLimitSlot(key)
162
+ if (!Bakery.sharedPool.consumeToken(slot, rl.max, rl.refill)) {
163
+ // Sampled — availability under flood: stdout is effectively
164
+ // synchronous on Windows, so a line per rejection replays the
165
+ // flood the limiter just absorbed as a logging flood.
166
+ const suppressed = sampleRateLimitLog(key)
167
+ if (suppressed !== null) {
168
+ if (suppressed > 0) {
169
+ serveLog.RATE_LIMITED_SUPPRESSED({ ip: key, count: suppressed })
170
+ } else {
171
+ serveLog.RATE_LIMITED({ ip: key })
172
+ }
173
+ }
174
+ return new Response('Too Many Requests', {
175
+ status: 429,
176
+ headers: {
177
+ 'Retry-After': String(retryAfterSeconds(rl.refill)),
178
+ },
179
+ })
180
+ }
181
+ }
182
+
183
+ Bakery.sharedPool.incrementCounter(COUNTER_SLOTS.TOTAL_REQUESTS, 1)
184
+
185
+ const resp: Handler.Response | symbol = await Try.return(
186
+ async function fetchHandler() {
187
+ const res = await handleRequest(req)
188
+
189
+ const isResError = res instanceof Response && res.status >= 400
190
+ const isObjError = is.object(res) && 'errorCode' in res
191
+
192
+ if (isResError || isObjError) {
193
+ Bakery.sharedPool.incrementCounter(COUNTER_SLOTS.TOTAL_ERRORS, 1)
194
+ return await handleRequestError(path, req, res)
195
+ }
196
+ return res
197
+ },
198
+
199
+ async function errorHandler(error) {
200
+ Bakery.sharedPool.incrementCounter(COUNTER_SLOTS.TOTAL_ERRORS, 1)
201
+ serveLog.UNHANDLED_ERR({ error: errorMsg(error) })
202
+ return await handleRequestError(path, req, error)
203
+ },
204
+ )
205
+
206
+ const elapsedMs = Math.round((Bun.nanoseconds() - req.startNs) / 1e6)
207
+ Bakery.sharedPool.incrementCounter(
208
+ COUNTER_SLOTS.LATENCY_SUM_MS,
209
+ elapsedMs,
210
+ )
211
+ return processResponse(resp, req)
212
+ })
213
+ },
214
+
215
+ websocket: serveWebSocket,
216
+
217
+ async error(error: Error, req?: Request): Promise<any> {
218
+ Bakery.sharedPool.incrementCounter(COUNTER_SLOTS.TOTAL_ERRORS, 1)
219
+ serveLog.UNHANDLED_ERR({ error: errorMsg(error) })
220
+ const hostname = req ? getHostname(req) : ''
221
+ const hostConfig = resolveHostConfig(hostname)
222
+ return hostStore.run({ config: hostConfig, hostname }, async () => {
223
+ return await handleRequestError('/', req, error)
224
+ })
225
+ },
226
+ })
227
+ } catch (err: any) {
228
+ serveLog.UNHANDLED_ERR({ error: `Failed to start server: ${errorMsg(err)}` })
229
+ process.exit(1)
230
+ }
231
+
232
+ if (isDevWorker) {
233
+ // One `.catch` on the whole chain, not one nested inside the `.then`. The
234
+ // nested form covered `startCompileService` rejecting but left the dynamic
235
+ // `import()` itself unhandled — so a compiler module that failed to load
236
+ // produced an unhandled rejection rather than the WATCHER_ERR line that
237
+ // exists to report exactly that.
238
+ import('@bakery-framework/core/compiler')
239
+ .then(({ startCompileService }) => startCompileService(Bakery.server))
240
+ .catch(e => serveLog.WATCHER_ERR({ error: String(e) }))
241
+ }
242
+
243
+ try {
244
+ await runStartupBanner()
245
+ } catch (e: any) {
246
+ serveLog.UNHANDLED_ERR({ error: `Startup banner failed: ${errorMsg(e)}` })
247
+ }
248
+
249
+ async function handleShutdown(signal: string) {
250
+ log({ level: 'info', msg: `Received ${signal}, shutting down...` })
251
+ serveLog.SHUTTING_DOWN()
252
+
253
+ Bakery.server?.stop(true)
254
+
255
+ // config.onShutdown, framework hooks, plugins, then resource close — see
256
+ // shutdown.ts for why that order. It used to run only the middle two, so an
257
+ // application's `onShutdown` was declared, defaulted to a no-op, and never
258
+ // called. The sequence carries its own deadline.
259
+ await runShutdownSequence()
260
+ }
261
+
262
+ /**
263
+ * `process.exit(0)` lives here, in a `finally`, because `handleShutdown` is
264
+ * async and the signal handler cannot await it: registered directly, its
265
+ * promise was neither awaited nor caught, so a rejection anywhere in teardown
266
+ * became an unhandled rejection *and* skipped the exit — the process kept
267
+ * running with its listener stopped, answering nothing.
268
+ */
269
+ function onSignal(signal: string): void {
270
+ void handleShutdown(signal)
271
+ .catch(error => {
272
+ serveLog.UNHANDLED_ERR({ error: `Shutdown failed: ${errorMsg(error)}` })
273
+ })
274
+ .finally(() => process.exit(0))
275
+ }
276
+
277
+ process.on('SIGINT', () => onSignal('SIGINT'))
278
+ process.on('SIGTERM', () => onSignal('SIGTERM'))