@bakery-framework/core 2.0.0-alpha.11 → 2.0.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.0.0-alpha.11",
3
+ "version": "2.0.0-alpha.13",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -30,19 +30,16 @@
30
30
  "exports": {
31
31
  ".": "./src/core/index.ts",
32
32
  "./compiler": "./src/compiler/index.ts",
33
- "./core": "./src/core/index.ts",
34
33
  "./handlers": "./src/handlers/index.ts",
35
34
  "./logger": "./src/logger/index.ts",
36
35
  "./utils": "./src/utils/index.ts",
37
36
  "./utils/common": "./src/utils/common/index.ts",
38
37
  "./utils/http": "./src/utils/http/index.ts",
39
- "./utils/isomorphic": "./src/utils/isomorphic/index.ts",
40
38
  "./types": "./src/types.d.ts",
41
39
  "./package.json": "./package.json",
42
40
  "./startup": "./src/startup.ts",
43
41
  "./tsconfig.app.json": "./tsconfig.app.json",
44
42
  "./session": "./src/session.ts",
45
- "./jsx": "./src/core/jsx.ts",
46
43
  "./plugins": "./src/plugins/index.ts",
47
44
  "./cache/lru": "./src/cache/lru.ts",
48
45
  "./cache/shared-db": "./src/cache/shared-db.ts",
@@ -2,7 +2,9 @@ import { Database } from 'bun:sqlite'
2
2
  import { dirname } from 'node:path'
3
3
  import { Bakery } from '../core/bakery'
4
4
  import { checkCacheVersion } from '../core/cache-version'
5
+ import { serveLog } from '../logger/serve-log'
5
6
  import { fs } from '../utils'
7
+ import { Try } from '../utils/common/try'
6
8
 
7
9
  /**
8
10
  * The tiered cache's spill-to-disk store — sessions and LRU overflow.
@@ -90,8 +92,76 @@ if (!stored) {
90
92
  for (const { name } of tables) cacheDb.run(`DROP TABLE IF EXISTS "${name}"`)
91
93
  cacheDb.run('UPDATE __schema SET version = ?', [SCHEMA_VERSION])
92
94
  }
93
- const journalMode = process.platform === 'win32' ? 'DELETE' : 'WAL'
94
- cacheDb.run(`PRAGMA journal_mode = ${journalMode};`)
95
+ /**
96
+ * **WAL first, DELETE only where WAL is actually refused.**
97
+ *
98
+ * This was `process.platform === 'win32' ? 'DELETE' : 'WAL'`, a rule whose
99
+ * reason was never written down. The standing hypothesis was that a Windows
100
+ * network path cannot host WAL, and that much is true: WAL needs a shared
101
+ * `-shm` mapping, which SMB and WebDAV do not provide. The rule was wrong in
102
+ * its scope rather than its premise. It applied to every Windows install,
103
+ * including the ordinary case of a local disk where WAL works perfectly.
104
+ *
105
+ * The cost of being wrong was paid on the request path, because sessions
106
+ * write there. Measured on this machine, four interleaved rounds with a
107
+ * CPU-bound control that stayed flat at 29-34 ms:
108
+ *
109
+ * single autocommit write DELETE 3629 us WAL 36 us 100x
110
+ * 100-row transaction DELETE 3.91 ms WAL 0.13 ms 29x
111
+ *
112
+ * So the rule is replaced by an attempt and a check, which is what makes it
113
+ * safe to change without knowing why it was there. SQLite refuses WAL in
114
+ * exactly two observable ways, and both are handled here:
115
+ *
116
+ * - it **returns the unchanged mode** in the pragma's own result row, which
117
+ * is what an in-memory or anonymous-temp database does (`memory`); and
118
+ * - it **throws**, which is what a read-only database does.
119
+ *
120
+ * Measured, not assumed: both shapes were reproduced before this was written.
121
+ * A pragma that silently did nothing would make the fallback unsafe, and it
122
+ * does not - the returned row is the authority, so a filesystem that cannot
123
+ * host WAL lands on DELETE and says so once instead of being guessed at in
124
+ * advance.
125
+ *
126
+ * Returning the effective mode rather than the requested one matters for the
127
+ * same reason: the log line has to name what the database is actually doing.
128
+ *
129
+ * Exported as a test seam rather than driven through the module's own
130
+ * import-time call: this file opens the real cache database when it loads, so
131
+ * the only way to assert on a *refusal* is to hand the function a database
132
+ * that produces one. `:memory:` is such a case and is what the test uses.
133
+ */
134
+ export function applyJournalMode(db: Database, file: string): string {
135
+ const asked = Try.return(
136
+ () =>
137
+ db
138
+ .query<{ journal_mode: string }, []>('PRAGMA journal_mode = WAL;')
139
+ .get()?.journal_mode ?? '',
140
+ (error: Error) => `threw: ${error.message}`,
141
+ )
142
+ if (asked.toLowerCase() === 'wal') return 'wal'
143
+
144
+ // Wrapped as well. A database that refuses WAL by throwing will refuse the
145
+ // fallback the same way when the mode it is already in is not DELETE, and
146
+ // an unopenable cache is a far worse outcome than an untuned one.
147
+ const fell = Try.return(
148
+ () =>
149
+ db
150
+ .query<{ journal_mode: string }, []>('PRAGMA journal_mode = DELETE;')
151
+ .get()?.journal_mode ?? 'unknown',
152
+ () =>
153
+ db.query<{ journal_mode: string }, []>('PRAGMA journal_mode;').get()
154
+ ?.journal_mode ?? 'unknown',
155
+ )
156
+ serveLog.JOURNAL_WAL_REFUSED({
157
+ file,
158
+ answer: asked || '(no row)',
159
+ mode: fell,
160
+ })
161
+ return fell
162
+ }
163
+
164
+ applyJournalMode(cacheDb, dbFilePath)
95
165
  cacheDb.run('PRAGMA synchronous = NORMAL;')
96
166
  cacheDb.run('PRAGMA temp_store = memory;')
97
167
  // `core/init.ts` installs THREAD_WORKER as an accessor holding a **boolean**,
@@ -41,6 +41,10 @@ class StringCache {
41
41
 
42
42
  if (flushIntervalMs !== undefined) {
43
43
  this.flushTimer = setInterval(() => this.flushToDisk(), flushIntervalMs)
44
+ // Unref'd for the reason `tiered.ts` gives: `Strings` is constructed at
45
+ // module scope, so importing `compiler/compiler.ts` pinned the event
46
+ // loop open for the life of the process.
47
+ this.flushTimer.unref?.()
44
48
  }
45
49
 
46
50
  registerCache(this)
@@ -133,6 +133,12 @@ export class TieredCache<K extends string | number, V> {
133
133
  () => this.flushToDisk(),
134
134
  this.opts.flushInterval,
135
135
  )
136
+ // A flush timer must not be the reason a process cannot exit. Every
137
+ // cache here is module-level, so importing `session.ts` — or anything
138
+ // reaching it — left an interval holding the loop open forever: a script
139
+ // or a test that imported core printed its answer and then hung. The CLI
140
+ // never noticed because it calls `process.exit` on every path.
141
+ this.flushTimer.unref?.()
136
142
  }
137
143
 
138
144
  registerCache(this)
@@ -144,7 +144,12 @@ export async function request(
144
144
  return data
145
145
  }
146
146
 
147
- export function formatHTML(html: string, indentWidth: number = 2): string {
147
+ /**
148
+ * File-local. It was exported and nothing imported it — `client/utils.ts` is
149
+ * not a published subpath, so the `export` widened nothing a consumer could
150
+ * reach and only made the name look like part of a surface.
151
+ */
152
+ function formatHTML(html: string, indentWidth: number = 2): string {
148
153
  if (!html) return ''
149
154
 
150
155
  const cleanHtml = html
@@ -80,7 +80,13 @@ function nextVirtualId(): string {
80
80
  }
81
81
 
82
82
  function preprocessImports(source: string, filePath: fs.AbsolutePath): string {
83
- const fileDir = fs.resolve(filePath)
83
+ // The file's **directory**, not the file. A relative import resolves against
84
+ // the directory containing the importer, and resolving against the path
85
+ // itself produced `…/src/entry.ts/a.css` — which exists nowhere, so every
86
+ // `import './x.css'` from a compiled module registered a virtual asset that
87
+ // could only 404. It has been wrong since before the workspace split, with
88
+ // no test and neither app using the feature.
89
+ const fileDir = fs.dirname(filePath)
84
90
 
85
91
  const matches = [...source.matchAll(RX_IMPORT)]
86
92
 
@@ -69,7 +69,10 @@ export const Bakery: globalThis.Bakery = {
69
69
  get version() {
70
70
  return getAppVersion()
71
71
  },
72
- sharedPool: new SharedMemoryPool(1024 * 1024),
72
+ // No size: the pool's own layout is the default now. It used to be asked for
73
+ // a megabyte, of which 9,280 bytes were the layout and the rest a region
74
+ // nothing read. See the constructor in `utils/shared-pool.ts`.
75
+ sharedPool: new SharedMemoryPool(),
73
76
  // Defined in `core/context.ts`, which is low enough that a module needing a
74
77
  // path does not have to import `Bakery` to get one — reaching them through
75
78
  // here is what closed the logger cycle. These stay the reading surface for
package/src/core/index.ts CHANGED
@@ -8,7 +8,7 @@ import { getConfig, NOOP } from './config'
8
8
  // `./bakery`, which re-exports `hostStore` from exactly here — so naming it
9
9
  // adds no module edge, only a name.
10
10
  import { getFrameworkVersion } from './context'
11
- import { createElement, Fragment, html } from './jsx'
11
+ import { createElement, Fragment, html, raw } from './jsx'
12
12
 
13
13
  export const defineConfig = <T extends AppConfig>(config: T): T => config
14
14
  export const definePlugin = _definePlugin
@@ -67,6 +67,18 @@ export {
67
67
  encodeSSE,
68
68
  Fragment,
69
69
  getConfig,
70
+ /**
71
+ * Opt a string out of JSX escaping.
72
+ *
73
+ * Here because it had nowhere else to be. `createElement` escapes children
74
+ * unless they came from itself, so `raw` is the documented way to
75
+ * interpolate markup an application already trusts — and it was reachable
76
+ * only through a `./jsx` subpath that existed to alias one file. The
77
+ * routing guide pointed at `@bakery-framework/core/core/jsx`, which the
78
+ * export map never named at all, so the documented import could not resolve
79
+ * for a consumer either way.
80
+ */
81
+ raw,
70
82
  /**
71
83
  * The version of `@bakery-framework/core` itself, read from its own manifest.
72
84
  *
package/src/core/jsx.ts CHANGED
@@ -127,9 +127,24 @@ export function html<P = {}>(render: RenderFn<P>) {
127
127
  return rawDom
128
128
  }
129
129
 
130
- if (rawDom.trim().toLowerCase().startsWith('<html'))
130
+ // Both questions are about the first nine characters, so only the first
131
+ // nine are trimmed and lowercased. The form this replaces built two whole
132
+ // trimmed, lowercased copies of the document to answer them: on a 124 KB
133
+ // page that was 159 us per render against 0.17 us here, a 920x difference
134
+ // on a path every server-rendered page goes through. Measured interleaved
135
+ // with a CPU-bound control that stayed flat at 29-34 ms, and the two forms
136
+ // agree on 18 hand-picked shapes and 200,000 fuzzed strings.
137
+ //
138
+ // The search scans the leading whitespace and stops, so the cost is the
139
+ // indentation rather than the document. `<!doctype` is the longer of the
140
+ // two prefixes at nine characters, which is the whole window needed.
141
+ const startsAt = rawDom.search(/\S/)
142
+ const prefix =
143
+ startsAt < 0 ? '' : rawDom.slice(startsAt, startsAt + 9).toLowerCase()
144
+
145
+ if (prefix.startsWith('<html'))
131
146
  return `<!DOCTYPE html>\n${rawDom}`
132
- if (rawDom.trim().toLowerCase().startsWith('<!doctype')) return rawDom
147
+ if (prefix.startsWith('<!doctype')) return rawDom
133
148
 
134
149
  let title = 'Document'
135
150
  const dom = rawDom.replace(/<title>(.*?)<\/title>/i, (_, t) => {
package/src/core/paths.ts CHANGED
@@ -16,7 +16,9 @@ import { FileSystem as fs } from '../utils/fs'
16
16
  * Use `frameworkPath()` for files the framework ships. Use `Bakery.root` (cwd)
17
17
  * for anything the application owns.
18
18
  */
19
- export const frameworkRoot: string = fs.resolve(import.meta.dir, '..')
19
+ /** File-local: `core/paths.ts` is not a published subpath and nothing
20
+ * outside this file read it. `frameworkPath()` below is the way in. */
21
+ const frameworkRoot: string = fs.resolve(import.meta.dir, '..')
20
22
 
21
23
  /** Resolve a path against the framework's own root. */
22
24
  export function frameworkPath(...segments: string[]): string {
@@ -27,7 +27,8 @@ async function normalizePluginResult(result: Handler.Response) {
27
27
  export namespace PluginHooks {
28
28
  export async function setup() {
29
29
  for (const plugin of getPlugins()) {
30
- const [err] = await Try.catch(() => plugin.setup?.(Bakery.config))
30
+ if (!plugin.setup) continue
31
+ const [err] = await Try.catch(() => plugin.setup!(Bakery.config))
31
32
  if (err) {
32
33
  serveLog.UNHANDLED_ERR({
33
34
  error: `Plugin setup error (${plugin.name}): ${errorMsg(err)}`,
@@ -38,7 +39,15 @@ export namespace PluginHooks {
38
39
 
39
40
  export async function onRequest(req: Request) {
40
41
  for (const plugin of getPlugins()) {
41
- const [err, result] = await Try.catch(plugin.onRequest?.(req))
42
+ // Skipped rather than awaited. `Try.catch` of an absent hook still
43
+ // allocates a promise and costs a microtask turn, and this loop runs on
44
+ // every request against every registered plugin - most of which declare
45
+ // no `onRequest` at all. Measured with four plugins and none declaring
46
+ // one, 1.33 us per request against 0.24 us; with one declaring it,
47
+ // 1.23 us against 0.60 us. Small, and `onError` below already reads
48
+ // this way, so the loops now share one idiom instead of three.
49
+ if (!plugin.onRequest) continue
50
+ const [err, result] = await Try.catch(plugin.onRequest(req))
42
51
  if (err) {
43
52
  serveLog.UNHANDLED_ERR({
44
53
  error: `Plugin request error (${plugin.name}): ${errorMsg(err)}`,
@@ -57,7 +66,8 @@ export namespace PluginHooks {
57
66
 
58
67
  export async function onRoute(req: Request) {
59
68
  for (const plugin of getPlugins()) {
60
- const [err] = await Try.catch(() => plugin.onRoute?.(req))
69
+ if (!plugin.onRoute) continue
70
+ const [err] = await Try.catch(() => plugin.onRoute!(req))
61
71
 
62
72
  if (err) {
63
73
  pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
@@ -67,7 +77,8 @@ export namespace PluginHooks {
67
77
 
68
78
  export async function onStart(server: any) {
69
79
  for (const plugin of getPlugins()) {
70
- const [err] = await Try.catch(() => plugin.onStart?.(server))
80
+ if (!plugin.onStart) continue
81
+ const [err] = await Try.catch(() => plugin.onStart!(server))
71
82
 
72
83
  if (err) {
73
84
  pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
@@ -7,7 +7,40 @@ import { response } from '../../utils/http'
7
7
  import { Handler, type Route } from '../core/$base'
8
8
  import { getStatic } from '../core/$static'
9
9
 
10
- const IS_IMAGE_REGEX = /(.*)\/(.*)(;(\d+))?\.(png|jpg|jpeg|webp|gif|bmp)$/i
10
+ /**
11
+ * Does this path name an image?
12
+ *
13
+ * **`[^/]*`, not `.*`, and the difference is a denial of service.** This read
14
+ * `/(.*)\\/(.*)(;(\\d+))?\\.(png|…)$/`, whose two greedy groups could each match
15
+ * the separator, so every `/` in the path doubled the ways the engine could
16
+ * split it. The work is superlinear and it is paid by paths that do **not**
17
+ * match, because a failing match is the one that has to try every split before
18
+ * it can say no — and `canHandle` runs on every route-cache miss, above the
19
+ * handlers that serve ordinary pages.
20
+ *
21
+ * Measured on Bun 1.4.0, one call, path of `/` + `a/` × n + `x.txt`:
22
+ *
23
+ * slashes chars old new
24
+ * 64 134 0.98 ms 0.00091 ms
25
+ * 128 262 7.05 ms 0.00146 ms
26
+ * 256 518 63.88 ms 0.00272 ms
27
+ * 512 1030 512.55 ms 0.00564 ms
28
+ *
29
+ * One unauthenticated request with a 1 KB path stalled the event loop for half
30
+ * a second, and the rate limiter cannot help at one request per second. A
31
+ * filename cannot contain a separator, so the second group never should have
32
+ * been able to: bounding it removes the ambiguity and the cost with it.
33
+ *
34
+ * The captures are gone because nothing read them — `canHandle` only calls
35
+ * `.test()`, and `IMAGE_CAPTURE` below is what parses the parts. The trailing
36
+ * `(;(\\d+))?` was dead for the same reason: `.*` already covered `;800`.
37
+ *
38
+ * Equivalence is not an argument from reading: old and new agree on a 40-path
39
+ * corpus and on 200,000 random strings over the alphabet that makes the shapes
40
+ * interesting ([ab/.;1png-_]), with zero disagreements. `image.test.ts` keeps
41
+ * both halves.
42
+ */
43
+ const IS_IMAGE_REGEX = /\/[^/]*\.(?:png|jpg|jpeg|webp|gif|bmp)$/i
11
44
  const IMAGE_CAPTURE = /^(.+?)([^/;.]+)(?:;(\d+))?\.([a-zA-Z0-9]+)$/i
12
45
 
13
46
  export class ImageHandler extends Handler {
@@ -233,21 +233,59 @@ export class DynamicHandler extends Handler {
233
233
  */
234
234
  static resolveRoute(path: string): Promise<Route.Info | null>
235
235
  static async resolveRoute(path: string) {
236
- const info = await this.resolveRouteFile(path)
237
- if (!info) return null
236
+ return this.checkBlocked(await this.resolveRouteFile(path))
237
+ }
238
238
 
239
+ /**
240
+ * Resolve `path` to a **literal file only** — never a dynamic or catch-all
241
+ * match.
242
+ *
243
+ * For the error handlers, which look up `error` and `error-<code>` pages by
244
+ * name. Asking the ordinary resolver for those was wrong twice over.
245
+ *
246
+ * **It served the wrong page.** `resolveRoute` falls through to dynamic
247
+ * matching, so an app with a root `[...slug].tsx` had every error page
248
+ * claimed by the catch-all, and `/blog/[id].tsx` answered a 404 under
249
+ * `/blog` by rendering a post with `id = 'error'`. An error page is a file
250
+ * an application put there on purpose; nothing about it should be pattern
251
+ * matched.
252
+ *
253
+ * **And it was most of the cost of a 404.** `DynamicErrorHandler` walks the
254
+ * path prefixes, so a miss ran two full resolutions per segment — each one a
255
+ * glob scan plus an `isForbidden` tree-walk, and each one guaranteed to fail
256
+ * for an app with no error pages. That is why a 404 cost ten to thirty times
257
+ * a served page and grew with depth: 64 segments measured at over ten
258
+ * seconds. Static-only makes each probe one glob and no dynamic scan.
259
+ */
260
+ protected static resolveStaticRoute(path: string): Promise<Route.Info | null>
261
+ protected static async resolveStaticRoute(path: string) {
262
+ return this.checkBlocked(await this.resolveRouteFile(path, true))
263
+ }
264
+
265
+ /**
266
+ * The deny-list check both resolvers end in.
267
+ *
268
+ * Extracted rather than repeated: it is the clause that stops a resolved
269
+ * *file* from being served when the request path alone looked innocent (see
270
+ * `resolveRouteFile`'s note), and two copies is two places for it to be
271
+ * dropped from.
272
+ */
273
+ private static checkBlocked(info: Route.Info | null) {
274
+ if (!info) return null
239
275
  if (
240
276
  this.servesFiles &&
241
277
  matchBlockedCached(Bakery.config.blocked, `/${info.path}`)
242
278
  ) {
243
279
  return null
244
280
  }
245
-
246
281
  return info
247
282
  }
248
283
 
249
- protected static resolveRouteFile(path: string): Promise<Route.Info | null>
250
- protected static async resolveRouteFile(path: string) {
284
+ protected static resolveRouteFile(
285
+ path: string,
286
+ staticOnly?: boolean,
287
+ ): Promise<Route.Info | null>
288
+ protected static async resolveRouteFile(path: string, staticOnly = false) {
251
289
  const cached = this.getCachedRoute(path)
252
290
  if (cached) return cached
253
291
 
@@ -269,6 +307,11 @@ export class DynamicHandler extends Handler {
269
307
  return this.cacheStaticRoute(path, staticInfo)
270
308
  }
271
309
 
310
+ // A literal file was all that was asked for. The dynamic passes below are
311
+ // not merely skipped as an optimization — for an error page they would be
312
+ // wrong, and `resolveStaticRoute` says why.
313
+ if (staticOnly) return null
314
+
272
315
  // `findDynamicRoute` already required `valid` and `!isForbidden` against
273
316
  // `Bakery.serveRoot` before returning, so wrapping it in
274
317
  // `validateCachedRoute` — which asserts exactly those two, against exactly
@@ -279,9 +279,12 @@ export class DynamicErrorHandler extends DynamicHandler {
279
279
 
280
280
  const defsPage = `${prefix}/error`
281
281
  const codePage = `${prefix}/error-${errors.errorCode}`
282
+ // `resolveStaticRoute`, never `resolveRoute`: an error page is a file by
283
+ // name, and letting the dynamic matcher answer meant a root catch-all
284
+ // claimed every one of them. See the note on that method.
282
285
  const routeInfo =
283
- (await super.resolveRoute(codePage)) ||
284
- (await super.resolveRoute(defsPage))
286
+ (await super.resolveStaticRoute(codePage)) ||
287
+ (await super.resolveStaticRoute(defsPage))
285
288
  if (routeInfo) return routeInfo
286
289
  }
287
290
 
@@ -1,4 +1,5 @@
1
1
  import { LRUCache } from '../../cache/lru'
2
+ import { hostKey } from '../../core/bakery'
2
3
  import type { Handler } from './$base'
3
4
 
4
5
  /**
@@ -126,8 +127,17 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
126
127
  // must not run twice. That constraint is what the branching encodes.
127
128
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cache-path + side-effect-sensitive probe loop
128
129
  async resolve(path: string, req?: Request, ...rest: any[]) {
129
- const host = req?.__hostname || ''
130
- const pathId = `${this.id}:${host}:${path}`
130
+ // The **resolved** host, not the header. `__hostname` is whatever arrived
131
+ // in `Host`, and `routeCache` is one bounded LRU shared by every tenant, so
132
+ // a client varying that header minted an entry per spelling and walked the
133
+ // real hosts' entries out of the cache for as long as it kept asking. The
134
+ // file cache carried the same bug and was fixed; this one kept it.
135
+ //
136
+ // `hostKey` resolves through the multi-host config the way every other
137
+ // per-tenant key does, so an unknown or unconfigured host collapses to a
138
+ // single entry instead of one per spelling. The shape is unchanged when
139
+ // there is no host: `hostKey` returns the bare path.
140
+ const pathId = `${this.id}:${hostKey(path)}`
131
141
  const cached: any = HandlerMap.routeCache.get(pathId)
132
142
 
133
143
  // Only tracked once there is a cache hit to skip past. Middleware has
@@ -80,6 +80,13 @@ const serveMsgs = {
80
80
  // marker was written and the next boot will try again. Worth a line rather
81
81
  // than silence: something is holding those files open, and a stale compiled
82
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.',
83
90
  CACHE_WIPE_INCOMPLETE:
84
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.',
85
92
  } as const
@@ -137,6 +144,7 @@ const pluginMsgs = {
137
144
  UNHANDLED_ERR: 'E Unhandled Plugin Error: %r{error}%*',
138
145
  ANALYTICS_STORE_ERR: 'E Analytics store init failed: %r{error}%*',
139
146
  DASHBOARD_BUNDLE_ERR: 'E Failed to bundle %ydashboard.js%*: %r{error}%*',
147
+ EXPLORER_QUERY_ERR: 'E Explorer %y{op}%* failed: %r{error}%*',
140
148
  } as const
141
149
 
142
150
  export const pluginLog = messageLogger(new Logger('plugins'), pluginMsgs)
package/src/session.ts CHANGED
@@ -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
 
@@ -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
  }
@@ -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/utils/fs.ts CHANGED
@@ -281,9 +281,29 @@ export namespace FileSystem {
281
281
  return (await Try(() => Bun.file(path).stat()))?.isDirectory() || false
282
282
  }
283
283
 
284
+ /**
285
+ * Handed a `BunFile`, this used to take the path back off it and build a
286
+ * **second** one to stat. A `BunFile` caches its own stat, so the instance
287
+ * the caller already has is free to read and the fresh one is a syscall:
288
+ * one question about one file cost two stats and an allocation.
289
+ *
290
+ * It shows up wherever a caller asks both questions, which is the common
291
+ * shape - `validCache` below, `nm.ts`'s `exists(nmFile) ? nmFile.lastModified`,
292
+ * and both mtime comparisons in `image.ts`. Measured on this machine, four
293
+ * interleaved rounds against a CPU-bound control flat at 27-31 ms:
294
+ *
295
+ * validCache, file present 33.0 us -> 17.3 us 1.9x
296
+ * validCache, file absent 34.9 us -> 17.4 us 2.0x
297
+ *
298
+ * The predicate is unchanged, and the two clauses in it are not
299
+ * interchangeable. `lastModified` on a **missing** file answers roughly
300
+ * `Date.now()` rather than 0, which is what the `< Date.now()` guard is
301
+ * for; dropping it reports every absent file as present. The `size > 0`
302
+ * fallback then covers a file whose mtime is in the future, which clock
303
+ * skew on a network share produces.
304
+ */
284
305
  export function exists(path: string | Bun.BunFile): boolean {
285
- path = typeof path === 'string' ? path : path.name || ''
286
- const file = Bun.file(path)
306
+ const file = typeof path === 'string' ? Bun.file(path) : path
287
307
  const lastMod = file.lastModified
288
308
  return Boolean((lastMod && lastMod < Date.now()) || file.size > 0)
289
309
  }
@@ -452,7 +472,41 @@ export namespace FileSystem {
452
472
  return false
453
473
  }
454
474
 
455
- let probeForbidden: (path: string) => boolean = nodeExistsSync
475
+ /**
476
+ * `statSync` with ENOENT suppressed, not `existsSync`.
477
+ *
478
+ * Node's `existsSync` is a `stat` with a `try`/`catch` around it, and on
479
+ * Bun/Windows the throw-and-catch for a path that is not there is most of
480
+ * what it costs. Asking for the stat directly and reading `undefined` for a
481
+ * miss skips that. Measured on this machine, four interleaved rounds
482
+ * against a CPU-bound control that stayed flat at 27-30 ms:
483
+ *
484
+ * existsSync 30.00 us guarded statSync 17.52 us 1.71x
485
+ *
486
+ * A miss is the case that matters: almost no directory holds a `.forbidden`
487
+ * marker, so every level of every walk takes this path. At four unique
488
+ * levels per request that is 50 us, at six 75 us.
489
+ *
490
+ * The `catch` is load-bearing rather than defensive habit. `throwIfNoEntry:
491
+ * false` suppresses **ENOENT only**, so a directory the process cannot stat
492
+ * still throws EACCES or EPERM where `existsSync` answers false - and a
493
+ * throw here would escape through `isForbidden` into the request. Answering
494
+ * false matches the behaviour this replaces exactly; the two forms agree on
495
+ * a missing marker, a present one, a marker that is a *directory*, a path
496
+ * several levels below anything that exists, a directory, and the empty
497
+ * string.
498
+ */
499
+ function statProbe(path: string): boolean {
500
+ try {
501
+ return nodeStatSync(path, { throwIfNoEntry: false }) !== undefined
502
+ } catch {
503
+ // See above: a stat that fails for any reason other than absence is
504
+ // reported as absent, which is what `existsSync` did.
505
+ return false
506
+ }
507
+ }
508
+
509
+ let probeForbidden: (path: string) => boolean = statProbe
456
510
 
457
511
  /**
458
512
  * Test seam (convention 9): lets `fs.test.ts` count the syscalls the
@@ -464,7 +518,7 @@ export namespace FileSystem {
464
518
  }
465
519
 
466
520
  export function __resetForbiddenProbe() {
467
- probeForbidden = nodeExistsSync
521
+ probeForbidden = statProbe
468
522
  }
469
523
 
470
524
  export async function mkdir(path: string) {
@@ -508,6 +562,10 @@ export namespace FileSystem {
508
562
  return compressable.has(cleanExt)
509
563
  }
510
564
 
565
+ /**
566
+ * Two reads of one `BunFile`, which is one stat: `exists` no longer
567
+ * re-wraps the instance, so the second read comes off the cached stat.
568
+ */
511
569
  function validCache(file: Bun.BunFile, sourceMtime: number | null): boolean {
512
570
  return exists(file) && (!sourceMtime || sourceMtime <= file.lastModified)
513
571
  }
@@ -243,17 +243,32 @@ export namespace ETag {
243
243
  // in-memory Blob or a `sendText` string body ignores Range entirely
244
244
  // (served whole, 200), which is why `.name` gates the claim.
245
245
  //
246
- // Skipped when Bun's own range path is about to answer (a GET carrying
247
- // `Range`): that path appends its own `Accept-Ranges: bytes` to the
248
- // 206/416, and setting it here too emitted `bytes, bytes`. The trade,
249
- // measured on Bun 1.4.0: a GET whose Range is malformed or multipart is
250
- // served whole with no advertisement from either side acceptable,
251
- // since a client that already sent `Range` is not the one this probe
252
- // header exists for. HEAD ignores `Range` and gets the header even when
253
- // one is present.
246
+ // Skipped when Bun's own range path is about to answer (a GET carrying a
247
+ // single-range `Range`): that path appends its own `Accept-Ranges: bytes`
248
+ // to the 206/416, and setting it here too emitted `bytes, bytes`.
249
+ //
250
+ // A **multipart** range (`bytes=0-1,10-11`) is carved back out of the
251
+ // skip: Bun does not do multipart it serves the whole file as a 200 and
252
+ // appends nothing so the skip left that response advertising nothing at
253
+ // all, which reads as "ranges not supported" to the one client that just
254
+ // demonstrated it wants them. Found by a downstream smoke test. The comma
255
+ // is the entire multipart grammar, so this cannot drift from Bun's own
256
+ // parse the way a real Range parser here could; RFC-legal multipart gets
257
+ // the 200-with-advertisement it deserves. What stays excluded-and-silent
258
+ // is a *malformed* single range (`Range: potato`) — also served whole by
259
+ // Bun, but that requester sent garbage, and mirroring Bun's full validity
260
+ // judgment here is exactly the second-parser drift this comment refuses.
261
+ // HEAD ignores `Range` and gets the header even when one is present.
262
+ const rangeValue = req?.headers.get('range')
254
263
  if (
255
264
  resolvedFile.name &&
256
- !(req && req.method === 'GET' && req.headers.has('range'))
265
+ !(
266
+ req &&
267
+ req.method === 'GET' &&
268
+ rangeValue !== null &&
269
+ rangeValue !== undefined &&
270
+ !rangeValue.includes(',')
271
+ )
257
272
  ) {
258
273
  headers['Accept-Ranges'] = 'bytes'
259
274
  }
@@ -22,11 +22,29 @@ export class SharedMemoryPool {
22
22
  header!: Int32Array
23
23
  counters!: Int32Array
24
24
  rateLimits!: Int32Array
25
- dataPool!: Uint8Array
26
25
 
27
- constructor(sizeOrBuffer: number | SharedArrayBuffer = 1024 * 1024) {
26
+ /**
27
+ * **The default is the layout's own size, not a megabyte.**
28
+ *
29
+ * It was `1024 * 1024`, and the layout uses 9,280 bytes of it: a 64-byte
30
+ * header, 1 KB of counters and 8 KB of rate-limit slots. The remaining
31
+ * 1,039,296 bytes were a `dataPool` region that **nothing read** - grep it
32
+ * across every package and app, and the only reference outside this file was
33
+ * a test asserting it was a `Uint8Array`. So 99.1% of a `SharedArrayBuffer`
34
+ * allocated at import in every server process existed for one assertion.
35
+ *
36
+ * The region is gone rather than shrunk. A scratch area with no consumer is
37
+ * not a feature waiting to be used; it is a number nobody can size, because
38
+ * there is nothing to size it against. Something that needs shared scratch
39
+ * later adds it along with the code that reads it.
40
+ *
41
+ * A larger size is still accepted and still honoured, because `bind()` reads
42
+ * the size out of the header rather than trusting `byteLength` - so a
43
+ * cluster master that allocates more and shares it still works.
44
+ */
45
+ constructor(sizeOrBuffer: number | SharedArrayBuffer = BUFFER_START_OFFSET) {
28
46
  if (typeof sizeOrBuffer === 'number') {
29
- const size = Math.max(sizeOrBuffer, BUFFER_START_OFFSET + 1024)
47
+ const size = Math.max(sizeOrBuffer, BUFFER_START_OFFSET)
30
48
  this.buffer = new SharedArrayBuffer(size)
31
49
  this.header = new Int32Array(this.buffer, 0, HEADER_INT_COUNT)
32
50
  this.counters = new Int32Array(
@@ -39,12 +57,6 @@ export class SharedMemoryPool {
39
57
  HEADER_BYTES + COUNTERS_BYTES,
40
58
  RATE_LIMIT_SLOT_COUNT * 2,
41
59
  )
42
- this.dataPool = new Uint8Array(
43
- this.buffer,
44
- BUFFER_START_OFFSET,
45
- size - BUFFER_START_OFFSET,
46
- )
47
-
48
60
  Atomics.store(this.header, 0, 0x42414b45)
49
61
  Atomics.store(this.header, 1, size)
50
62
  Atomics.store(this.header, 2, BUFFER_START_OFFSET)
@@ -70,12 +82,7 @@ export class SharedMemoryPool {
70
82
  HEADER_BYTES + COUNTERS_BYTES,
71
83
  RATE_LIMIT_SLOT_COUNT * 2,
72
84
  )
73
- const size = Atomics.load(this.header, 1) || this.buffer.byteLength
74
- this.dataPool = new Uint8Array(
75
- this.buffer,
76
- BUFFER_START_OFFSET,
77
- size - BUFFER_START_OFFSET,
78
- )
85
+
79
86
  }
80
87
 
81
88
  incrementCounter(slot: number, delta = 1): number {