@bakery-framework/core 2.0.0-alpha.8 → 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.
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 +8 -8
  18. package/src/core/define-route.ts +5 -6
  19. package/src/core/index.ts +19 -7
  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
@@ -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
package/src/startup.ts CHANGED
@@ -39,7 +39,7 @@ let pluginSetup: Promise<void> | null = null
39
39
  * Calling both meant every non-clustered worker set its plugins up twice.
40
40
  * Handler and mount registration is idempotent so that went unnoticed, but the
41
41
  * analytics plugin registers a shutdown hook and kicks off `loadAnalyticsData()`
42
- * from `setup()` — so it got two of each. Memoising the promise (not a boolean)
42
+ * from `setup()`, so it got two of each. Memoizing the promise (not a boolean)
43
43
  * also makes a concurrent second caller await the first run rather than race it.
44
44
  */
45
45
  export function setupPlugins(): Promise<void> {
@@ -96,7 +96,7 @@ export async function runStartupBanner(): Promise<void> {
96
96
  const host = Bakery.config.host
97
97
 
98
98
  // Ground truth first. The banner's job is to print where the server is
99
- // listening, and `Bakery.server.port` is what the OS actually gave us —
99
+ // listening, and `Bakery.server.port` is what the OS actually gave us,
100
100
  // which is the only correct answer under `PORT=0`, and the only *honest*
101
101
  // one if the bound port and the requested one ever part company again.
102
102
  // `resolvePort` covers the callers that print a banner without a server
@@ -134,7 +134,7 @@ export async function runStartupBanner(): Promise<void> {
134
134
  // constant, and any user-supplied value replaces the reference. The
135
135
  // default limiter 429s load tests and shared-NAT offices with nothing
136
136
  // anywhere saying it exists, so this line is its one announcement. An
137
- // app-configured value prints nothing — their choice, their knowledge.
137
+ // app-configured value prints nothing: their choice, their knowledge.
138
138
  if (Bakery.config.rateLimit === DEFAULT_RATE_LIMIT) {
139
139
  serveLog.RATE_LIMIT_DEFAULT(DEFAULT_RATE_LIMIT)
140
140
  }
package/src/types.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * These were declared in `declare global`, which is fine inside one repo and a
5
5
  * hazard the moment the packages are published: the names are generic enough
6
6
  * that a consuming app is likely to define its own `MapOf`, and a global type
7
- * alias cannot be merged or opted out of — the app just gets a redeclaration
7
+ * alias cannot be merged or opted out of, the app just gets a redeclaration
8
8
  * error with nowhere to put the fix.
9
9
  *
10
10
  * Moving them cost 24 files an `import type` line and cost consumers nothing:
@@ -12,7 +12,7 @@
12
12
  * that they were always internal plumbing rather than DX.
13
13
  *
14
14
  * `verbatimModuleSyntax` makes the compiler reject a value-position import of
15
- * these, so nothing can accidentally introduce a runtime module edge — which
15
+ * these, so nothing can accidentally introduce a runtime module edge, which
16
16
  * matters because `client/utils.ts` is compiled into the browser bundle.
17
17
  */
18
18
  export type MapOf<T> = { [key: string]: T }
@@ -20,7 +20,7 @@ export type MapOf<T> = { [key: string]: T }
20
20
  /** A value, or a function producing it. */
21
21
  export type Wrapped<T, Args extends any[] = []> = T | ((...args: Args) => T)
22
22
 
23
- /** Awaited or not — the shape almost every framework hook returns. */
23
+ /** Awaited or not: the shape almost every framework hook returns. */
24
24
  export type MixedPromise<T> = Promise<T> | T
25
25
 
26
26
  type ResolveMatchReturn<V, R> = R extends any
@@ -54,12 +54,12 @@ export type Match<D extends symbol> = {
54
54
  /**
55
55
  * The `body` a route module's default export receives: declared params merge
56
56
  * over a permissive base. `body.id` is `string` once you declare it, while
57
- * `body.anythingElse` stays reachable as `any` — the parse rules in
57
+ * `body.anythingElse` stays reachable as `any`: the parse rules in
58
58
  * `utils/http/body.ts` mean the framework genuinely cannot know every key, so
59
59
  * locking the object down would be a lie in the other direction.
60
60
  *
61
61
  * There is no filename-literal inference (`[id].ts` does not conjure
62
- * `{ id: string }` on its own — that needs codegen); the contract is that the
62
+ * `{ id: string }` on its own. That needs codegen); the contract is that the
63
63
  * author declares the shape once, at the `defineRoute` / `html` call, instead
64
64
  * of annotating the whole signature or settling for `any`.
65
65
  */
@@ -71,13 +71,13 @@ export type RouteBody<P = {}> = P & MapOf<any>
71
71
  * directory under the `!` form).
72
72
  *
73
73
  * Exists so a route over mixed or unknown segments can say
74
- * `defineRoute<MapOf<RouteParam>>` instead of hand-writing the union — and so
74
+ * `defineRoute<MapOf<RouteParam>>` instead of hand-writing the union, and so
75
75
  * the union has one definition to change if it ever grows.
76
76
  */
77
77
  export type RouteParam = string | string[]
78
78
 
79
79
  /**
80
- * What a route module may actually return — read off `processResponse`
80
+ * What a route module may actually return. Read off `processResponse`
81
81
  * (`router.ts`) and `ApiHandler.handle`: a `Response`, a `BunFile` (streamed
82
82
  * with an ETag), the JSON envelope, a plain data object or array (JSON-encoded
83
83
  * as-is), a string (HTML-sniffed) or number, or nothing (204; a 404 under
@@ -85,10 +85,10 @@ export type RouteParam = string | string[]
85
85
  *
86
86
  * Deliberately not `object` (which `Handler.Response` uses): `object` absorbs
87
87
  * `Response`, `BunFile` and `JsonResponse` into one member and the union stops
88
- * saying anything — the same trap `routeTable()`'s dispatch avoids. `MapOf<any>`
88
+ * saying anything, the same trap `routeTable()`'s dispatch avoids. `MapOf<any>`
89
89
  * is the narrowest thing that covers "a plain data object"; a target index
90
90
  * signature of `any` does still accept class instances, but that only matters
91
- * for inference targets — this union is a constraint on what an author may
91
+ * for inference targets: this union is a constraint on what an author may
92
92
  * return, and nothing consumes a route module's return type downstream, so the
93
93
  * members stay legible instead of collapsing.
94
94
  */
@@ -107,7 +107,7 @@ export type RouteResponse = MixedPromise<
107
107
 
108
108
  /**
109
109
  * A typed route module default export. `$dynamic.executeModule` calls it as
110
- * `(req, body, Bakery.server)` — the third argument is optional here because
110
+ * `(req, body, Bakery.server)`: the third argument is optional here because
111
111
  * `Bakery.server` is itself optional before `Bun.serve` runs, and almost no
112
112
  * handler wants it.
113
113
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `Case` moved to `utils/isomorphic/case` — the client bundle carried an
2
+ * `Case` moved to `utils/isomorphic/case`: the client bundle carried an
3
3
  * identical copy. `toHash` stays here: it calls `Bun.hash`, so it is server-only
4
4
  * and must not be pulled into the isomorphic layer.
5
5
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Moved to `utils/isomorphic/match` so the browser bundle and the server share
3
- * one implementation — the prototype-chain fix (`Object.hasOwn` rather than
3
+ * one implementation: the prototype-chain fix (`Object.hasOwn` rather than
4
4
  * `in`) previously had to be applied to both copies separately.
5
5
  */
6
6
  export * from '../isomorphic/match'
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `is`, `Math2` and `throws` moved to `utils/isomorphic/` — the client bundle
2
+ * `is`, `Math2` and `throws` moved to `utils/isomorphic/`: the client bundle
3
3
  * carried its own copies of all three. They are re-exported here so existing
4
4
  * `@server/utils` importers are unaffected.
5
5
  *
@@ -12,7 +12,7 @@ export const DEFAULT_RATE_LIMIT = { max: 100, refill: 10 }
12
12
  * Files that must never be served off disk.
13
13
  *
14
14
  * These are matched against the file a *file-serving* handler is about to
15
- * return, not against every request path — see `servesFiles` in `router.ts`.
15
+ * return, not against every request path. See `servesFiles` in `router.ts`.
16
16
  * An API route or a proxied path is a name, not a file, and never had any
17
17
  * business being tested against this list.
18
18
  *
@@ -22,8 +22,8 @@ export const DEFAULT_RATE_LIMIT = { max: 100, refill: 10 }
22
22
  * on that attempt.
23
23
  *
24
24
  * Note what is *not* here: an extension-wide ban on JSON. It caught every
25
- * JSON document an app might legitimately publish — a web app manifest, a
26
- * `.well-known` document — and since `blocked` in config can only ever append,
25
+ * JSON document an app might legitimately publish: a web app manifest, a
26
+ * `.well-known` document, and since `blocked` in config can only ever append,
27
27
  * an app had no way to opt back out. What the list is actually protecting is
28
28
  * the handful of JSON files that describe the project, so those are named.
29
29
  */
@@ -46,9 +46,9 @@ export const DEFAULT_BLOCKED_GLOBS = [
46
46
  // trailing `**` collapses to a single segment: `{**/node_modules/**}` matches
47
47
  // `/node_modules/a` and then stops, so `/node_modules/pkg/index.js` was not
48
48
  // blocked by anything. Each pattern still matched on its own, which is why
49
- // it survived — the defect only exists in the form the config actually uses.
49
+ // it survived: the defect only exists in the form the config actually uses.
50
50
  // `.cache` is the framework's disposable working dir and `bakery/` holds the
51
- // database and its backups — the visible one is the precious one, so that a
51
+ // database and its backups: the visible one is the precious one, so that a
52
52
  // dotfile sweep cannot reach the data. `bakery/` being un-dotted is why it
53
53
  // needs naming here explicitly: `*.db` covers the database file itself, but
54
54
  // not the `.ts` schema snapshots under `bakery/backups/`.
@@ -67,17 +67,17 @@ export const DEFAULT_BLOCKED_GLOBS = [
67
67
  /**
68
68
  * A request path folded to the form the *filesystem* would resolve it to.
69
69
  *
70
- * Two normalisations, both of them things the OS does and the glob does not:
70
+ * Two normalizations, both of them things the OS does and the glob does not:
71
71
  *
72
72
  * - **Case.** NTFS and APFS are case-insensitive, so `/PACKAGE.JSON` opens the
73
- * same bytes as `/package.json` — but `Bun.Glob` is case-sensitive, so only
73
+ * same bytes as `/package.json`, but `Bun.Glob` is case-sensitive, so only
74
74
  * the second one was blocked. Every file this list protects was reachable by
75
75
  * holding down shift, and the static pipeline was verified to serve
76
76
  * `/STYLES/GLOBAL.CSS` and `/styles/global.css` as the same 170 bytes.
77
77
  * - **Trailing dots and spaces.** Win32 strips both from each path component,
78
78
  * so `/package.json.` and `/package.json ` are the same file too.
79
79
  *
80
- * `.`, `..` and any all-dots component are left intact — trimming those would
80
+ * `.`, `..` and any all-dots component are left intact: trimming those would
81
81
  * rewrite a relative segment into something else, and containment is
82
82
  * `fs.isForbidden`'s job, not this function's.
83
83
  */
@@ -93,14 +93,14 @@ export function normalizeBlockedPath(path: string): string {
93
93
  * Each path component truncated at its first `:`.
94
94
  *
95
95
  * Win32 resolves `x.ts::$DATA` to the bytes of `x.ts` and
96
- * `dir::$INDEX_ALLOCATION` to `dir` — NTFS alternate-data-stream syntax — and
96
+ * `dir::$INDEX_ALLOCATION` to `dir` (NTFS alternate-data-stream syntax), and
97
97
  * `new URL()` keeps the suffix in `pathname`, so a glob anchored on the real
98
98
  * name missed while the filesystem opened the file anyway. The directory form
99
99
  * matters independently: it defeats the `dir/**` patterns that the file form
100
100
  * cannot reach.
101
101
  *
102
102
  * Only ever used as an *extra* match candidate (see `matchBlocked`), never as
103
- * a replacement — colons are legal in POSIX filenames, and a real file named
103
+ * a replacement: colons are legal in POSIX filenames, and a real file named
104
104
  * `a:b.db` must stay blocked by `*.db` rather than being tested as `a`.
105
105
  */
106
106
  function stripStreamSuffix(path: string): string {
@@ -117,18 +117,18 @@ function stripStreamSuffix(path: string): string {
117
117
  /**
118
118
  * Whether `path` is refused by the blocked globs.
119
119
  *
120
- * Matched twice: once as written, once normalised. Both are needed, and for
121
- * different reasons. The normalised pass is what closes the case and
120
+ * Matched twice: once as written, once normalized. Both are needed, and for
121
+ * different reasons. The normalized pass is what closes the case and
122
122
  * trailing-dot variants above. The verbatim pass is what keeps an app's own
123
- * `blocked: ['**\/Secrets.txt']` working — `initConfig` appends user patterns
123
+ * `blocked: ['**\/Secrets.txt']` working, `initConfig` appends user patterns
124
124
  * to this list untouched, so folding only the path would quietly stop matching
125
125
  * the very patterns the app wrote.
126
126
  *
127
127
  * On a case-*sensitive* filesystem this over-blocks: a file genuinely named
128
128
  * `/PACKAGE.JSON`, distinct from `/package.json`, is refused. That is the
129
- * intended direction to be wrong in, and it is the reason this normalises the
129
+ * intended direction to be wrong in, and it is the reason this normalizes the
130
130
  * path rather than resolving the file. The router runs this check before it
131
- * knows which file — often before there is a file at all — so "ask the
131
+ * knows which file (often before there is a file at all), so "ask the
132
132
  * filesystem for the real name" is not available at the point the answer is
133
133
  * needed, and would cost a `realpath` per request where it was.
134
134
  */