@bakery-framework/core 2.2.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.2.2",
3
+ "version": "2.4.0",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -12,6 +12,10 @@ import { fs } from '../utils/fs'
12
12
  import { checkCacheVersion } from './cache-version'
13
13
  import { hostStore } from './context'
14
14
 
15
+ // Re-exported for the CLI's boot checks, which reach config through this
16
+ // subpath; the module itself imports nothing, so it can sit under the router.
17
+ export { checkHostMounts } from './host-mounts'
18
+
15
19
  export const NOOP = () => {}
16
20
 
17
21
  const defaultConfig: Required<AppConfig> = {
@@ -63,6 +67,7 @@ const defaultConfig: Required<AppConfig> = {
63
67
  sessions: {},
64
68
  root: 'src',
65
69
  hosts: {},
70
+ hostMounts: [],
66
71
  websocket: {
67
72
  message: NOOP,
68
73
  open: NOOP,
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Host mounts: a host that serves one path subtree of the app at its root.
3
+ *
4
+ * `hostMounts: [{ host: 'kit.*', path: '/kit' }]` makes `kit.example.com/forms`
5
+ * the page `/kit/forms` is on every other host. The subtree stays part of the
6
+ * app: its pages load the app's modules, stylesheets and API from the root,
7
+ * the way they do on any host, so only page requests move into it. Which
8
+ * requests those are is `routeOf` in `router.ts`.
9
+ *
10
+ * Patterns rather than `hosts` keys, because a mount's hostname is often not
11
+ * known at boot: an app whose domain is a setting can still name "the `kit`
12
+ * label of whatever domain this is". A mount is also not a tenant. It changes
13
+ * which page a path names and nothing else, so sessions, caches and the
14
+ * `hosts` entries do not see it.
15
+ */
16
+
17
+ export type HostMount = {
18
+ /**
19
+ * The hostnames the mount serves: a name (`kit.example.com`), a pattern in
20
+ * which `*` stands for one or more whole labels (`kit.*`,
21
+ * `*.docs.example.com`), or a function of the hostname, which it receives
22
+ * lowercased and without the port.
23
+ */
24
+ host: string | ((hostname: string) => boolean)
25
+ /** The subtree served at the host's root, such as `/kit`. */
26
+ path: string
27
+ }
28
+
29
+ export type CompiledHostMount = {
30
+ /** The subtree: a leading slash and no trailing one. */
31
+ readonly path: string
32
+ /** Whether a lowercased hostname without its port is served by the mount. */
33
+ matches(hostname: string): boolean
34
+ }
35
+
36
+ /** One label of a hostname pattern, `*` aside. */
37
+ const LABEL = /^[a-z0-9_-]+$/
38
+
39
+ /** `*` as a pattern label: one or more whole labels. */
40
+ const ANY_LABELS = '[^.]+(?:\\.[^.]+)*'
41
+
42
+ /**
43
+ * A hostname pattern as a regular expression. Throws naming `where` when
44
+ * `host` is not a name, or puts `*` inside a label: `k*t.example.com` would
45
+ * have to choose between matching part of a label and matching labels, and
46
+ * either reading surprises someone.
47
+ */
48
+ export function hostPattern(host: string, where = 'host'): RegExp {
49
+ const labels = host.toLowerCase().split('.')
50
+ if (labels.some(label => label === '')) {
51
+ throw new Error(
52
+ `${where} '${host}' is not a hostname: a name has no empty label`,
53
+ )
54
+ }
55
+ const parts = labels.map(label => {
56
+ if (label === '*') return ANY_LABELS
57
+ if (!LABEL.test(label)) {
58
+ throw new Error(
59
+ `${where} '${host}' has the label '${label}': a label is letters, digits, '-' and '_', and '*' stands for whole labels only`,
60
+ )
61
+ }
62
+ return label
63
+ })
64
+ return new RegExp(`^${parts.join('\\.')}$`)
65
+ }
66
+
67
+ /**
68
+ * What every host serves from the app's root, a mounted one included: the
69
+ * framework's and the plugins' routes, the API, and the site-wide metadata
70
+ * RFC 8615 reserves. The router keeps requests under these out of a mount,
71
+ * and no mount may name a path under one.
72
+ */
73
+ export const ROOT_ONLY = ['/_', '/api/', '/.well-known/'] as const
74
+
75
+ function mountPath(value: unknown, where: string): string {
76
+ if (typeof value !== 'string' || !value.startsWith('/')) {
77
+ throw new Error(
78
+ `${where} must be a path starting with '/': got ${JSON.stringify(value)}`,
79
+ )
80
+ }
81
+ const path = value.replace(/\/+$/, '')
82
+ if (path === '') {
83
+ throw new Error(
84
+ `${where} '${value}' is the whole app, which every host already serves`,
85
+ )
86
+ }
87
+ const segments = path.slice(1).split('/')
88
+ if (segments.some(s => s === '' || s === '.' || s === '..')) {
89
+ throw new Error(
90
+ `${where} '${value}' has an empty, '.' or '..' segment: a mount names one directory`,
91
+ )
92
+ }
93
+ if (/[?#*\\]/.test(path)) {
94
+ throw new Error(
95
+ `${where} '${value}' is a path, so it holds no '?', '#', '*' or '\\'`,
96
+ )
97
+ }
98
+ const reserved = ROOT_ONLY.find(prefix => `${path}/`.startsWith(prefix))
99
+ if (reserved) {
100
+ throw new Error(
101
+ `${where} '${value}' is under ${reserved}, which every host serves as it is`,
102
+ )
103
+ }
104
+ return path
105
+ }
106
+
107
+ function compile(mount: HostMount, where: string): CompiledHostMount {
108
+ if (!mount || typeof mount !== 'object') {
109
+ throw new Error(`${where} must be { host, path }`)
110
+ }
111
+ const path = mountPath(mount.path, `${where}.path`)
112
+ const { host } = mount
113
+ if (typeof host === 'function') return { path, matches: host }
114
+ if (typeof host !== 'string') {
115
+ throw new Error(
116
+ `${where}.host must be a hostname, a pattern such as 'kit.*', or a function of the hostname`,
117
+ )
118
+ }
119
+ const pattern = hostPattern(host, `${where}.host`)
120
+ return { path, matches: hostname => pattern.test(hostname) }
121
+ }
122
+
123
+ /**
124
+ * Compiled once per array: the processed config is frozen for the life of
125
+ * the process, and a test that swaps it in brings a new array, so identity is
126
+ * the whole validity check.
127
+ */
128
+ const compiled = new WeakMap<
129
+ readonly HostMount[],
130
+ readonly CompiledHostMount[]
131
+ >()
132
+
133
+ /** `hostMounts` compiled, throwing at the first entry that cannot mean anything. */
134
+ export function compileHostMounts(
135
+ mounts: readonly HostMount[],
136
+ ): readonly CompiledHostMount[] {
137
+ const cached = compiled.get(mounts)
138
+ if (cached) return cached
139
+ if (!Array.isArray(mounts)) {
140
+ throw new Error('hostMounts must be an array of { host, path }')
141
+ }
142
+ const result = mounts.map((mount, i) => compile(mount, `hostMounts[${i}]`))
143
+ compiled.set(mounts, result)
144
+ return result
145
+ }
146
+
147
+ /**
148
+ * Compile the app's `hostMounts` before anything binds, so an entry that
149
+ * cannot mean anything stops the boot and names itself, rather than throwing
150
+ * on the first request that reaches it.
151
+ */
152
+ export function checkHostMounts(config: {
153
+ hostMounts: readonly HostMount[]
154
+ }): void {
155
+ compileHostMounts(config.hostMounts)
156
+ }
157
+
158
+ /** The first mount serving `hostname`, or null. */
159
+ export function findHostMount(
160
+ mounts: readonly CompiledHostMount[],
161
+ hostname: string,
162
+ ): CompiledHostMount | null {
163
+ if (!mounts.length) return null
164
+ const host = hostname.toLowerCase()
165
+ for (const mount of mounts) {
166
+ if (mount.matches(host)) return mount
167
+ }
168
+ return null
169
+ }
170
+
171
+ /** `path` on a mounted host, as the app names it: `/` is the mount itself. */
172
+ export function mountedPath(path: string, mount: string): string {
173
+ return path === '/' ? mount : `${mount}${path}`
174
+ }
package/src/global.d.ts CHANGED
@@ -168,6 +168,17 @@ declare global {
168
168
  sessions?: import('./session').SessionOptions
169
169
 
170
170
  hosts?: Record<string, HostEntry>
171
+
172
+ /**
173
+ * Hosts that serve one path subtree of the app at their root:
174
+ * `{ host: 'kit.*', path: '/kit' }` makes `kit.example.com/forms` the
175
+ * page `/kit/forms`. Only page requests move into the subtree (a
176
+ * navigation, or a method other than GET and HEAD); the modules,
177
+ * stylesheets, files and API those pages load come from the app's root,
178
+ * as on any host. The first entry whose `host` matches wins. See
179
+ * `core/host-mounts.ts` and `routeOf` in `router.ts`.
180
+ */
181
+ hostMounts?: import('./core/host-mounts').HostMount[]
171
182
  }
172
183
 
173
184
  type ServerPlugin = _plugins.ServerPlugin
package/src/router.ts CHANGED
@@ -1,9 +1,16 @@
1
- import { Bakery, hostStore } from './core/bakery'
1
+ import { Bakery, getHostname, hostStore } from './core/bakery'
2
2
  import { matchBlockedCached } from './core/context'
3
+ import {
4
+ compileHostMounts,
5
+ findHostMount,
6
+ mountedPath,
7
+ ROOT_ONLY,
8
+ } from './core/host-mounts'
3
9
  import { PluginHooks } from './core/plugins'
4
10
  import { DefaultErrorHandler } from './handlers/assets/static'
5
11
  import type { Handler } from './handlers/core/$base'
6
12
  import { ErrorHandler } from './handlers/core/$error'
13
+ import { resolveMount } from './handlers/core/$mounts'
7
14
  import { WebSocketHandler } from './handlers/core/$websocket'
8
15
  import { errorMsg, getElapsed, log, serveLog } from './logger'
9
16
  import { Session } from './session'
@@ -82,14 +89,104 @@ function servesFiles(handler: any): boolean {
82
89
  return handler?.servesFiles !== false
83
90
  }
84
91
 
92
+ /**
93
+ * How routing reads one request: the path it resolves, the host mount it
94
+ * moved into, and whether this URL's answer depends on the kind of request,
95
+ * which is what `processResponse` sends a `Vary` for.
96
+ */
97
+ type Routing = { path: string; mount: string | null; varies: boolean }
98
+
99
+ export type RequestRoute = Readonly<Pick<Routing, 'path' | 'mount'>>
100
+
101
+ /** Weak, as `parsedUrl`'s memo is: an entry lives as long as its request. */
102
+ const routings = new WeakMap<Request, Routing>()
103
+
104
+ /**
105
+ * Whether `path` could be a page of a mounted subtree: nothing in `ROOT_ONLY`,
106
+ * no file named, and no plugin's route mount or proxy entry claiming it.
107
+ *
108
+ * "No file named" is a last segment with no extension. A page links its
109
+ * stylesheets, images and downloads by their root paths, and a navigation to
110
+ * one of them (a PDF opened in a tab) has to reach the file.
111
+ */
112
+ function pagePathOnMount(
113
+ path: string,
114
+ config: Readonly<ProcessedAppConfig>,
115
+ ): boolean {
116
+ if (ROOT_ONLY.some(prefix => path.startsWith(prefix))) return false
117
+ if (path.slice(path.lastIndexOf('/') + 1).includes('.')) return false
118
+ if (resolveMount(path)) return false
119
+ for (const prefix in config.proxy) {
120
+ if (path.startsWith(prefix)) return false
121
+ }
122
+ return true
123
+ }
124
+
125
+ /**
126
+ * Whether `req` asks for a page rather than for something a page loads.
127
+ *
128
+ * A navigation (a tab or a frame loading the URL) is one, and so is any
129
+ * method but GET and HEAD: a form post, or a server action, which a Vue page
130
+ * posts to its own URL. A module import, a stylesheet, an image and a `fetch`
131
+ * are not, and that is the line that has to hold: an import of a TypeScript
132
+ * module is written `/kit/useDemo`, with no extension to tell it from a page,
133
+ * and moved into the subtree it would reach a catch-all page instead.
134
+ *
135
+ * A browser says which it is in `Sec-Fetch-Mode`. A client that sends no
136
+ * Fetch Metadata is navigating when it asks for HTML.
137
+ */
138
+ function isPageRequest(req: Request): boolean {
139
+ if (req.method !== 'GET' && req.method !== 'HEAD') return true
140
+ if (req.headers.has('upgrade')) return false
141
+ const mode = req.headers.get('sec-fetch-mode')
142
+ if (mode) return mode === 'navigate'
143
+ return (req.headers.get('accept') ?? '').includes('text/html')
144
+ }
145
+
146
+ function routing(req: Request): Routing {
147
+ const cached = routings.get(req)
148
+ if (cached) return cached
149
+
150
+ // Shared with worker.ts, which has already asked for the same parse; a
151
+ // direct caller (test, embedder) gets it parsed here instead.
152
+ const path = parsedUrl(req).pathname
153
+ let result: Routing = { path, mount: null, varies: false }
154
+
155
+ // A partial config (a test's, an embedder's) may carry no `hostMounts`.
156
+ const config = Bakery.config
157
+ const mounts = config.hostMounts?.length
158
+ ? compileHostMounts(config.hostMounts)
159
+ : null
160
+ const mount = mounts && findHostMount(mounts, getHostname(req, config))
161
+ if (mount && pagePathOnMount(path, config)) {
162
+ result = isPageRequest(req)
163
+ ? { path: mountedPath(path, mount.path), mount: mount.path, varies: true }
164
+ : { path, mount: null, varies: true }
165
+ }
166
+
167
+ routings.set(req, result)
168
+ return result
169
+ }
170
+
171
+ /**
172
+ * The path routing resolves for `req`, and the host mount it moved into.
173
+ *
174
+ * On a host that no entry of `hostMounts` serves, that is the request's own
175
+ * path and no mount. On a mounted host, a page request (`isPageRequest`) for
176
+ * a path that can be a page (`pagePathOnMount`) resolves under the mount:
177
+ * `/forms` is `/kit/forms`, and `/` is `/kit`. Every other request keeps its
178
+ * path, so the modules, stylesheets and API a subtree's pages load still come
179
+ * from the app's root. Memoized per request.
180
+ */
181
+ export function routeOf(req: Request): RequestRoute {
182
+ return routing(req)
183
+ }
184
+
85
185
  export function handleRequest(
86
186
  req: Request,
87
187
  ): Handler.Response | MixedPromise<symbol>
88
188
  export async function handleRequest(req: Request) {
89
- // Shared with worker.ts, which has already asked for the same parse; a
90
- // direct caller (test, embedder) gets it parsed here instead.
91
- const url = parsedUrl(req)
92
- const path = url.pathname
189
+ const path = routing(req).path
93
190
 
94
191
  // One read of the config getter, not three: `Bakery.serveRoot` walks
95
192
  // `hostStore.getStore()?.config ?? getConfig()` on every access, and the
@@ -312,6 +409,21 @@ export async function handleRequestError(
312
409
  return DefaultErrorHandler.handle(path, req, error)
313
410
  }
314
411
 
412
+ /** Add `tokens` to `res`'s `Vary`, keeping what it names and naming each once. */
413
+ function addVary(res: Response, tokens: readonly string[]): void {
414
+ const existing = res.headers.get('Vary')
415
+ const named = new Set(
416
+ (existing ?? '')
417
+ .split(',')
418
+ .map(token => token.trim().toLowerCase())
419
+ .filter(Boolean),
420
+ )
421
+ if (named.has('*')) return
422
+ const missing = tokens.filter(token => !named.has(token.toLowerCase()))
423
+ if (!missing.length) return
424
+ res.headers.set('Vary', [existing, ...missing].filter(Boolean).join(', '))
425
+ }
426
+
315
427
  export async function processResponse(
316
428
  data: Handler.Response | MixedPromise<symbol>,
317
429
  req: Request,
@@ -373,6 +485,11 @@ export async function processResponse(
373
485
  const cors = Bakery.config.cors
374
486
  if (cors) applyCors(cors, req, resp)
375
487
 
488
+ // On a mounted host one URL can answer a navigation from the subtree and a
489
+ // module import from the root (see `routeOf`), so a cache keyed on the URL
490
+ // alone could hand either one to the other.
491
+ if (routing(req).varies) addVary(resp, ['Sec-Fetch-Mode', 'Accept'])
492
+
376
493
  const final = ETag.sendResponse(req, resp)
377
494
  if (!(final instanceof Response)) {
378
495
  log({
@@ -0,0 +1,90 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { Bakery } from './core/bakery'
4
+
5
+ /**
6
+ * Session cookies are signed: `sId=<id>.<mac>`, an HMAC-SHA256 of the id.
7
+ *
8
+ * The id alone is the bearer token, and it is unguessable, so the signature
9
+ * adds nothing to whether a stolen cookie works. What it buys is a refusal
10
+ * that costs nothing: with `sessions.store`, every request carrying a
11
+ * well-formed id used to cost a lookup by primary key before the rate limiter
12
+ * could refuse it (`Session.attach` runs first, since `keyBy` may read the
13
+ * session), so a flood of made-up ids from one address queued on the pool
14
+ * behind real traffic. A made-up id now fails here, in microseconds, and no
15
+ * store is asked. The built-in store gains the same, for its SQLite tier.
16
+ *
17
+ * The stored id is unchanged: only the cookie carries the MAC, so no store
18
+ * row or column moves.
19
+ */
20
+
21
+ /** Bytes of the MAC a cookie carries: 128 bits, ample for a MAC. */
22
+ const MAC_BYTES = 16
23
+
24
+ /** The key made for this process from `bakery/session.key`, once read. */
25
+ let keptKey: Buffer | null = null
26
+
27
+ /**
28
+ * The key that signs: `sessions.secret` when set, else the one kept in the
29
+ * data directory, made on first use. Every worker on the machine reads the
30
+ * same file, and the first to make it wins (`wx`), so they agree. Servers
31
+ * sharing one store need the same `sessions.secret`.
32
+ */
33
+ function signingKey(secret: string | undefined): Buffer | string {
34
+ if (secret) return secret
35
+ keptKey ??= keyFromFile(`${Bakery.dataDir}/session.key`)
36
+ return keptKey
37
+ }
38
+
39
+ function keyFromFile(path: string): Buffer {
40
+ const read = () => Buffer.from(readFileSync(path, 'utf8').trim(), 'base64url')
41
+ try {
42
+ return read()
43
+ } catch (error: any) {
44
+ if (error?.code !== 'ENOENT') throw error
45
+ }
46
+
47
+ // Absent: make it. A worker that loses the race to create it reads the
48
+ // winner's instead, so every worker signs with the same key.
49
+ mkdirSync(Bakery.dataDir, { recursive: true })
50
+ const key = randomBytes(32)
51
+ try {
52
+ writeFileSync(path, key.toString('base64url'), { flag: 'wx', mode: 0o600 })
53
+ return key
54
+ } catch (error: any) {
55
+ if (error?.code === 'EEXIST') return read()
56
+ throw new Error(
57
+ `Session cookies are signed with a key kept in ${path}, which could not be written (${error?.message ?? error}). Set sessions.secret in server.config.ts instead.`,
58
+ )
59
+ }
60
+ }
61
+
62
+ function mac(key: Buffer | string, id: string): Buffer {
63
+ return createHmac('sha256', key).update(id).digest().subarray(0, MAC_BYTES)
64
+ }
65
+
66
+ /** The cookie value for a session id: the id, a dot, and its MAC. */
67
+ export function signSessionId(id: string, secret?: string): string {
68
+ return `${id}.${mac(signingKey(secret), id).toString('base64url')}`
69
+ }
70
+
71
+ /**
72
+ * The session id a cookie value names, or `''` when its MAC does not match
73
+ * (or it has none, as a cookie issued before signing has none): such a
74
+ * cookie names no session, and nothing is looked up for it. Compared in
75
+ * constant time, so the comparison says nothing about how close a guess came.
76
+ */
77
+ export function verifiedSessionId(value: string, secret?: string): string {
78
+ const dot = value.lastIndexOf('.')
79
+ if (dot <= 0) return ''
80
+ const id = value.slice(0, dot)
81
+ const given = Buffer.from(value.slice(dot + 1), 'base64url')
82
+ const expected = mac(signingKey(secret), id)
83
+ if (given.length !== expected.length) return ''
84
+ return timingSafeEqual(given, expected) ? id : ''
85
+ }
86
+
87
+ /** Test seam: forget the kept key, so the next use reads the file again. */
88
+ export function __forgetSessionKey(): void {
89
+ keptKey = null
90
+ }
package/src/session.ts CHANGED
@@ -3,6 +3,7 @@ import { Bakery, hostKey } from './core/bakery'
3
3
  import { peekConfig, resolveHostname } from './core/config'
4
4
  import { hostStore } from './core/context'
5
5
  import { errorMsg, serveLog } from './logger/serve-log'
6
+ import { signSessionId, verifiedSessionId } from './session-signing'
6
7
  import type { MapOf } from './types'
7
8
  import { deferredValue, hasDeferredValue } from './utils'
8
9
  import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
@@ -175,6 +176,13 @@ export interface SessionOptions {
175
176
  * `Session.endForAccount` can end every session of one account.
176
177
  */
177
178
  account?: string
179
+ /**
180
+ * Signs the session cookie, so a made-up id is refused before any store is
181
+ * asked (`session-signing.ts`). Omitted, a key is made once and kept in
182
+ * `bakery/session.key`. Servers that share one store need the same secret.
183
+ * Changing it ends every session: their cookies no longer verify.
184
+ */
185
+ secret?: string
178
186
  }
179
187
 
180
188
  /**
@@ -359,7 +367,8 @@ export class Session<
359
367
  req.url?.startsWith('https:') || forwardedHttps || import.meta.env.PROD,
360
368
  )
361
369
  const secureFlag = isHttps ? '; Secure' : ''
362
- return `sId=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secureFlag}`
370
+ const value = signSessionId(session.id, sessionOptions()?.secret)
371
+ return `sId=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secureFlag}`
363
372
  }
364
373
 
365
374
  public static delete(reqOrId: string | Request): boolean {
@@ -413,7 +422,10 @@ export class Session<
413
422
 
414
423
  if (!request.headers.has('cookie')) return ''
415
424
  const cookieHeader = request.headers.get('cookie') || ''
416
- return cookieHeader.match(/(?:^|;\s*)sId=([^;]+)/)?.[1] || ''
425
+ const value = cookieHeader.match(/(?:^|;\s*)sId=([^;]+)/)?.[1]
426
+ // Signed: an id whose MAC does not match names no session, so neither
427
+ // store is asked about it. See `session-signing.ts`.
428
+ return value ? verifiedSessionId(value, sessionOptions()?.secret) : ''
417
429
  }
418
430
 
419
431
  public static from<T extends MapOf<any> = MapOf<any>>(
package/src/utils/fs.ts CHANGED
@@ -3,7 +3,11 @@ import {
3
3
  existsSync as nodeExistsSync,
4
4
  statSync as nodeStatSync,
5
5
  } from 'node:fs'
6
- import { mkdir as fsMkdir, rm as fsRm } from 'node:fs/promises'
6
+ import {
7
+ mkdir as fsMkdir,
8
+ readdir as fsReaddir,
9
+ rm as fsRm,
10
+ } from 'node:fs/promises'
7
11
  import {
8
12
  dirname as nodeDirname,
9
13
  relative as nodeRelative,
@@ -288,9 +292,11 @@ export namespace FileSystem {
288
292
  * one question about one file cost two stats and an allocation.
289
293
  *
290
294
  * 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:
295
+ * shape - `nm.ts`'s `exists(nmFile) ? nmFile.lastModified`, both mtime
296
+ * comparisons in `image.ts`, and the cache check `validCache` was before
297
+ * the source's version went into the cache file's name (see
298
+ * `versionedName`). Measured on this machine, four interleaved rounds
299
+ * against a CPU-bound control flat at 27-31 ms:
294
300
  *
295
301
  * validCache, file present 33.0 us -> 17.3 us 1.9x
296
302
  * validCache, file absent 34.9 us -> 17.4 us 2.0x
@@ -563,19 +569,106 @@ export namespace FileSystem {
563
569
  }
564
570
 
565
571
  /**
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.
572
+ * The name a build of `cacheName` from a source of `sourceMtime` is kept
573
+ * under: the mtime goes into the name ahead of the extension, which still
574
+ * decides the MIME type and the compression (`abc.js` built from a source of
575
+ * 1791456519209 is `abc@1791456519209.js`). With no mtime to check, the
576
+ * plain name.
577
+ *
578
+ * So a cached file answers for one version of its source, and existence is
579
+ * the whole test. The test before compared the source's mtime with the
580
+ * cache file's mtime, which is its *write* time: a build that read the
581
+ * source, then saw it edited before it wrote, left a file newer than the
582
+ * edit, which then counted as current for it and served the previous
583
+ * version until the next edit. Measured with a build held open across an
584
+ * edit: the request for the edit and every request after it got the build
585
+ * of the source before it. That is how an SFC in a dev server kept its old
586
+ * styles beside a new template and script after three quick edits.
587
+ */
588
+ function versionedName(cacheName: string, sourceMtime: number | null): string {
589
+ if (!sourceMtime) return cacheName
590
+ const ext = parse(cacheName).ext
591
+ return `${cacheName.slice(0, cacheName.length - ext.length)}@${sourceMtime}${ext}`
592
+ }
593
+
594
+ /**
595
+ * Delete the builds of `cacheName` from sources older than `sourceMtime`, raw
596
+ * and compressed, once a newer one is written: every edit would otherwise
597
+ * leave its predecessor's three files behind for as long as the cache lives.
598
+ *
599
+ * Older only, so a build that finishes late never deletes a newer one beside
600
+ * it. Best effort, and off the request's path: a file another request is
601
+ * still sending cannot be deleted on Windows, and the next build's sweep
602
+ * finds it again. Listing the directory is the cost, 1.5 ms for 3000 entries
603
+ * on this machine, paid once per build.
604
+ */
605
+ async function dropOlderBuilds(
606
+ cacheDir: string,
607
+ cacheName: string,
608
+ sourceMtime: number,
609
+ ): Promise<void> {
610
+ const ext = parse(cacheName).ext
611
+ const stem = cacheName.slice(0, cacheName.length - ext.length)
612
+ const [, names] = await Try.catch(() => fsReaddir(cacheDir))
613
+ if (!names) return
614
+
615
+ const prefix = `${stem}@`
616
+ for (const name of names) {
617
+ if (!name.startsWith(prefix)) continue
618
+ const rest = name.slice(prefix.length)
619
+ const version = rest.match(/^(\d+)/)?.[1]
620
+ if (!version || Number(version) >= sourceMtime) continue
621
+ // The rest of the name after the version must be this entry's own
622
+ // extension, compressed or not: `abc@1.root.js` is another entry.
623
+ const suffix = rest.slice(version.length)
624
+ if (!buildSuffixes(ext).includes(suffix)) continue
625
+ // Not awaited one by one, and a failure is only a file left for the
626
+ // next sweep: see above.
627
+ void fsRm(resolve(cacheDir, name), { force: true }).catch(() => undefined)
628
+ }
629
+ }
630
+
631
+ /** `.js`, `.js.zst`, `.js.gz`: the names one build writes, for `ext`. */
632
+ function buildSuffixes(ext: string): string[] {
633
+ return [ext, ...COMPRESSION_MAP.map(c => `${ext}${c.ext}`)]
634
+ }
635
+
636
+ /**
637
+ * How long a superseded build is kept before its sweep. A build of an
638
+ * older source can finish after a newer one has written, and its caller
639
+ * opens the file it was handed a moment later: a sweep run straight away
640
+ * deleted it first, and that request answered ENOENT (2 runs in 8 of the
641
+ * test that holds a build open across an edit). Ten seconds is far longer
642
+ * than a request takes to open its file.
568
643
  */
569
- function validCache(file: Bun.BunFile, sourceMtime: number | null): boolean {
570
- return exists(file) && (!sourceMtime || sourceMtime <= file.lastModified)
644
+ let sweepDelayMs = 10_000
645
+
646
+ /** Test seam: how long a sweep waits. See `sweepDelayMs`. */
647
+ export function __setSweepDelay(ms: number): void {
648
+ sweepDelayMs = ms
649
+ }
650
+
651
+ /** Sweep the versions before `sourceMtime` once the delay has passed. */
652
+ function scheduleSweep(
653
+ cacheDir: string,
654
+ cacheName: string,
655
+ sourceMtime: number,
656
+ ): void {
657
+ const timer = setTimeout(
658
+ () => void dropOlderBuilds(cacheDir, cacheName, sourceMtime),
659
+ sweepDelayMs,
660
+ )
661
+ // A pending sweep is housekeeping: it must not hold a process open.
662
+ timer.unref?.()
571
663
  }
572
664
 
573
665
  type FileContent = string | Uint8Array<ArrayBuffer> | ArrayBuffer
574
666
 
575
667
  /**
576
- * Builds currently running, keyed on the resolved raw path.
668
+ * Builds currently running, keyed on the resolved raw path, which names the
669
+ * source's version (`versionedName`).
577
670
  *
578
- * `validCache` is a pure filesystem test with no record of work in progress,
671
+ * The cache test is a pure filesystem test with no record of work in progress,
579
672
  * so N concurrent first-hits on a cold cache each saw "not cached", each ran
580
673
  * `compiler()`, and each wrote the same three paths. Measured: 8 concurrent
581
674
  * callers produced 8 compiler invocations. Every `.ts`/`.tsx`/`.vue` asset
@@ -589,12 +682,12 @@ export namespace FileSystem {
589
682
  * bounded by construction (convention 6): an entry exists only while its own
590
683
  * build is running, never past it.
591
684
  *
592
- * The key is the path alone, not the path plus `sourceMtime`. A caller that
593
- * joins mid-build can therefore receive a build started for a marginally
594
- * older mtime: a window only as wide as one compile, which the next request
595
- * re-validates and rebuilds. Including the mtime would close that window by
596
- * letting two builds of the same file race each other onto the same three
597
- * output paths, which is the worse trade.
685
+ * The key carries the version because the path does. A caller for a newer
686
+ * source used to join a build of an older one, and this note said the next
687
+ * request would re-validate and rebuild: it did not, since that build's file
688
+ * was written after the edit and so counted as current (`versionedName`).
689
+ * Builds of two versions write two sets of paths now, so they never race
690
+ * onto the same three.
598
691
  */
599
692
  const inFlightBuilds = new Map<string, Promise<Bun.BunFile | null>>()
600
693
 
@@ -605,7 +698,7 @@ export namespace FileSystem {
605
698
  compiler: () => MixedPromise<FileContent | null>,
606
699
  compress = true,
607
700
  ): Promise<Bun.BunFile | null> {
608
- const rawPath = resolve(cacheDir, cacheName)
701
+ const rawPath = resolve(cacheDir, versionedName(cacheName, sourceMtime))
609
702
 
610
703
  const pending = inFlightBuilds.get(rawPath)
611
704
  if (pending) return pending
@@ -637,11 +730,17 @@ export namespace FileSystem {
637
730
  const compressible = compress && isCompressible(ext)
638
731
 
639
732
  const rawFile = Bun.file(rawPath)
733
+ // Once written, superseded versions go, after a delay: see
734
+ // `dropOlderBuilds` and `sweepDelayMs`.
735
+ const built = <T>(file: T): T => {
736
+ if (sourceMtime) scheduleSweep(cacheDir, cacheName, sourceMtime)
737
+ return file
738
+ }
640
739
 
641
740
  if (compressible) {
642
741
  for (const { ext: compExt } of COMPRESSION_MAP) {
643
742
  const compFile = Bun.file(`${rawPath}${compExt}`)
644
- if (validCache(compFile, sourceMtime)) return compFile
743
+ if (exists(compFile)) return compFile
645
744
  }
646
745
 
647
746
  const content = await compiler()
@@ -670,19 +769,19 @@ export namespace FileSystem {
670
769
  }),
671
770
  ])
672
771
 
673
- return Bun.file(rawPath + COMPRESSION_MAP[0].ext)
772
+ return built(Bun.file(rawPath + COMPRESSION_MAP[0].ext))
674
773
  }
675
774
 
676
775
  // The mirror of the compressed branch's check above. Without it a
677
776
  // non-compressible entry (a .woff2 from the gstatic proxy, say) was
678
777
  // rebuilt (for fonts, re-fetched upstream) on every single request.
679
- if (validCache(rawFile, sourceMtime)) return rawFile
778
+ if (exists(rawFile)) return rawFile
680
779
 
681
780
  const content = await compiler()
682
781
  if (content == null) return null
683
782
  await mkdir(cacheDir)
684
783
  await rawFile.write(content)
685
- return rawFile
784
+ return built(rawFile)
686
785
  }
687
786
 
688
787
  /**