@bakery-framework/core 2.2.1 → 2.3.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 +1 -1
- package/src/cache/index.ts +16 -0
- package/src/compiler/dev-service.ts +47 -3
- package/src/handlers/core/$dynamic.ts +4 -3
- package/src/session-signing.ts +90 -0
- package/src/session.ts +14 -2
- package/src/utils/fs.ts +120 -21
package/package.json
CHANGED
package/src/cache/index.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { Bakery } from '../core/bakery'
|
|
2
|
+
import { HandlerMap } from '../handlers/core/$registry'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The dev watcher's route reset, run on every change to a route file: each
|
|
6
|
+
* handler's own caches, and `HandlerMap.routeCache` with them.
|
|
7
|
+
*
|
|
8
|
+
* The route cache is what a created file needs cleared. It remembers which
|
|
9
|
+
* handler won a path, and a hit re-asks only that handler, so a winner that
|
|
10
|
+
* still says yes keeps the path after a file that outranks it appears.
|
|
11
|
+
* `StaticHandler` says yes to every path. Measured in a dev app: with a root
|
|
12
|
+
* catch-all deleted and `/` requested (404, as livereload does on the delete),
|
|
13
|
+
* `/` still answered 404 three seconds after `index.vue` was written, until
|
|
14
|
+
* the dev worker restarted. And `/about` kept serving `about.html` after an
|
|
15
|
+
* `about.vue` (VueHandler, 58, above HTMLHandler, 55) was written beside it.
|
|
16
|
+
*/
|
|
2
17
|
export function initRoutes() {
|
|
18
|
+
HandlerMap.routeCache.clear()
|
|
3
19
|
for (const [, handlers] of Object.entries(Bakery.handlers)) {
|
|
4
20
|
for (const HandlerClass of handlers.list()) {
|
|
5
21
|
HandlerClass.initRoutes()
|
|
@@ -456,9 +456,8 @@ const watchIgnores = Glob.strings(
|
|
|
456
456
|
// restart was Bun's module registry caching the imported page module, and
|
|
457
457
|
// TSXHandler now busts that per request in dev exactly as ApiHandler always
|
|
458
458
|
// has. Pages take the cheap path via `tsScriptGlob` above. The one thing the
|
|
459
|
-
// restart still bought
|
|
460
|
-
//
|
|
461
|
-
// server.config.ts or restart when editing a shared Layout.tsx.
|
|
459
|
+
// restart still bought, flushing the *components* a page imports,
|
|
460
|
+
// `isLoadedModule` now does for exactly the files that need it.
|
|
462
461
|
const prioFilesGlob = Glob.strings('server.config.ts')
|
|
463
462
|
|
|
464
463
|
/**
|
|
@@ -563,6 +562,45 @@ export function isCreatedRouteModule(
|
|
|
563
562
|
return MODULE_ROUTE_EXT.test(filePath)
|
|
564
563
|
}
|
|
565
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Whether `filePath` is a module this process imported through a plain
|
|
567
|
+
* specifier, so Bun's registry keeps serving the copy it already has and an
|
|
568
|
+
* edit cannot reach the running server without a restart.
|
|
569
|
+
*
|
|
570
|
+
* Routes are imported cache-busted (`bustInDev`'s `?v=<mtime>`), and the
|
|
571
|
+
* registry keys those with their query, so an edited route is not one: the
|
|
572
|
+
* next request imports it under a new key. Everything a route imports is
|
|
573
|
+
* keyed bare, and that is what used to go stale: a component a page imports
|
|
574
|
+
* (a shared `Layout.tsx`), and a helper an API route imports from outside the
|
|
575
|
+
* api directory, such as a top-level `server/` folder of permission checks.
|
|
576
|
+
* Measured in a dev app: an edit to `server/auth.ts` did not apply until a
|
|
577
|
+
* file under `src/api` was touched.
|
|
578
|
+
*
|
|
579
|
+
* `require.cache` is where Bun lists them, ES modules included (1.4.2: a
|
|
580
|
+
* static import keyed by path, `a.ts?v=1` keyed with its query). A Bun that
|
|
581
|
+
* stopped listing them would turn this back into the old behavior rather than
|
|
582
|
+
* break anything, and `dev-service.test.ts` asks the real registry, so it
|
|
583
|
+
* would say so.
|
|
584
|
+
*/
|
|
585
|
+
export function isLoadedModule(
|
|
586
|
+
filePath: string,
|
|
587
|
+
loaded: Iterable<string> = Object.keys(require.cache),
|
|
588
|
+
cwd: string = fs.cwd,
|
|
589
|
+
): boolean {
|
|
590
|
+
const target = modulePathKey(resolve(cwd, filePath))
|
|
591
|
+
for (const key of loaded) {
|
|
592
|
+
if (!key.includes('?') && modulePathKey(key) === target) return true
|
|
593
|
+
}
|
|
594
|
+
return false
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** One spelling of a path for comparison: forward slashes, and case-folded
|
|
598
|
+
* on Windows, where `Auth.ts` and `auth.ts` are one file. */
|
|
599
|
+
function modulePathKey(path: string): string {
|
|
600
|
+
const slashed = path.replaceAll('\\', '/')
|
|
601
|
+
return process.platform === 'win32' ? slashed.toLowerCase() : slashed
|
|
602
|
+
}
|
|
603
|
+
|
|
566
604
|
async function processFileEvent(
|
|
567
605
|
filePath: string,
|
|
568
606
|
server: any,
|
|
@@ -588,6 +626,12 @@ async function processFileEvent(
|
|
|
588
626
|
return process.exit(42)
|
|
589
627
|
}
|
|
590
628
|
|
|
629
|
+
// A module the server holds a plain copy of: see `isLoadedModule`.
|
|
630
|
+
if (isLoadedModule(filePath)) {
|
|
631
|
+
serveLog.BACKEND_CHANGE({ file: filePath })
|
|
632
|
+
return process.exit(42)
|
|
633
|
+
}
|
|
634
|
+
|
|
591
635
|
if (tsScriptGlob.match(filePath)) {
|
|
592
636
|
initRoutes()
|
|
593
637
|
return notifySockets(server, filePath)
|
|
@@ -35,9 +35,10 @@ const dynamicCaches = new Map<any, HandlerCache<RegExp, Route.Info>>()
|
|
|
35
35
|
* a bare `PROD` gate flipped mid-suite and broke the reload tests in files
|
|
36
36
|
* loaded after it, while passing in isolation.
|
|
37
37
|
*
|
|
38
|
-
* Only the route module itself is busted: components it imports
|
|
39
|
-
* `Layout.tsx
|
|
40
|
-
* `
|
|
38
|
+
* Only the route module itself is busted: components and helpers it imports
|
|
39
|
+
* (a shared `Layout.tsx`) stay in Bun's registry, and the dev watcher restarts
|
|
40
|
+
* the worker when one of them changes (`isLoadedModule` in
|
|
41
|
+
* compiler/dev-service.ts).
|
|
41
42
|
*
|
|
42
43
|
* One implementation on purpose. `ApiHandler` and `TSXHandler`/`TSXErrorHandler`
|
|
43
44
|
* both need it, and they carried a byte-identical copy each (including this
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 - `
|
|
292
|
-
*
|
|
293
|
-
*
|
|
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
|
-
*
|
|
567
|
-
*
|
|
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
|
-
|
|
570
|
-
|
|
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
|
-
*
|
|
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
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
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 (
|
|
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 (
|
|
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
|
/**
|