@bakery-framework/core 2.0.0-alpha.9 → 2.0.0-rc.2

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.
Files changed (78) hide show
  1. package/README.md +8 -8
  2. package/package.json +2 -5
  3. package/src/cache/lru.ts +2 -2
  4. package/src/cache/shared-db.ts +81 -11
  5. package/src/cache/string.ts +4 -0
  6. package/src/cache/tiered.ts +11 -5
  7. package/src/client/globals.d.ts +7 -7
  8. package/src/client/livereload.ts +2 -2
  9. package/src/client/utils.ts +11 -6
  10. package/src/compiler/compiler.ts +48 -44
  11. package/src/compiler/dev-service.ts +29 -30
  12. package/src/compiler/prompt-tracker.ts +2 -2
  13. package/src/compiler/tsconfig-sync.ts +130 -59
  14. package/src/core/bakery.ts +6 -3
  15. package/src/core/cache-version.ts +68 -18
  16. package/src/core/config.ts +16 -16
  17. package/src/core/context.ts +54 -8
  18. package/src/core/define-route.ts +5 -6
  19. package/src/core/index.ts +29 -8
  20. package/src/core/init.ts +38 -35
  21. package/src/core/jsx.ts +19 -4
  22. package/src/core/paths.ts +3 -1
  23. package/src/core/plugins.ts +15 -4
  24. package/src/core/port.ts +6 -6
  25. package/src/global.d.ts +36 -18
  26. package/src/handlers/assets/google-font.ts +6 -6
  27. package/src/handlers/assets/image.ts +36 -3
  28. package/src/handlers/assets/nm.ts +7 -7
  29. package/src/handlers/assets/static.ts +55 -12
  30. package/src/handlers/assets/ts.ts +3 -3
  31. package/src/handlers/assets/tsx.ts +1 -1
  32. package/src/handlers/assets/virtual-asset.ts +1 -1
  33. package/src/handlers/core/$base.ts +24 -8
  34. package/src/handlers/core/$dynamic.ts +66 -23
  35. package/src/handlers/core/$error.ts +29 -17
  36. package/src/handlers/core/$middleware.ts +6 -6
  37. package/src/handlers/core/$mounts.ts +2 -2
  38. package/src/handlers/core/$registry.ts +16 -6
  39. package/src/handlers/core/$routing.ts +18 -19
  40. package/src/handlers/core/$static.ts +11 -12
  41. package/src/handlers/routes/api.ts +5 -5
  42. package/src/handlers/routes/html.ts +2 -2
  43. package/src/handlers/routes/proxy.ts +1 -1
  44. package/src/logger/clients.ts +2 -2
  45. package/src/logger/logger.ts +4 -4
  46. package/src/logger/serve-log.ts +32 -18
  47. package/src/plugins/index.ts +1 -1
  48. package/src/plugins/routes.ts +7 -7
  49. package/src/plugins/types.ts +13 -11
  50. package/src/router.ts +12 -12
  51. package/src/session.ts +41 -23
  52. package/src/shared.d.ts +5 -5
  53. package/src/startup.ts +3 -3
  54. package/src/types.d.ts +10 -10
  55. package/src/utils/common/case.ts +1 -1
  56. package/src/utils/common/match.ts +1 -1
  57. package/src/utils/common/misc.ts +1 -1
  58. package/src/utils/constants.ts +15 -15
  59. package/src/utils/fs.ts +97 -39
  60. package/src/utils/http/authorize.ts +24 -17
  61. package/src/utils/http/cors.ts +6 -6
  62. package/src/utils/http/credential.ts +4 -4
  63. package/src/utils/http/csrf.ts +6 -7
  64. package/src/utils/http/dom.ts +9 -9
  65. package/src/utils/http/etag.ts +54 -13
  66. package/src/utils/http/html.ts +29 -20
  67. package/src/utils/http/ip.ts +1 -1
  68. package/src/utils/http/sse.ts +11 -11
  69. package/src/utils/http/url.ts +4 -4
  70. package/src/utils/http/validate.ts +3 -4
  71. package/src/utils/isomorphic/escape.ts +1 -1
  72. package/src/utils/isomorphic/is.ts +2 -2
  73. package/src/utils/isomorphic/misc.ts +1 -1
  74. package/src/utils/isomorphic/stringify.ts +2 -3
  75. package/src/utils/isomorphic/try.ts +1 -1
  76. package/src/utils/shared-pool.ts +23 -16
  77. package/tsconfig.app.json +3 -3
  78. package/tsconfig.vue.json +3 -3
