@bakery-framework/core 2.2.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -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
  /**