@bakery-framework/cli 2.0.0-alpha.9 → 2.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/package.json +4 -4
- package/src/args.ts +2 -2
- package/src/dev.ts +2 -2
- package/src/index.ts +49 -1
- package/src/orm.ts +6 -6
- package/src/pipeline.ts +5 -5
- package/src/prod.ts +1 -1
- package/src/rate-limit.ts +4 -4
- package/src/schema-hash.ts +4 -4
- package/src/shutdown.ts +14 -14
- package/src/threads.ts +7 -7
- package/src/worker.ts +12 -12
package/README.md
CHANGED
|
@@ -32,15 +32,15 @@ There is no `--port` flag. The port resolves as `PORT` → `port` in
|
|
|
32
32
|
fallback: `PORT=3000x` exits 1 instead of binding somewhere random.
|
|
33
33
|
|
|
34
34
|
For a standalone schema sync in a deploy step, drive `SyncService` from
|
|
35
|
-
`@bakery-framework/orm/sync` directly
|
|
35
|
+
`@bakery-framework/orm/sync` directly: `--sync` starts a server afterwards.
|
|
36
36
|
|
|
37
37
|
## License
|
|
38
38
|
|
|
39
|
-
MIT with the Commons Clause v1.0
|
|
39
|
+
MIT with the Commons Clause v1.0. See [LICENSE](./LICENSE).
|
|
40
40
|
|
|
41
|
-
**Not an OSI-approved
|
|
42
|
-
the software
|
|
41
|
+
**Not an OSI-approved license.** The Commons Clause removes the right to *sell*
|
|
42
|
+
the software: meaning to charge for a product or service whose value derives
|
|
43
43
|
substantially from it, hosting and support included. Everything else the MIT
|
|
44
|
-
|
|
45
|
-
If your
|
|
44
|
+
license grants is unchanged: use it, modify it, ship it inside your own product.
|
|
45
|
+
If your organization only permits OSI-approved dependencies, this will not pass
|
|
46
46
|
that check.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/cli",
|
|
3
|
-
"version": "2.0.0-
|
|
3
|
+
"version": "2.0.0-rc.1",
|
|
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.
|
|
39
|
+
"bun": ">=1.4.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@bakery-framework/core": "^2.0.0-
|
|
42
|
+
"@bakery-framework/core": "^2.0.0-rc.1"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@bakery-framework/orm": "^2.0.0-
|
|
45
|
+
"@bakery-framework/orm": "^2.0.0-rc.1"
|
|
46
46
|
},
|
|
47
47
|
"peerDependenciesMeta": {
|
|
48
48
|
"@bakery-framework/orm": {
|
package/src/args.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Split out of `index.ts` so it can be tested: that file is the mode
|
|
5
5
|
* dispatcher, and importing it boots a server. Nothing here reads
|
|
6
|
-
* `process.argv
|
|
6
|
+
* `process.argv`: the caller passes the slice, which is also what lets a test
|
|
7
7
|
* state the argv it means.
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -35,7 +35,7 @@ export function autoThreadCount(): number {
|
|
|
35
35
|
* `handleThreadsMaster` with one worker.
|
|
36
36
|
*
|
|
37
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
|
|
38
|
+
* the `-t` forms). A flag with a non-numeric or absent value is not an error:
|
|
39
39
|
* it falls back to `autoThreadCount()`, so `--threads --dev` asks for the
|
|
40
40
|
* default rather than rejecting. `Math.max(1, …)` is what keeps `--threads 0`
|
|
41
41
|
* from requesting a cluster with no workers in it.
|
package/src/dev.ts
CHANGED
|
@@ -22,7 +22,7 @@ try {
|
|
|
22
22
|
|
|
23
23
|
config = await initConfig()
|
|
24
24
|
// Still before initImportMap(): a plugin's setup() may contribute entries.
|
|
25
|
-
// setupPlugins()
|
|
25
|
+
// setupPlugins() memoizes, so setupServer()'s later call is a no-op here.
|
|
26
26
|
await setupPlugins()
|
|
27
27
|
await initImportMap()
|
|
28
28
|
initHostImportMaps()
|
|
@@ -74,7 +74,7 @@ if (hasORM()) {
|
|
|
74
74
|
serveLog.SCHEMA_SYNC_SKIP()
|
|
75
75
|
// Only on the skip path. When a sync runs it reports drift itself, from
|
|
76
76
|
// the plan it just built; here nothing else would ever look. Measured at
|
|
77
|
-
// 2.7ms median against apps/example (4 tables)
|
|
77
|
+
// 2.7ms median against apps/example (4 tables): it is one introspection
|
|
78
78
|
// pass, so it grows with table count, which is why it is not on the path
|
|
79
79
|
// that is about to introspect anyway.
|
|
80
80
|
const { initDB, connection } = await import(
|
package/src/index.ts
CHANGED
|
@@ -14,15 +14,63 @@ const isThreadWorker = import.meta.env.THREAD_WORKER
|
|
|
14
14
|
|
|
15
15
|
const threadsOption = parseThreadsOption(process.argv.slice(2))
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* `--help` before anything else, because everything else starts a server.
|
|
19
|
+
*
|
|
20
|
+
* There was no help branch at all: `bakery --help` fell through to `./prod`,
|
|
21
|
+
* booted a production server, bound the configured port and sat there. On a
|
|
22
|
+
* machine where that port is already taken it fails with a bind error, and on
|
|
23
|
+
* one where it is free it silently *takes* it: either way the user asked what
|
|
24
|
+
* the flags were and got a running service.
|
|
25
|
+
*
|
|
26
|
+
* Printed with `console.log` and not the structured logger, for the reason
|
|
27
|
+
* CLAUDE.md records as one of the two standing exceptions: usage text is
|
|
28
|
+
* program output, not a log line, and it goes to stdout so it can be piped.
|
|
29
|
+
*/
|
|
30
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
31
|
+
console.log(`
|
|
32
|
+
Usage: bakery [--dev] [--sync] [--threads N] [--port N]
|
|
33
|
+
|
|
34
|
+
Runs the application in the current directory: the one whose
|
|
35
|
+
\`server.config.ts\` sits beside it.
|
|
36
|
+
|
|
37
|
+
Flags:
|
|
38
|
+
--dev Development: a supervisor that watches files, compiles on
|
|
39
|
+
demand and reloads the browser. Without it, production.
|
|
40
|
+
--sync, -s Run the schema sync before starting. Requires
|
|
41
|
+
@bakery-framework/orm; it is an error rather than a skip
|
|
42
|
+
when that is missing, because asking for a sync and
|
|
43
|
+
silently not doing one looks like it worked.
|
|
44
|
+
--threads N, -t N Fork a cluster of N workers. Production only: ignored
|
|
45
|
+
under --dev. THREAD_ID 0 owns the startup banner.
|
|
46
|
+
--port N, -p N Listening port. Overrides \`port\` in server.config.ts and
|
|
47
|
+
the PORT environment variable.
|
|
48
|
+
--help, -h This.
|
|
49
|
+
|
|
50
|
+
Schema commands have their own help: \`bun run db:sync --help\`.
|
|
51
|
+
`)
|
|
52
|
+
process.exit(0)
|
|
53
|
+
}
|
|
54
|
+
|
|
17
55
|
// Before any mode takes over, and before the config is read: `applyPortFlag`
|
|
18
56
|
// writes `process.env.PORT`, which is what the worker, the startup banner and
|
|
19
|
-
// the dev master's advertised URL all resolve from
|
|
57
|
+
// the dev master's advertised URL all resolve from, and what the spawned dev
|
|
20
58
|
// worker and the cluster Workers inherit. Doing it here means none of them
|
|
21
59
|
// needed changing.
|
|
22
60
|
applyPortFlag()
|
|
23
61
|
|
|
24
62
|
if (
|
|
25
63
|
(process.argv.includes('--sync') || process.argv.includes('-s')) &&
|
|
64
|
+
// **Not in development, where `dev.ts` owns the decision.** Under `--dev`
|
|
65
|
+
// this file is the watcher master, which then spawns a `--dev-worker` that
|
|
66
|
+
// reaches `dev.ts`, and that path reads `--sync` as `force`, hashes the
|
|
67
|
+
// schema sources, decides skip-or-run against the recorded hash and writes
|
|
68
|
+
// the new one. Running here as well meant `--dev --sync` synced twice per
|
|
69
|
+
// boot: once blindly in the master and once properly in the worker, against
|
|
70
|
+
// the same database, with the master's pass doing work the worker was about
|
|
71
|
+
// to redo. Production keeps this branch, because `prod.ts` does not sync at
|
|
72
|
+
// all and this is the only thing that answers `--sync` there.
|
|
73
|
+
!isDev &&
|
|
26
74
|
!isDevWorker &&
|
|
27
75
|
!isThreadWorker
|
|
28
76
|
) {
|
package/src/orm.ts
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The CLI declares it as an **optional peer**, so an app scaffolded with
|
|
5
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
|
|
6
|
+
* `await import()`, so its absence is not a module-graph problem: it is a
|
|
7
7
|
* question this file answers once, before anything tries.
|
|
8
8
|
*
|
|
9
9
|
* **Absence and breakage must not be conflated, and telling them apart is the
|
|
10
10
|
* only reason this file exists.** The obvious implementation is to wrap the
|
|
11
11
|
* `await import()` in a try and treat a throw as "not installed", and it is
|
|
12
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
|
-
*
|
|
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
15
|
* happily with no database and no complaint. `initDB` failing has always been
|
|
16
16
|
* fatal and stays fatal. Only *resolution* failing is soft.
|
|
17
17
|
*
|
|
@@ -25,10 +25,10 @@ let resolved: boolean | null = null
|
|
|
25
25
|
/**
|
|
26
26
|
* True when `@bakery-framework/orm` can be resolved from this package.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
28
|
+
* Memoized: it is asked on the boot path and once more per shutdown, the answer
|
|
29
29
|
* cannot change within a process, and the filesystem walk is not free.
|
|
30
30
|
*
|
|
31
|
-
* `import.meta.dir` is the resolution base on purpose
|
|
31
|
+
* `import.meta.dir` is the resolution base on purpose: it is what a real
|
|
32
32
|
* `import` from this module would use, so the answer matches what the `await
|
|
33
33
|
* import()` calls below it will actually do. Resolving from `process.cwd()`
|
|
34
34
|
* would ask a different question (does the *app* see it) and could disagree,
|
|
@@ -43,7 +43,7 @@ export function hasORM(): boolean {
|
|
|
43
43
|
resolved = true
|
|
44
44
|
} catch {
|
|
45
45
|
// Not installed. The only expected outcome of this catch, and the reason
|
|
46
|
-
// it is allowed to be silent
|
|
46
|
+
// it is allowed to be silent. See the note above about what is *not*
|
|
47
47
|
// routed through here.
|
|
48
48
|
resolved = false
|
|
49
49
|
}
|
package/src/pipeline.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { retryAfterSeconds } from './rate-limit'
|
|
|
9
9
|
* They were inline in the `Bun.serve` callback, which is unreachable from a
|
|
10
10
|
* test: `worker.ts` calls `Bun.serve` at module scope, so importing it binds a
|
|
11
11
|
* port. Nothing about them is server-specific, so they live here and the
|
|
12
|
-
* callback calls them
|
|
12
|
+
* callback calls them: the serve options are otherwise unchanged.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/** The `rateLimit` config with the `false` (disabled) arm removed. */
|
|
@@ -24,7 +24,7 @@ export type RateLimitConfig = Exclude<ProcessedAppConfig['rateLimit'], false>
|
|
|
24
24
|
*
|
|
25
25
|
* The `is.object` guard is load-bearing twice over. `'errorCode' in res` is a
|
|
26
26
|
* `TypeError` on a primitive, and `Try.return`'s failure sentinel is a
|
|
27
|
-
* `symbol
|
|
27
|
+
* `symbol`, so the guard is what keeps the rejection path from throwing
|
|
28
28
|
* inside the code that exists to handle throws. Note `is.object([])` is
|
|
29
29
|
* deliberately `true` (see CLAUDE.md); an array simply has no `errorCode`.
|
|
30
30
|
*
|
|
@@ -41,9 +41,9 @@ export function isErrorResult(res: unknown): boolean {
|
|
|
41
41
|
* Which token bucket this request spends from.
|
|
42
42
|
*
|
|
43
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
|
|
44
|
+
* hashes to one fixed slot, so every client whose IP could not be determined,
|
|
45
45
|
* which is all of them when `Bakery.server` is not yet assigned, and any of
|
|
46
|
-
* them behind a proxy with `trustProxy` off
|
|
46
|
+
* them behind a proxy with `trustProxy` off: would share a single bucket and
|
|
47
47
|
* 429 each other. Falling back to the hostname keeps the collision at
|
|
48
48
|
* per-host, which is the coarsest grouping that is still meaningful.
|
|
49
49
|
*/
|
|
@@ -58,7 +58,7 @@ export function rateLimitKey(
|
|
|
58
58
|
/**
|
|
59
59
|
* The 429 a rejected request receives.
|
|
60
60
|
*
|
|
61
|
-
* `Retry-After` is whole seconds per RFC 9110 and never 0
|
|
61
|
+
* `Retry-After` is whole seconds per RFC 9110 and never 0. See
|
|
62
62
|
* `retryAfterSeconds`.
|
|
63
63
|
*/
|
|
64
64
|
export function tooManyRequests(refill: number): Response {
|
package/src/prod.ts
CHANGED
|
@@ -16,7 +16,7 @@ try {
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
await initConfig()
|
|
19
|
-
// Before initImportMap() so plugin-contributed entries land in it;
|
|
19
|
+
// Before initImportMap() so plugin-contributed entries land in it; memoized,
|
|
20
20
|
// so the later setupServer() call inside worker.ts does not repeat it.
|
|
21
21
|
await setupPlugins()
|
|
22
22
|
await initImportMap()
|
package/src/rate-limit.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const RATE_LIMIT_SLOTS = 1024
|
|
|
20
20
|
* left 100 reachable buckets out of 1024 with **83% of all keys in bucket 0**.
|
|
21
21
|
* Rate limiting is on by default at `{max: 100, refill: 10}`, so in practice
|
|
22
22
|
* most clients shared one bucket with a sustained ceiling of 10 requests per
|
|
23
|
-
* second
|
|
23
|
+
* second, one busy client 429'd everyone else, and an ordinary page load
|
|
24
24
|
* (many requests inside a single refill tick) tripped it on its own.
|
|
25
25
|
*
|
|
26
26
|
* Taking the modulo in bigint space and converting the small result keeps the
|
|
@@ -55,13 +55,13 @@ const logState = new LRUCache<string, { last: number; suppressed: number }>(
|
|
|
55
55
|
*
|
|
56
56
|
* The constraint is availability under flood: stdout writes are effectively
|
|
57
57
|
* synchronous on Windows, so one log line per rejected request hands the flood
|
|
58
|
-
* the limiter just absorbed straight to the logger
|
|
58
|
+
* the limiter just absorbed straight to the logger, the 429 path becomes as
|
|
59
59
|
* expensive as the work it was refusing. At most one line per key per
|
|
60
60
|
* `RATE_LIMIT_LOG_WINDOW_MS` instead.
|
|
61
61
|
*
|
|
62
62
|
* Returns `null` when the line must be suppressed, otherwise the number of
|
|
63
63
|
* rejections suppressed since the key's previous line (0 for a first
|
|
64
|
-
* offender). Bounded per convention 6
|
|
64
|
+
* offender). Bounded per convention 6: the key derives from client-controlled
|
|
65
65
|
* data (IP, or `rateLimit.keyBy`), so the state lives in an LRU and an evicted
|
|
66
66
|
* key simply logs again as if new. Over-logging is the safe direction to be
|
|
67
67
|
* wrong in.
|
|
@@ -88,7 +88,7 @@ export function __resetRateLimitLogState(): void {
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
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
|
|
91
|
+
* header. Whole seconds per RFC 9110, and never less than 1: "0" would tell
|
|
92
92
|
* the client to retry immediately, which is the opposite of the point.
|
|
93
93
|
*/
|
|
94
94
|
export function retryAfterSeconds(refill: number): number {
|
package/src/schema-hash.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
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
|
|
3
|
+
* (resolved with the same probe order as orm/sync/load.ts, configured path,
|
|
4
4
|
* then the `orm/` folder layout, then a root `schema.ts`) plus the DB target,
|
|
5
5
|
* since switching `DB_URL` changes what "synced" means.
|
|
6
6
|
*
|
|
7
|
-
* Returns `null` for any indeterminate state
|
|
8
|
-
* exist, an unreadable file
|
|
7
|
+
* Returns `null` for any indeterminate state (a configured path that does not
|
|
8
|
+
* exist, an unreadable file), so `classifySchemaSync` fails closed into
|
|
9
9
|
* re-syncing. Total absence of a schema is *not* indeterminate (it is a
|
|
10
10
|
* supported state for the defaults) and hashes to a stable value.
|
|
11
11
|
*
|
|
12
12
|
* `base` defaults to the app's cwd, which is what `dev.ts` wants and the only
|
|
13
13
|
* value it passes. It is a parameter at all because `fs.cwd` is a module-level
|
|
14
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
|
|
15
|
+
* probe any other way without `process.chdir` before the import: process-global
|
|
16
16
|
* state of exactly the kind convention 9 exists to keep out of the suite.
|
|
17
17
|
*
|
|
18
18
|
* The `@bakery-framework/core/utils` import stays dynamic, as it was inline in
|
package/src/shutdown.ts
CHANGED
|
@@ -11,7 +11,7 @@ let running: Promise<void> | null = null
|
|
|
11
11
|
*
|
|
12
12
|
* The same bound the cluster master applies to a worker's pre-terminate flush,
|
|
13
13
|
* for the same reason it states: *a wedged worker must delay shutdown, not
|
|
14
|
-
* prevent it*. The standalone path did not
|
|
14
|
+
* prevent it*. The standalone path did not honor that: `runShutdownSequence()`
|
|
15
15
|
* gated `process.exit(0)` with no deadline, so one hook that never settled meant
|
|
16
16
|
* SIGINT never terminated the process and Ctrl-C appeared to do nothing.
|
|
17
17
|
*/
|
|
@@ -20,7 +20,7 @@ export const SHUTDOWN_TIMEOUT_MS = FLUSH_TIMEOUT_MS
|
|
|
20
20
|
/**
|
|
21
21
|
* The two teardown steps that close process-wide resources. Behind a seam
|
|
22
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
|
|
23
|
+
* ORM connection for every test file scheduled after it. See `__setTestDb` in
|
|
24
24
|
* `orm/connection.ts` for the same pattern and the same reason.
|
|
25
25
|
*/
|
|
26
26
|
export interface ShutdownTeardown {
|
|
@@ -43,7 +43,7 @@ const defaultTeardown: ShutdownTeardown = {
|
|
|
43
43
|
|
|
44
44
|
let testTeardown: ShutdownTeardown | null = null
|
|
45
45
|
|
|
46
|
-
/** Test seam
|
|
46
|
+
/** Test seam. See `ShutdownTeardown`. */
|
|
47
47
|
export function __setTestTeardown(teardown: ShutdownTeardown): void {
|
|
48
48
|
testTeardown = teardown
|
|
49
49
|
}
|
|
@@ -55,28 +55,28 @@ export function __resetTestTeardown(): void {
|
|
|
55
55
|
/**
|
|
56
56
|
* Run every teardown step this process owns, once.
|
|
57
57
|
*
|
|
58
|
-
*
|
|
58
|
+
* Memoized rather than guarded by a boolean: a cluster worker can be asked to
|
|
59
59
|
* flush by the master *and* receive a signal, and the second caller has to wait
|
|
60
60
|
* for the first run to finish rather than race it or skip it.
|
|
61
61
|
*
|
|
62
62
|
* ## Order
|
|
63
63
|
*
|
|
64
|
-
* 1. `Bakery.config.onShutdown()
|
|
65
|
-
* 2. `Bakery.shutdownHooks
|
|
64
|
+
* 1. `Bakery.config.onShutdown()`: the application's own hook.
|
|
65
|
+
* 2. `Bakery.shutdownHooks`: framework internals (tiered-cache flush, session
|
|
66
66
|
* prune timer).
|
|
67
|
-
* 3. `PluginHooks.onShutdown()
|
|
68
|
-
* 4. Resource close
|
|
67
|
+
* 3. `PluginHooks.onShutdown()`, plugins.
|
|
68
|
+
* 4. Resource close: the shared cache database, then the ORM connection.
|
|
69
69
|
*
|
|
70
70
|
* The app goes first because it is the only participant that can still need the
|
|
71
71
|
* framework intact: step 2 flushes the session/cache tier, so an app hook that
|
|
72
72
|
* wants to write a last session value has to run before it, not after. That is
|
|
73
73
|
* also the exact reverse of startup, where `runStartupBanner()` calls
|
|
74
|
-
* `PluginHooks.onStart()` and then `config.onStart()` last
|
|
74
|
+
* `PluginHooks.onStart()` and then `config.onStart()` last: last up, first down.
|
|
75
75
|
*
|
|
76
|
-
* Step 4 exists because steps 1
|
|
76
|
+
* Step 4 exists because steps 1 to 3 all still write. `cache/tiered.ts` used to
|
|
77
77
|
* close the shared cache database inside its own step-2 hook, which is
|
|
78
78
|
* registered at module-evaluation time and so runs *first* of all the framework
|
|
79
|
-
* hooks
|
|
79
|
+
* hooks: before every plugin. The analytics plugin's shutdown flush binds that
|
|
80
80
|
* same handle, so its statements threw and its page-hit and history deltas were
|
|
81
81
|
* dropped on every clean stop. The ORM connection was closed by nothing at all:
|
|
82
82
|
* `closeDB()` had exactly one caller, the `db:sync` CLI, so a SIGINT abandoned
|
|
@@ -84,7 +84,7 @@ export function __resetTestTeardown(): void {
|
|
|
84
84
|
*
|
|
85
85
|
* Every step is isolated: a throwing hook is reported and the rest still run.
|
|
86
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
|
|
87
|
+
* to save. The whole sequence is bounded by `timeoutMs` for the same reason:
|
|
88
88
|
* losing the tail of a flush beats never exiting.
|
|
89
89
|
*/
|
|
90
90
|
export function runShutdownSequence(
|
|
@@ -94,7 +94,7 @@ export function runShutdownSequence(
|
|
|
94
94
|
return running
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/** Test seam
|
|
97
|
+
/** Test seam. See `__setTestConfig`. Clears the once-per-process memo. */
|
|
98
98
|
export function __resetShutdownSequence(): void {
|
|
99
99
|
running = null
|
|
100
100
|
}
|
|
@@ -139,7 +139,7 @@ async function shutdown(): Promise<void> {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// Inside the step, not above it: an `await import()` that fails throws, and
|
|
142
|
-
// out here that rejection escaped the sequence entirely
|
|
142
|
+
// out here that rejection escaped the sequence entirely, taking the resource
|
|
143
143
|
// close below with it and, in `worker.ts`, the `process.exit(0)` that follows.
|
|
144
144
|
await step('plugin onShutdown', async () => {
|
|
145
145
|
const { PluginHooks } = await import('@bakery-framework/core/core/plugins')
|
package/src/threads.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const FLUSH_TIMEOUT_MS = 5000
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
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
|
|
13
|
+
* a plain object, which is the only practical way to exercise the timeout: a
|
|
14
14
|
* real Worker that never acknowledges means spawning a real server.
|
|
15
15
|
*/
|
|
16
16
|
export interface FlushTarget {
|
|
@@ -20,7 +20,7 @@ export interface FlushTarget {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Ask every worker to flush, and resolve when they all have
|
|
23
|
+
* Ask every worker to flush, and resolve when they all have, or when
|
|
24
24
|
* `timeoutMs` elapses, whichever comes first.
|
|
25
25
|
*
|
|
26
26
|
* `worker.terminate()` is immediate: a worker's shutdown hooks never run, so
|
|
@@ -87,7 +87,7 @@ export const RESPAWN_RESET_AFTER_MS = 60_000
|
|
|
87
87
|
* backoff, `RESPAWN_BASE_DELAY_MS` doubling per consecutive failure up to
|
|
88
88
|
* `RESPAWN_MAX_DELAY_MS`. A fixed 100ms meant a worker that died during boot
|
|
89
89
|
* (bad DB URL, port conflict) re-ran initDB/setupServer ~10 times a second
|
|
90
|
-
* indefinitely. Pure
|
|
90
|
+
* indefinitely. Pure: delay = f(consecutiveFailures); the caller owns the
|
|
91
91
|
* count and resets it after `RESPAWN_RESET_AFTER_MS` of survival.
|
|
92
92
|
*/
|
|
93
93
|
export function respawnDelayMs(consecutiveFailures: number): number {
|
|
@@ -108,7 +108,7 @@ export async function handleThreadsMaster(threadCount: number) {
|
|
|
108
108
|
if (process.platform !== 'linux' && threadCount > 1) {
|
|
109
109
|
// Not just Windows: the multi-worker model needs kernel-level SO_REUSEPORT
|
|
110
110
|
// load balancing, which only Linux provides. On macOS N sockets either
|
|
111
|
-
// fail to bind or never receive balanced traffic
|
|
111
|
+
// fail to bind or never receive balanced traffic, and a bind failure
|
|
112
112
|
// feeds the respawn loop below.
|
|
113
113
|
serveLog.CLUSTER_CLAMPED({
|
|
114
114
|
platform: PLATFORM_NAMES[process.platform] ?? process.platform,
|
|
@@ -138,9 +138,9 @@ export async function handleThreadsMaster(threadCount: number) {
|
|
|
138
138
|
// getter-only the assignment threw, and a Try() swallow hid it).
|
|
139
139
|
//
|
|
140
140
|
// THREAD_WORKER is deliberately NOT set. It is the flag that scales caches
|
|
141
|
-
// *down* for N-way memory sharing
|
|
141
|
+
// *down* for N-way memory sharing: HandlerCache 500→50, HandlerMap
|
|
142
142
|
// routeCache 5000→500, the tiered cache's memory tier ÷4, the SQLite page
|
|
143
|
-
// caches ~10x smaller
|
|
143
|
+
// caches ~10x smaller, and a single worker that owns the whole process
|
|
144
144
|
// would get all of that for zero benefit. Setting it here made
|
|
145
145
|
// `--threads 1` strictly worse than plain `bun run serve`; leaving it
|
|
146
146
|
// unset makes this path identical to plain prod.
|
|
@@ -161,7 +161,7 @@ export async function handleThreadsMaster(threadCount: number) {
|
|
|
161
161
|
const spawnWorker = (id: number) => {
|
|
162
162
|
workerState.set(id, {
|
|
163
163
|
isTerminated: false,
|
|
164
|
-
// The streak survives the respawn
|
|
164
|
+
// The streak survives the respawn: that is what makes it a streak.
|
|
165
165
|
consecutiveFailures: workerState.get(id)?.consecutiveFailures ?? 0,
|
|
166
166
|
spawnedAt: Date.now(),
|
|
167
167
|
})
|
package/src/worker.ts
CHANGED
|
@@ -42,7 +42,7 @@ const sharedPoolBound = new Promise<void>(resolve => {
|
|
|
42
42
|
if (typeof self !== 'undefined' && 'addEventListener' in self) {
|
|
43
43
|
self.addEventListener('message', (e: any) => {
|
|
44
44
|
if (e.data?.type === 'INIT_SHARED_POOL' && e.data.buffer) {
|
|
45
|
-
// Rebinds on every send on purpose
|
|
45
|
+
// Rebinds on every send on purpose: a late or repeated handover from
|
|
46
46
|
// the master must still land; resolving the promise twice is a no-op.
|
|
47
47
|
Bakery.sharedPool.bind(e.data.buffer)
|
|
48
48
|
signalSharedPoolBound()
|
|
@@ -52,7 +52,7 @@ if (typeof self !== 'undefined' && 'addEventListener' in self) {
|
|
|
52
52
|
// The cluster master asking for a flush before it calls terminate().
|
|
53
53
|
// Deliberately no process.exit() here: inside a Worker thread that would
|
|
54
54
|
// take the whole cluster down, master included. We flush, we acknowledge,
|
|
55
|
-
// and the master terminates us
|
|
55
|
+
// and the master terminates us, or gives up waiting and does it anyway.
|
|
56
56
|
void (async () => {
|
|
57
57
|
Bakery.server?.stop(true)
|
|
58
58
|
await runShutdownSequence()
|
|
@@ -63,9 +63,9 @@ if (typeof self !== 'undefined' && 'addEventListener' in self) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
try {
|
|
66
|
-
//
|
|
66
|
+
// Memoized no-op on the dev/prod entry paths, which already ran it (and
|
|
67
67
|
// exited there if it threw). A cluster worker is spawned straight into this
|
|
68
|
-
// file and passes through neither entry, so this is its first call
|
|
68
|
+
// file and passes through neither entry, so this is its first call, and in
|
|
69
69
|
// PROD a present-but-broken server.config.ts must fail the boot here rather
|
|
70
70
|
// than serve the built-in defaults.
|
|
71
71
|
await initConfig()
|
|
@@ -74,9 +74,9 @@ try {
|
|
|
74
74
|
process.exit(1)
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// Skipped entirely when the ORM is not installed
|
|
77
|
+
// Skipped entirely when the ORM is not installed. The app has no database and
|
|
78
78
|
// asked for none. Note what is *inside* the guard rather than outside it: once
|
|
79
|
-
// the ORM is present, a failure to
|
|
79
|
+
// the ORM is present, a failure to initialize is still fatal, because at that
|
|
80
80
|
// point the app does have a database and it does not work.
|
|
81
81
|
if (hasORM()) {
|
|
82
82
|
try {
|
|
@@ -100,7 +100,7 @@ try {
|
|
|
100
100
|
if (import.meta.env.THREAD_WORKER) {
|
|
101
101
|
// The master posts INIT_SHARED_POOL immediately after constructing this
|
|
102
102
|
// Worker, but the message lands on a later event-loop turn than an immediate
|
|
103
|
-
// Bun.serve
|
|
103
|
+
// Bun.serve. Early requests would hit a worker-local SharedMemoryPool whose
|
|
104
104
|
// counters and rate-limit state bind() then silently discards. So in
|
|
105
105
|
// thread-worker mode only, wait for the handover before serving. Plain
|
|
106
106
|
// prod/dev never set THREAD_WORKER and skip this entirely.
|
|
@@ -120,7 +120,7 @@ if (import.meta.env.THREAD_WORKER) {
|
|
|
120
120
|
// Same resolver `startup.ts`'s banner and the dev master's URL use, so what we
|
|
121
121
|
// bind and what they advertise cannot disagree. It throws on a malformed
|
|
122
122
|
// `PORT` rather than handing `Bun.serve` a `NaN` it silently turns into a
|
|
123
|
-
// random ephemeral port
|
|
123
|
+
// random ephemeral port, which is how `PORT=3000x` used to produce a server
|
|
124
124
|
// on 51570 under a banner reading `http://localhost:3000/`.
|
|
125
125
|
let PORT: number
|
|
126
126
|
try {
|
|
@@ -157,7 +157,7 @@ try {
|
|
|
157
157
|
const key = rateLimitKey(rl, req, hostname)
|
|
158
158
|
const slot = rateLimitSlot(key)
|
|
159
159
|
if (!Bakery.sharedPool.consumeToken(slot, rl.max, rl.refill)) {
|
|
160
|
-
// Sampled
|
|
160
|
+
// Sampled: availability under flood: stdout is effectively
|
|
161
161
|
// synchronous on Windows, so a line per rejection replays the
|
|
162
162
|
// flood the limiter just absorbed as a logging flood.
|
|
163
163
|
const suppressed = sampleRateLimitLog(key)
|
|
@@ -221,7 +221,7 @@ try {
|
|
|
221
221
|
if (isDevWorker) {
|
|
222
222
|
// One `.catch` on the whole chain, not one nested inside the `.then`. The
|
|
223
223
|
// nested form covered `startCompileService` rejecting but left the dynamic
|
|
224
|
-
// `import()` itself unhandled
|
|
224
|
+
// `import()` itself unhandled, so a compiler module that failed to load
|
|
225
225
|
// produced an unhandled rejection rather than the WATCHER_ERR line that
|
|
226
226
|
// exists to report exactly that.
|
|
227
227
|
import('@bakery-framework/core/compiler')
|
|
@@ -241,7 +241,7 @@ async function handleShutdown(signal: string) {
|
|
|
241
241
|
|
|
242
242
|
Bakery.server?.stop(true)
|
|
243
243
|
|
|
244
|
-
// config.onShutdown, framework hooks, plugins, then resource close
|
|
244
|
+
// config.onShutdown, framework hooks, plugins, then resource close. See
|
|
245
245
|
// shutdown.ts for why that order. It used to run only the middle two, so an
|
|
246
246
|
// application's `onShutdown` was declared, defaulted to a no-op, and never
|
|
247
247
|
// called. The sequence carries its own deadline.
|
|
@@ -252,7 +252,7 @@ async function handleShutdown(signal: string) {
|
|
|
252
252
|
* `process.exit(0)` lives here, in a `finally`, because `handleShutdown` is
|
|
253
253
|
* async and the signal handler cannot await it: registered directly, its
|
|
254
254
|
* promise was neither awaited nor caught, so a rejection anywhere in teardown
|
|
255
|
-
* became an unhandled rejection *and* skipped the exit
|
|
255
|
+
* became an unhandled rejection *and* skipped the exit, the process kept
|
|
256
256
|
* running with its listener stopped, answering nothing.
|
|
257
257
|
*/
|
|
258
258
|
function onSignal(signal: string): void {
|