@@ -10,10 +10,10 @@ const serveMsgs = {
10
10
  // the one recorded after the last successful sync, so the boot-time schema
11
11
  // sync is skipped. `--sync` bypasses the skip.
12
12
  SCHEMA_SYNC_SKIP:
13
- 'I Schema unchanged since last sync — %yskipping schema sync%* (%c--sync%* forces it)',
13
+ 'I Schema unchanged since last sync: %yskipping schema sync%* (%c--sync%* forces it)',
14
14
  // The skip above is precisely when drift goes unnoticed: schema.ts has not
15
15
  // changed, so no sync runs, so nothing looks at the database. Something else
16
- // altered it — a hand-run ALTER, another environment pointed at the same URL,
16
+ // altered it: a hand-run ALTER, another environment pointed at the same URL,
17
17
  // a restore from an older dump.
18
18
  SCHEMA_DRIFT:
19
19
  'W %yDatabase no longer matches the last schema Bakery applied%*: {reason}. Run %cdb:history%* to see what was applied, or %cdb:sync --dry-run%* to see what the difference means.',
@@ -27,26 +27,32 @@ const serveMsgs = {
27
27
  'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
28
28
  TSCONFIG_PROJECTS_WRITTEN:
29
29
  'I Wrote %y{count}%* tsconfig project(s) to %y.cache/tsconfig/%*',
30
+ // One-time repair. Previous releases wired the generated projects into the
31
+ // app's tsconfig.json as `references`, which broke `tsc -p <app>`
32
+ // (TS6305/6306/6310). Named so the rewrite of a tracked file comes with a
33
+ // line saying why it happened.
34
+ TSCONFIG_REFERENCES_REMOVED:
35
+ 'I Removed generated %yreferences%* from %ytsconfig.json%*, the %y.cache/tsconfig/%* projects are standalone, and referencing them broke %ytsc -p%*',
30
36
  // A plugin asking for a project name that is taken. Named rather than
31
37
  // silent: the symptom otherwise is one plugin's types quietly not applying,
32
38
  // discovered much later and blamed on the wrong thing.
33
39
  TSCONFIG_PROJECT_CLASH:
34
- 'W Plugin %y{plugin}%* wants tsconfig project %y{project}%*, which already exists — %rskipped%*',
40
+ 'W Plugin %y{plugin}%* wants tsconfig project %y{project}%*, which already exists: %rskipped%*',
35
41
  // Degrades to "no types" rather than failing the boot: a missing declaration
36
42
  // is a worse editor experience, not a broken server.
37
43
  TSCONFIG_FILE_UNRESOLVED:
38
- 'W Could not resolve tsconfig files entry %y{entry}%* — %rskipped%*',
44
+ 'W Could not resolve tsconfig files entry %y{entry}%*: %rskipped%*',
39
45
  MANUAL_RELOAD: 'I %yManual reload%* triggered from client logger!',
40
46
  CONFIG_IMPORT_ERR: 'E Failed to import %yserver.config.ts%*: %r{error}%*',
41
47
  // Multi-line on purpose: a present-but-broken config booting on defaults is
42
- // the kind of failure a single scrolled-away line hides. DEV only — in PROD
48
+ // the kind of failure a single scrolled-away line hides. DEV only: in PROD
43
49
  // the same condition throws out of initConfig() instead of logging.
44
50
  CONFIG_BROKEN:
45
- 'E %rserver.config.ts is present but failed to load — booting on built-in defaults (no plugins, no hosts, default port)%*\n file: %y{file}%*\n{error}',
51
+ 'E %rserver.config.ts is present but failed to load: booting on built-in defaults (no plugins, no hosts, default port)%*\n file: %y{file}%*\n{error}',
46
52
  // The banner restatement of CONFIG_BROKEN, so the warning survives console
47
53
  // scrollback and sits next to the URLs the developer actually reads.
48
54
  CONFIG_BROKEN_BANNER:
49
- 'W %rserver.config.ts failed to load%* %y— running on built-in defaults. See the import error above.%*',
55
+ 'W %rserver.config.ts failed to load%* %y, running on built-in defaults. See the import error above.%*',
50
56
  WEBSOCKET_ERR: 'E WebSocket error from %y{ip}%*: %r{error}%*',
51
57
  RATE_LIMITED: 'W Rate limited: %y{ip}%*',
52
58
  // Emitted instead of RATE_LIMITED when sampling (cli/rate-limit.ts) held
@@ -57,32 +63,39 @@ const serveMsgs = {
57
63
  // limit silently 429s load tests and shared-NAT offices, so its existence
58
64
  // gets one announcement. An app-configured value prints nothing.
59
65
  RATE_LIMIT_DEFAULT:
60
- 'I Rate limit: %y{max}%* burst / %y{refill}%* req/s per IP (default) — set %crateLimit: false%* to disable',
66
+ 'I Rate limit: %y{max}%* burst / %y{refill}%* req/s per IP (default). Set %crateLimit: false%* to disable',
61
67
  // Multi-worker port sharing rides on kernel-level SO_REUSEPORT load
62
68
  // balancing, which only Linux provides; elsewhere N sockets either fail to
63
69
  // bind or never receive balanced traffic.
64
70
  CLUSTER_CLAMPED:
65
71
  'W [Cluster] %y{platform}%* has no kernel-level SO_REUSEPORT load balancing (Linux-only), so %y{requested}%* workers cannot share one port. Running a single worker.',
66
72
  WORKER_RESPAWN:
67
- 'W [Cluster] Worker %g{id}%* exited unexpectedly — restarting in %y{delay}%*ms (consecutive failure #%y{failures}%*).',
73
+ 'W [Cluster] Worker %g{id}%* exited unexpectedly: restarting in %y{delay}%*ms (consecutive failure #%y{failures}%*).',
68
74
  // Thread-worker only: the master should hand over the shared memory pool
69
75
  // before the worker starts serving; past the deadline the worker serves on
70
76
  // its local pool rather than deadlock.
71
77
  SHARED_POOL_TIMEOUT:
72
- 'W [Cluster] No shared memory pool from the master within %y{timeout}%*ms — serving on a worker-local pool; cross-worker counters and rate limits will not be shared until it arrives.',
78
+ 'W [Cluster] No shared memory pool from the master within %y{timeout}%*ms: serving on a worker-local pool; cross-worker counters and rate limits will not be shared until it arrives.',
73
79
  // The version wipe could not empty the cache directory, so no "current"
74
80
  // marker was written and the next boot will try again. Worth a line rather
75
81
  // than silence: something is holding those files open, and a stale compiled
76
82
  // page surviving an upgrade is the failure the wipe exists to prevent.
83
+ // SQLite would not take WAL for this file, so it is running the DELETE
84
+ // journal instead. Worth one line rather than silence: on a local disk WAL
85
+ // is 100x faster per write and 29x on a transaction, and sessions write on
86
+ // the request path, so this line is the difference between "this
87
+ // filesystem cannot do better" and a deployment quietly paying that.
88
+ JOURNAL_WAL_REFUSED:
89
+ 'W SQLite refused WAL for %y{file}%* (answered %y{answer}%*): running the %y{mode}%* journal instead. Expected on a network path; on a local disk it costs every write.',
77
90
  CACHE_WIPE_INCOMPLETE:
78
- 'W Cache directory could not be cleared for the version change — %y{dir}%* still contains %y{files}%*. Retrying on next start; close anything holding those files.',
91
+ 'W Cache directory could not be cleared for the version change: %y{dir}%* still contains %y{files}%*. Retrying on next start; close anything holding those files.',
79
92
  } as const
80
93
 
81
94
  export const serveLog = messageLogger(new Logger('serve'), serveMsgs)
82
95
 
83
96
  const handlerMsgs = {
84
97
  // Emitted by `DynamicHandler.executeModule`, which every route handler goes
85
- // through — an API route, a `.tsx` page and an `error-*.tsx` all land here.
98
+ // through: an API route, a `.tsx` page and an `error-*.tsx` all land here.
86
99
  // It used to say "API module" while printing a `.tsx` path, alongside a
87
100
  // `TSX_IMPORT_ERR` that no code ever reached.
88
101
  API_IMPORT_ERR: 'E Failed to import route module (%y{file}%*): %r{error}%*',
@@ -90,7 +103,7 @@ const handlerMsgs = {
90
103
  // default. `ApiHandler` used to answer that with a bare 404 naming nothing,
91
104
  // which reads as "your route file is missing" for a file that is right there.
92
105
  API_NO_DEFAULT:
93
- 'E API route has no %cexport default%* (%y{file}%*) — the module loaded but exposes no handler',
106
+ 'E API route has no %cexport default%* (%y{file}%*): the module loaded but exposes no handler',
94
107
  // A handler ran and returned neither a value nor a Response. Still a 404 (see
95
108
  // `ApiHandler.handle`), but the log names the file rather than leaving the
96
109
  // developer to guess which route answered.
@@ -106,13 +119,13 @@ const handlerMsgs = {
106
119
  BUNDLE_SIDE_EFFECTS_REPAIRED:
107
120
  'I %y{file}%* tree-shook to an empty export list because its package declares %ysideEffects: false%*; re-bundled through a re-export shim, which keeps the code.',
108
121
  BUNDLE_EMPTY_EXPORTS:
109
- 'E %y{file}%* bundled to an export list with no code behind it — every name it exports is undefined, so it is rejected rather than served. The bundler reported success, and re-bundling through a re-export shim did not recover it. Import the specific module you need instead of the package root.',
122
+ 'E %y{file}%* bundled to an export list with no code behind it: every name it exports is undefined, so it is rejected rather than served. The bundler reported success, and re-bundling through a re-export shim did not recover it. Import the specific module you need instead of the package root.',
110
123
  BUNDLE_CJS_INTEROP:
111
124
  'I Generated named exports for the CommonJS package %y{file}%*, so a named import of it works in the browser.',
112
125
  BUNDLE_CJS_PROBED:
113
126
  'I Could not read %y{file}%* export names statically, so they were probed by importing it in a short-lived child process.',
114
127
  BUNDLE_CJS_DEFAULT_ONLY:
115
- 'W %y{file}%* assigns %ymodule.exports%* wholesale and its members could not be read, so its browser bundle exports only %ydefault%* — a named import from it fails in the browser with "does not provide an export named …", and nothing fails here. Import the default and read the property off it, or use an ESM build.',
128
+ 'W %y{file}%* assigns %ymodule.exports%* wholesale and its members could not be read, so its browser bundle exports only %ydefault%*: a named import from it fails in the browser with "does not provide an export named …", and nothing fails here. Import the default and read the property off it, or use an ESM build.',
116
129
  } as const
117
130
 
118
131
  export const handlerLog = messageLogger(new Logger('handlers'), handlerMsgs)
@@ -131,6 +144,7 @@ const pluginMsgs = {
131
144
  UNHANDLED_ERR: 'E Unhandled Plugin Error: %r{error}%*',
132
145
  ANALYTICS_STORE_ERR: 'E Analytics store init failed: %r{error}%*',
133
146
  DASHBOARD_BUNDLE_ERR: 'E Failed to bundle %ydashboard.js%*: %r{error}%*',
147
+ EXPLORER_QUERY_ERR: 'E Explorer %y{op}%* failed: %r{error}%*',
134
148
  } as const
135
149
 
136
150
  export const pluginLog = messageLogger(new Logger('plugins'), pluginMsgs)
@@ -142,7 +156,7 @@ export const errorMsg = (err: any) => err?.stack || err?.message || String(err)
142
156
  *
143
157
  * `BuildMessage` and `ResolveMessage` are the two things Bun throws for a
144
158
  * syntax error and an unresolvable import. Neither is `instanceof Error`,
145
- * neither carries a `stack`, and both report *zero* own enumerable keys — so
159
+ * neither carries a `stack`, and both report *zero* own enumerable keys, so
146
160
  * nothing that inspects a thrown value by spreading it or by `instanceof` sees
147
161
  * anything at all. `message`, `name` and `position` are the accessors that
148
162
  * actually answer.
@@ -188,7 +202,7 @@ function positionLines(position: Diagnostic['position']): string[] {
188
202
  * `Error` and useless for the diagnostics above: they have no stack, so it
189
203
  * degrades to a bare "Expected identifier but found end of file" with no file
190
204
  * and no line. This walks `position` instead, and recurses through the
191
- * `AggregateError.errors` array that `import()`ing a broken `.tsx` throws —
205
+ * `AggregateError.errors` array that `import()`ing a broken `.tsx` throws:
192
206
  * that one *is* an `Error`, but it has no stack either, so the summary line
193
207
  * ("4 errors building …") was all anyone ever saw.
194
208
  *
@@ -217,7 +231,7 @@ export function errorDetail(err: any): string {
217
231
  *
218
232
  * For the log lines whose template already names the file. Bun's own
219
233
  * `position.file` is `input.ts` whenever `transform()` was handed a source
220
- * string rather than a path — which is every compile this codebase does — so
234
+ * string rather than a path (which is every compile this codebase does), so
221
235
  * printing it beside the real path would contradict it.
222
236
  */
223
237
  export function errorWithPosition(err: any): string {
@@ -2,7 +2,7 @@
2
2
  * The plugin-authoring surface, as one barrel.
3
3
  *
4
4
  * Every other public directory in core has an `index.ts` and `plugins/` did
5
- * not, so `@bakery-framework/core/plugins` pointed straight at `routes.ts` — which meant
5
+ * not, so `@bakery-framework/core/plugins` pointed straight at `routes.ts`, which meant
6
6
  * `definePlugin` and `ServerPlugin` were only reachable through
7
7
  * `@bakery-framework/core/plugins/types`, a second subpath for one concept. It also broke
8
8
  * under TypeScript's `paths` resolution, which maps `@bakery-framework/core/plugins` to
@@ -2,8 +2,8 @@
2
2
  * Declarative endpoint tables for plugins.
3
3
  *
4
4
  * The dashboard already used a `Record<path, handler>` map; analytics used a
5
- * hand-rolled `if (path === … && method === …)` chain. This generalises the
6
- * dashboard's shape — the one that was already working — so a third plugin does
5
+ * hand-rolled `if (path === … && method === …)` chain. This generalizes the
6
+ * dashboard's shape (the one that was already working), so a third plugin does
7
7
  * not arrive with a fourth style of dispatch.
8
8
  *
9
9
  * Scope is deliberately narrow: exact-match paths only, no params or wildcards.
@@ -16,7 +16,7 @@ import { response } from '../utils/http/response'
16
16
  import type { ValidResponses } from './types'
17
17
 
18
18
  /**
19
- * Handlers may return anything the router knows how to serialize — the
19
+ * Handlers may return anything the router knows how to serialize: the
20
20
  * dashboard's return `JsonResponseData` and `BunFile` as well as `Response`.
21
21
  * `ValidResponses` (= `Handler.Response`) is the framework's name for that set.
22
22
  */
@@ -27,7 +27,7 @@ export type PluginRoute = (req: Request, url: URL) => ValidResponses
27
27
  * method-qualified path (`'POST /api/_x/reset'`). A method-qualified entry wins
28
28
  * over a bare one for the same path.
29
29
  *
30
- * The two forms are guarded differently — see `guardFor` below. A bare key is
30
+ * The two forms are guarded differently. See `guardFor` below. A bare key is
31
31
  * same-origin-only; a method-qualified one gets the ordinary CSRF check.
32
32
  */
33
33
  export type PluginRouteTable = Record<string, PluginRoute>
@@ -35,7 +35,7 @@ export type PluginRouteTable = Record<string, PluginRoute>
35
35
  /**
36
36
  * What a dispatch built from `T` actually resolves to: the union of that
37
37
  * table's own handler return types, awaited, with the `undefined`/`void` cases
38
- * replaced by `null` — exactly what `dispatch` does at runtime with `?? null`.
38
+ * replaced by `null`. Exactly what `dispatch` does at runtime with `?? null`.
39
39
  *
40
40
  * Keyed on the table type rather than flattened to `ValidResponses` because
41
41
  * `ValidResponses` contains `object`, which absorbs `Response`, `Bun.BunFile`
@@ -59,7 +59,7 @@ export type PluginRouteResult<T extends PluginRouteTable> =
59
59
  * unsafe ones same-origin. `'GET /api/_x/public'` is therefore also the way to
60
60
  * declare an endpoint that *is* meant to answer cross-origin.
61
61
  *
62
- * A **bare** key matches every method, which means the author did not say —
62
+ * A **bare** key matches every method, which means the author did not say,
63
63
  * and that is exactly how the dashboard's mutating endpoints ended up
64
64
  * answering a `GET`. `checkCsrf` is no help there: it waves GET through by
65
65
  * definition, so a cross-site `<img src="/api/_dashboard/execute-action">`
@@ -76,7 +76,7 @@ function guardFor(qualified: boolean) {
76
76
 
77
77
  export function routeTable<T extends PluginRouteTable>(routes: T) {
78
78
  /**
79
- * Declared as an overload — the same shape `handleRequest` uses in
79
+ * Declared as an overload: the same shape `handleRequest` uses in
80
80
  * `router.ts`. The signature carries the precise per-table union; the
81
81
  * implementation only ever sees the `PluginRoute` *constraint*, so on its own
82
82
  * it could infer no better than the wide `ValidResponses`.
@@ -6,18 +6,20 @@ export type ValidResponses = Handler.Response
6
6
  /**
7
7
  * A TypeScript project a plugin contributes to the app.
8
8
  *
9
- * Written to `.cache/tsconfig/<name>.json` on every dev boot and referenced
10
- * from the app's root `tsconfig.json`, so the editor typechecks each file under
11
- * the project that owns it. Regenerated rather than committed: it is derived
12
- * from the plugin list and the app's config, and `.cache/` is the disposable
13
- * half of the two runtime directories.
9
+ * Written to `.cache/tsconfig/<name>.json` on every dev boot, as a standalone
10
+ * project invoked directly (`vue-tsc -p .cache/tsconfig/vue.json`). It is
11
+ * deliberately *not* referenced from the app's root `tsconfig.json`: a
12
+ * `references` entry to an unbuilt `noEmit` project makes `tsc -p <app>` fail
13
+ * (TS6305/6306/6310; see `compiler/tsconfig-sync.ts`). Regenerated rather
14
+ * than committed: it is derived from the plugin list and the app's config, and
15
+ * `.cache/` is the disposable half of the two runtime directories.
14
16
  */
15
17
  export interface PluginTsProject {
16
18
  /** File name under `.cache/tsconfig/`, and the project's identity. */
17
19
  name: string
18
20
  /** Usually one of core's three bases. Written through as-is. */
19
21
  extends: string
20
- /** Globs, relative to the **app root** — the generator rewrites them. */
22
+ /** Globs, relative to the **app root**: the generator rewrites them. */
21
23
  include?: string[]
22
24
  exclude?: string[]
23
25
  /**
@@ -34,7 +36,7 @@ export interface PluginTsProject {
34
36
  *
35
37
  * Off by default, and that default is the correction: the generator used to
36
38
  * write those `paths` into *every* project on the reasoning that an alias is
37
- * app-wide. It is not. `importMap` is a **browser** import map — the framework
39
+ * app-wide. It is not. `importMap` is a **browser** import map: the framework
38
40
  * serves it as `<script type="importmap">` and the browser is what resolves
39
41
  * its specifiers. An alias in the server project therefore typechecks an
40
42
  * import that only the browser can satisfy, which is the same class of bug the
@@ -47,13 +49,13 @@ export interface PluginTsProject {
47
49
  * Does code in this project run on the server?
48
50
  *
49
51
  * Declared, not inferred. The first version read `bun-types` out of the base
50
- * config and treated that as the marker — true today, and an inference about
52
+ * config and treated that as the marker: true today, and an inference about
51
53
  * intent drawn from a detail that exists for another reason. A plugin saying
52
54
  * what its project *is* cannot drift out of step with itself.
53
55
  *
54
56
  * What it controls: the app's schema registration, so the ORM's tables are the
55
57
  * app's own rather than the permissive `any` fallback. Off for browser code on
56
- * purpose — `@bakery-framework/orm` is server-only, so a `.ts` bound for the
58
+ * purpose: `@bakery-framework/orm` is server-only, so a `.ts` bound for the
57
59
  * browser importing `DB` should fail to typecheck rather than be helpfully
58
60
  * typed, and the package ships TypeScript source calling `Bun.*`, which a
59
61
  * client config cannot compile anyway.
@@ -63,7 +65,7 @@ export interface PluginTsProject {
63
65
 
64
66
  /** What a plugin contributes to the generated tsconfig projects. */
65
67
  export interface PluginTsConfig {
66
- /** A project this plugin owns outright — Vue SFCs, for instance. */
68
+ /** A project this plugin owns outright: Vue SFCs, for instance. */
67
69
  project?: PluginTsProject
68
70
  }
69
71
 
@@ -77,7 +79,7 @@ export interface ServerPlugin {
77
79
  *
78
80
  * A plugin that brings its own file type or its own ambient globals needs a
79
81
  * project to typecheck them under. `@bakery-framework/plugin-vue` is the
80
- * worked example — it owns `.vue` and declares `req`/`body` for SFC scope,
82
+ * worked example: it owns `.vue` and declares `req`/`body` for SFC scope,
81
83
  * globals core deliberately does not provide.
82
84
  */
83
85
  tsconfig?: PluginTsConfig
package/src/router.ts CHANGED
@@ -27,11 +27,11 @@ import { parsedUrl } from './utils/http/url'
27
27
  * The origin check lives here rather than in each `canHandle` on purpose:
28
28
  * every socket a plugin or an app registers inherits it, instead of every
29
29
  * author having to remember. `/_analytics_ws` was the only handler that got it
30
- * right on its own, and it did so by authenticating — `/_livereload` had
30
+ * right on its own, and it did so by authenticating: `/_livereload` had
31
31
  * nothing, and `WebSocketHandler.canHandle` returns `true` by default, so the
32
32
  * base class was handing out sockets to anyone who asked.
33
33
  *
34
- * Returning `false` (rather than a 403) keeps the published signature — the
34
+ * Returning `false` (rather than a 403) keeps the published signature: the
35
35
  * caller below turns a refusal into `400 WebSocket Upgrade Failed`, which is
36
36
  * also what an unclaimed path gets. The reason reaches the log; the client is
37
37
  * told nothing it did not already know.
@@ -65,10 +65,10 @@ export async function upgradeWebsocket(
65
65
  * Whether the deny-list applies to whatever this handler is about to answer
66
66
  * with. `Bakery.config.blocked` exists to stop files on disk being served, so
67
67
  * handlers that read the request path as a route *name* opt out by declaring
68
- * `servesFiles = false` — `ApiHandler`, `ProxyHandler`, `MiddlewareHandler`.
68
+ * `servesFiles = false`: `ApiHandler`, `ProxyHandler`, `MiddlewareHandler`.
69
69
  *
70
70
  * Deny by default, and the direction matters. Naming only the obvious file
71
- * servers — Static, Public, NM — would have left `/schema.ts` and
71
+ * servers (Static, Public, NM) would have left `/schema.ts` and
72
72
  * `/server.config.ts` reachable through `TSHandler`, which compiles a source
73
73
  * file and serves the result. Anything that does not opt out, including a
74
74
  * plugin's handler, keeps the check.
@@ -94,7 +94,7 @@ export async function handleRequest(req: Request) {
94
94
  // One read of the config getter, not three: `Bakery.serveRoot` walks
95
95
  // `hostStore.getStore()?.config ?? getConfig()` on every access, and the
96
96
  // blocked-glob check below used to pay the same walk again. And no
97
- // `fs.resolve` around the target — `isForbidden` normalises both arguments
97
+ // `fs.resolve` around the target: `isForbidden` normalizes both arguments
98
98
  // itself, so that was a second full path resolution of the same string on
99
99
  // every request.
100
100
  const config = Bakery.config
@@ -102,7 +102,7 @@ export async function handleRequest(req: Request) {
102
102
  // Before anything else, including the forbidden-path check: a preflight names
103
103
  // the route it is asking about in a *header*, not the path, so running it
104
104
  // through routing would answer a question nobody asked. The browser sends no
105
- // credentials with it either, which is why it cannot be authorised.
105
+ // credentials with it either, which is why it cannot be authorized.
106
106
  //
107
107
  // Only when `cors` is configured. Absent, this is not reached at all and the
108
108
  // browser's own default applies.
@@ -136,7 +136,7 @@ export async function handleRequest(req: Request) {
136
136
  const handler = await Bakery.handlers.fetch.resolve(path, req)
137
137
  if (!handler) return new Response('Not Found', { status: 404 })
138
138
 
139
- // `matchBlockedCached` memoises the verdict on the request store, so the
139
+ // `matchBlockedCached` memoizes the verdict on the request store, so the
140
140
  // re-check `StaticHandler.handle` keeps for its direct callers costs a map
141
141
  // hit instead of a second pair of glob matches.
142
142
  if (servesFiles(handler) && matchBlockedCached(config.blocked, path)) {
@@ -236,14 +236,14 @@ export const serveWebSocket: Bun.WebSocketHandler<any> = {
236
236
  * Make an error page answer with the error's status.
237
237
  *
238
238
  * The HTML and TSX error handlers end in `injectIfHtml`, which builds its
239
- * Response without one — so an app with `src/error-404.html` served its 404
239
+ * Response without one, so an app with `src/error-404.html` served its 404
240
240
  * page as `200 OK` and every crawler, cache and monitor believed it. Applied
241
241
  * here rather than inside `injectIfHtml` on purpose: the only status signal
242
242
  * available down there is `params`, which for a GET is the query string, and
243
243
  * `?errorCode=500` is not something a client gets to decide.
244
244
  *
245
245
  * A `JsonResponseData` (the `/api/` arm) carries its own `.status` that
246
- * `processResponse` reads, and a BunFile has no status at all — both pass
246
+ * `processResponse` reads, and a BunFile has no status at all: both pass
247
247
  * through untouched.
248
248
  */
249
249
  function applyErrorStatus(
@@ -277,7 +277,7 @@ export async function handleRequestError(
277
277
 
278
278
  // Wrapped for the same reason the registry call below it is: this is app
279
279
  // code. An `onError` that throws used to escape `Try.return`'s fallback in
280
- // the caller — a fallback that itself rejects is not a fallback — so the one
280
+ // the caller (a fallback that itself rejects is not a fallback), so the one
281
281
  // hook whose job is to handle failure took the app's error page down with
282
282
  // it and the client got Bun's raw 500 instead.
283
283
  const [onErrorFailed, configError] = await Try.catch(() =>
@@ -353,8 +353,8 @@ export async function processResponse(
353
353
  // (e.g. an auth cookie from a login route) that must not be overwritten.
354
354
  sess && resp.headers.append('Set-Cookie', sess)
355
355
 
356
- // Every response funnels through here — pages, API JSON, static files, error
357
- // pages — so this is the one place that cannot miss one. Applied before ETag
356
+ // Every response funnels through here (pages, API JSON, static files, error
357
+ // pages), so this is the one place that cannot miss one. Applied before ETag
358
358
  // so the negotiated `Vary` sees the `Origin` entry and merges with it rather
359
359
  // than either overwriting the other.
360
360
  const cors = Bakery.config.cors
package/src/session.ts CHANGED
@@ -7,7 +7,7 @@ import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
7
7
  /**
8
8
  * Keys under this prefix are framework-internal privilege markers. They share
9
9
  * the same bag as application data, so anything that writes a caller-supplied
10
- * key must refuse this prefix — otherwise a preferences endpoint (or the
10
+ * key must refuse this prefix: otherwise a preferences endpoint (or the
11
11
  * dashboard's own session editor) becomes a privilege-escalation primitive.
12
12
  */
13
13
  export const RESERVED_SESSION_PREFIX = '__bakery.'
@@ -17,7 +17,7 @@ export function isReservedSessionKey(key: string): boolean {
17
17
  }
18
18
 
19
19
  /**
20
- * Clock seam — the cookie half-life bookkeeping is time-based, and its tests
20
+ * Clock seam: the cookie half-life bookkeeping is time-based, and its tests
21
21
  * inject a clock instead of sleeping (see `__setTestDb` / `__setTestConfig`
22
22
  * for the pattern). Only the cookie-reissue math reads this; `createdAt` and
23
23
  * TTL expiry keep `Date.now()`.
@@ -47,7 +47,7 @@ export function newSessionId(): string {
47
47
  * The cache key for a session id under the current host.
48
48
  *
49
49
  * There is one `TieredCache('sessions')` for the whole process, and until this
50
- * existed every id went into it unqualified — so under a multi-tenant `hosts`
50
+ * existed every id went into it unqualified, so under a multi-tenant `hosts`
51
51
  * config one tenant's dashboard listed, read and deleted every other tenant's
52
52
  * sessions. `hostKey()` is the guard the five other per-tenant caches already
53
53
  * use; sessions were the one that never got it.
@@ -102,6 +102,19 @@ export class Session<
102
102
  return Session.cache.count
103
103
  }
104
104
 
105
+ /**
106
+ * Attach the session cookie to a response built outside the pipeline.
107
+ *
108
+ * **Takes the `Request`, and that is not incidental.** `getCookie` reads the
109
+ * session through `hasDeferredValue(req, 'session')`, which looks for a
110
+ * symbol the router installs, so the value has to come from the real
111
+ * request. There used to be an instance form, `session.bind(res)`, that
112
+ * called this with `{ session: this }`: a fake carrying no symbol, so the
113
+ * check failed, `getCookie` returned an empty string, and the cookie was
114
+ * never appended. It was a documented method that could not work, and it is
115
+ * gone rather than repaired: the session alone does not know whether the
116
+ * request it belongs to issued a cookie this turn.
117
+ */
105
118
  public static bind(req: Request, response?: Response) {
106
119
  if (!response) return response
107
120
 
@@ -116,7 +129,7 @@ export class Session<
116
129
  const session = req.session
117
130
 
118
131
  // Issue only when the session actually changed, or when the read path
119
- // flagged a half-life refresh (see `markAccessed`) — not on every read.
132
+ // flagged a half-life refresh (see `markAccessed`), not on every read.
120
133
  // A per-request Set-Cookie made every session-carrying response unique,
121
134
  // defeating If-None-Match, and dirtied merely-read sessions into the
122
135
  // flush timer's write batch.
@@ -138,7 +151,7 @@ export class Session<
138
151
  ? Math.floor(DEFAULT_SESSION_PERSIST / 1000)
139
152
  : Math.floor(DEFAULT_SESSION_TTL / 1000)
140
153
 
141
- // Only believe x-forwarded-proto behind a trusted proxy — the same rule
154
+ // Only believe x-forwarded-proto behind a trusted proxy: the same rule
142
155
  // getHostname and getClientIp already apply. In production default to
143
156
  // Secure, so a terminator that omits the header can't downgrade the cookie.
144
157
  const trustProxy = Bakery.config.trustProxy
@@ -178,7 +191,7 @@ export class Session<
178
191
  session.rawData = { ...metadata.data }
179
192
  session.data = session.initProxy()
180
193
  // Rows written before the field existed revive as 0 = "unknown", which
181
- // `markAccessed` reads as long past half-life — at worst one extra
194
+ // `markAccessed` reads as long past half-life, at worst one extra
182
195
  // reissue, never a cookie that silently expires under an active user.
183
196
  session.cookieIssuedAt = metadata.cookieIssuedAt ?? 0
184
197
  return session
@@ -209,7 +222,7 @@ export class Session<
209
222
  if (existing.isExpired()) {
210
223
  Session.cache.delete(sessionKey(sessionId))
211
224
  } else {
212
- // Accessed, not modified — see `markAccessed`. `touch()` here made
225
+ // Accessed, not modified. See `markAccessed`. `touch()` here made
213
226
  // every session read count as a write.
214
227
  existing.markAccessed()
215
228
  return existing
@@ -227,7 +240,7 @@ export class Session<
227
240
 
228
241
  /**
229
242
  * Epoch ms when `getCookie` last emitted `Set-Cookie` for this id; 0 means
230
- * never (or unknown — a row written before the field existed). Persisted
243
+ * never (or unknown: a row written before the field existed). Persisted
231
244
  * through `toJSON`, so it only costs a write when a write is already
232
245
  * happening; while the instance lives in the memory tier it carries across
233
246
  * requests for free.
@@ -236,7 +249,7 @@ export class Session<
236
249
 
237
250
  /**
238
251
  * Set by `markAccessed` when the cookie has crossed half its Max-Age;
239
- * cleared when `getCookie` issues. Deliberately not persisted — it is
252
+ * cleared when `getCookie` issues. Deliberately not persisted: it is
240
253
  * per-instance request bookkeeping, and losing it costs nothing (the next
241
254
  * read recomputes it from `cookieIssuedAt`).
242
255
  */
@@ -277,7 +290,7 @@ export class Session<
277
290
  * Force the session to count as modified: stored on the next flush and its
278
291
  * cookie re-issued. This is the published "force a cookie refresh" surface
279
292
  * (docs/guides/sessions.md) and keeps its dirty semantics. It is *not* the
280
- * read path — `Session.from` uses `markAccessed`, which slides expiry
293
+ * read path: `Session.from` uses `markAccessed`, which slides expiry
281
294
  * without paying write costs.
282
295
  */
283
296
  public touch(): this {
@@ -286,18 +299,18 @@ export class Session<
286
299
  }
287
300
 
288
301
  /**
289
- * The read-path bump — what `Session.from` does instead of `touch()`.
302
+ * The read-path bump, what `Session.from` does instead of `touch()`.
290
303
  * Reading a session must not dirty it: that made every read-only request
291
304
  * pay a JSON.stringify + synchronous SQLite write on the next flush and
292
305
  * re-issue Set-Cookie, which defeated If-None-Match caching.
293
306
  *
294
307
  * Liveness needs no work here: the `Session.cache.get` in `from` has
295
308
  * already re-stamped the memory tier's accessedAt. What this method owns is
296
- * sliding cookie expiration — once more than half the cookie's Max-Age has
309
+ * sliding cookie expiration: once more than half the cookie's Max-Age has
297
310
  * elapsed since it was last issued, flag a reissue. `getCookie` then emits
298
311
  * the cookie *and* re-persists the session, which renews the DB row's
299
312
  * accessedAt at a cadence of at most maxAge/2 against the pruner's cutoff
300
- * of maxAge — so active sessions never age out server-side either.
313
+ * of maxAge, so active sessions never age out server-side either.
301
314
  */
302
315
  private markAccessed(): void {
303
316
  const maxAgeMs = this.hasPersistedKeys()
@@ -350,10 +363,10 @@ export class Session<
350
363
  *
351
364
  * This is the missing anti-fixation primitive. `reset()` clears data but
352
365
  * keeps the id, so an app that does `req.session.set('userId', …)` on login
353
- * finishes authentication on the *same* id the visitor arrived with —
366
+ * finishes authentication on the *same* id the visitor arrived with,
354
367
  * including one an attacker planted. Call it at the privilege boundary:
355
368
  *
356
- * ```ts no-check — illustrative call site, not a compiled example
369
+ * ```ts no-check: illustrative call site, not a compiled example
357
370
  * req.session.regenerate().set('userId', user.id, true)
358
371
  * ```
359
372
  *
@@ -362,7 +375,7 @@ export class Session<
362
375
  * the same object `req.session` already holds, so the rest of the request
363
376
  * sees the new id.
364
377
  *
365
- * `createdAt` is deliberately preserved — the session continues, only its
378
+ * `createdAt` is deliberately preserved: the session continues, only its
366
379
  * bearer token is replaced.
367
380
  */
368
381
  public regenerate(): this {
@@ -423,10 +436,6 @@ export class Session<
423
436
  return this
424
437
  }
425
438
 
426
- public bind(response?: Response) {
427
- return Session.bind({ session: this } as any, response)
428
- }
429
-
430
439
  public destroy(): void {
431
440
  Session.delete(this.id)
432
441
  }
@@ -454,7 +463,7 @@ export class Session<
454
463
 
455
464
  /**
456
465
  * Every enumeration below filters to the current host and yields the bare
457
- * session id, not the cache key — so `Session.keys()` still returns ids the
466
+ * session id, not the cache key, so `Session.keys()` still returns ids the
458
467
  * dashboard can hand back to `Session.get` / `Session.delete`.
459
468
  */
460
469
  static *entries(): IterableIterator<[string, Session<any>]> {
@@ -487,7 +496,7 @@ export class Session<
487
496
  // The check is on `hosts` and not on the scope, deliberately: `hostKey`
488
497
  // collapses to '' in the unconfigured case too, so the scope alone cannot
489
498
  // tell the two situations apart. This method used to compute one anyway and
490
- // never read it — scoping lives in `Session.entries()`, which the slow path
499
+ // never read it: scoping lives in `Session.entries()`, which the slow path
491
500
  // below goes through.
492
501
  const hosts = Bakery.config.hosts
493
502
  if (!hosts || Object.keys(hosts).length === 0) {
@@ -495,7 +504,7 @@ export class Session<
495
504
  }
496
505
 
497
506
  // Scoped path: `TieredCache.search` pages in SQL across the whole table,
498
- // which is every tenant's sessions, and it has no key-prefix predicate — so
507
+ // which is every tenant's sessions, and it has no key-prefix predicate, so
499
508
  // the filter has to happen before paging, in JS. Bounded by the session
500
509
  // table, which the pruner holds down to live sessions.
501
510
  const needle = options.search?.trim().toLowerCase() || ''
@@ -548,6 +557,15 @@ const sessionPruneTimer = setInterval(
548
557
  1000 * 60 * 15, // prune every 15 minutes
549
558
  )
550
559
 
560
+ // Unref'd, like the two flush timers in `cache/`. A 15-minute prune is not a
561
+ // reason a process cannot exit, and this one is module-level: importing
562
+ // `session.ts` (which `core/index` does) held the event loop open for the
563
+ // life of any process that touched core. The CLI never saw it because every
564
+ // one of its paths ends in `process.exit`; a script, an embedder or a bare
565
+ // `bun -e` that imported the barrel printed its answer and then hung.
566
+ // `onShutdown` still clears it, which is what matters for an orderly stop.
567
+ sessionPruneTimer.unref?.()
568
+
551
569
  Bakery.onShutdown(() => {
552
570
  clearInterval(sessionPruneTimer)
553
571
  })
package/src/shared.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { MapOf } from './types'
5
5
  *
6
6
  * These used to be declared twice, verbatim, in `global.d.ts` and
7
7
  * `client/globals.d.ts`. Nothing caught it: `skipLibCheck: true` suppresses
8
- * errors *inside* .d.ts files — including this project's own — so four TS2300
8
+ * errors *inside* .d.ts files (including this project's own), so four TS2300
9
9
  * duplicate-identifier errors sat there invisibly, and every tsconfig excluded
10
10
  * `src/client/**` on top of that.
11
11
  *
@@ -16,7 +16,7 @@ import type { MapOf } from './types'
16
16
  * declared in two ambient files again.
17
17
  *
18
18
  * `MapOf` and `Wrapped` have since moved out of the global scope entirely,
19
- * into `types.d.ts` — their names are generic enough to collide with a
19
+ * into `types.d.ts`: their names are generic enough to collide with a
20
20
  * consuming app's own. What stays global here is framework-specific enough
21
21
  * not to, and is needed by browser globals that are bound at runtime rather
22
22
  * than imported.
@@ -27,12 +27,12 @@ import type { MapOf } from './types'
27
27
  declare global {
28
28
  /**
29
29
  * Bound by `client/utils.ts` in the browser and `core/init.ts` on the
30
- * server — the same isomorphic implementation either side, so code moving
30
+ * server: the same isomorphic implementation either side, so code moving
31
31
  * between an SFC browser script and a server block keeps the name.
32
32
  */
33
33
  var randomId: typeof import('./utils/isomorphic/misc').randomId
34
34
 
35
- /** The one JSON envelope — see convention 7. */
35
+ /** The one JSON envelope. See convention 7. */
36
36
  type JsonResponse<T = any> = {
37
37
  time: number
38
38
  status: number
@@ -52,7 +52,7 @@ declare global {
52
52
  (value: any, type: 'undefined'): value is undefined
53
53
  // `Function` is the type being narrowed to, and there is no narrower
54
54
  // spelling of "callable" a predicate can promise. `noBannedTypes` is off
55
- // repo-wide, so this needs no suppression — see MONOREPO.md for why.
55
+ // repo-wide, so this needs no suppression. See MONOREPO.md for why.
56
56
  (value: any, type: 'function'): value is Function
57
57
  (value: any, type?: string): boolean
58
58
  string(value: any): value is string