@bakery-framework/core 1.0.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.
Files changed (91) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +89 -0
  3. package/package.json +69 -0
  4. package/src/cache/index.ts +8 -0
  5. package/src/cache/lru.ts +41 -0
  6. package/src/cache/shared-db.ts +51 -0
  7. package/src/cache/string.ts +150 -0
  8. package/src/cache/tiered.ts +493 -0
  9. package/src/client/globals.d.ts +74 -0
  10. package/src/client/livereload.ts +437 -0
  11. package/src/client/utils.ts +315 -0
  12. package/src/compiler/compiler.ts +263 -0
  13. package/src/compiler/dev-service.ts +660 -0
  14. package/src/compiler/index.ts +2 -0
  15. package/src/compiler/prompt-tracker.ts +36 -0
  16. package/src/compiler/tsconfig-sync.ts +71 -0
  17. package/src/core/bakery.ts +96 -0
  18. package/src/core/cache-version.ts +119 -0
  19. package/src/core/config.ts +296 -0
  20. package/src/core/context.ts +121 -0
  21. package/src/core/index.ts +61 -0
  22. package/src/core/init.ts +90 -0
  23. package/src/core/jsx.ts +152 -0
  24. package/src/core/paths.ts +24 -0
  25. package/src/core/plugins.ts +120 -0
  26. package/src/core/port.ts +73 -0
  27. package/src/global.d.ts +374 -0
  28. package/src/handlers/assets/google-font.ts +225 -0
  29. package/src/handlers/assets/image.ts +136 -0
  30. package/src/handlers/assets/nm.ts +73 -0
  31. package/src/handlers/assets/public.ts +17 -0
  32. package/src/handlers/assets/static.ts +86 -0
  33. package/src/handlers/assets/ts.ts +61 -0
  34. package/src/handlers/assets/tsx.ts +106 -0
  35. package/src/handlers/assets/virtual-asset.ts +104 -0
  36. package/src/handlers/core/$base.ts +256 -0
  37. package/src/handlers/core/$dynamic.ts +285 -0
  38. package/src/handlers/core/$error.ts +301 -0
  39. package/src/handlers/core/$middleware.ts +71 -0
  40. package/src/handlers/core/$mounts.ts +84 -0
  41. package/src/handlers/core/$registry.ts +153 -0
  42. package/src/handlers/core/$routing.ts +205 -0
  43. package/src/handlers/core/$static.ts +100 -0
  44. package/src/handlers/core/$websocket.ts +52 -0
  45. package/src/handlers/index.ts +21 -0
  46. package/src/handlers/routes/api.ts +95 -0
  47. package/src/handlers/routes/html.ts +95 -0
  48. package/src/handlers/routes/livereload.ts +54 -0
  49. package/src/handlers/routes/proxy.ts +74 -0
  50. package/src/logger/clients.ts +12 -0
  51. package/src/logger/index.ts +3 -0
  52. package/src/logger/logger.ts +375 -0
  53. package/src/logger/serve-log.ts +206 -0
  54. package/src/plugins/index.ts +15 -0
  55. package/src/plugins/routes.ts +110 -0
  56. package/src/plugins/types.ts +19 -0
  57. package/src/router.ts +351 -0
  58. package/src/session.ts +556 -0
  59. package/src/shared.d.ts +63 -0
  60. package/src/startup.ts +154 -0
  61. package/src/types.d.ts +111 -0
  62. package/src/utils/common/case.ts +11 -0
  63. package/src/utils/common/index.ts +5 -0
  64. package/src/utils/common/json.ts +35 -0
  65. package/src/utils/common/match.ts +6 -0
  66. package/src/utils/common/misc.ts +53 -0
  67. package/src/utils/common/try.ts +6 -0
  68. package/src/utils/constants.ts +153 -0
  69. package/src/utils/fs.ts +621 -0
  70. package/src/utils/http/body.ts +65 -0
  71. package/src/utils/http/csrf.ts +111 -0
  72. package/src/utils/http/dom.ts +238 -0
  73. package/src/utils/http/escape.ts +8 -0
  74. package/src/utils/http/etag.ts +318 -0
  75. package/src/utils/http/html.ts +525 -0
  76. package/src/utils/http/index.ts +8 -0
  77. package/src/utils/http/ip.ts +32 -0
  78. package/src/utils/http/response.ts +129 -0
  79. package/src/utils/index.ts +4 -0
  80. package/src/utils/isomorphic/case.ts +52 -0
  81. package/src/utils/isomorphic/escape.ts +43 -0
  82. package/src/utils/isomorphic/index.ts +15 -0
  83. package/src/utils/isomorphic/is.ts +36 -0
  84. package/src/utils/isomorphic/match.ts +50 -0
  85. package/src/utils/isomorphic/math.ts +11 -0
  86. package/src/utils/isomorphic/misc.ts +22 -0
  87. package/src/utils/isomorphic/stringify.ts +42 -0
  88. package/src/utils/isomorphic/try.ts +94 -0
  89. package/src/utils/jsonc.ts +10 -0
  90. package/src/utils/shared-pool.ts +193 -0
  91. package/tsconfig.app.json +34 -0
@@ -0,0 +1,621 @@
1
+ import {
2
+ readFileSync as fsRfs,
3
+ existsSync as nodeExistsSync,
4
+ statSync as nodeStatSync,
5
+ } from 'node:fs'
6
+ import { mkdir as fsMkdir, rm as fsRm } from 'node:fs/promises'
7
+ import {
8
+ dirname as nodeDirname,
9
+ relative as nodeRelative,
10
+ resolve,
11
+ } from 'node:path'
12
+ import { parse as parsedPath } from 'node:path/posix'
13
+ import { promisify } from 'node:util'
14
+ import { gzip as zlibGzip } from 'node:zlib'
15
+ import { LRUCache } from '../cache/lru'
16
+ // A cycle on paper (`core/context` imports this file for `getAppVersion`),
17
+ // harmless in practice: neither module touches the other's bindings during
18
+ // evaluation — `hostStore` is only read inside `isForbidden`, at request time.
19
+ import { hostStore } from '../core/context'
20
+ import type { MixedPromise } from '../types'
21
+ import { is, Try } from './common'
22
+
23
+ type CompressInput = string | Uint8Array | ArrayBuffer
24
+
25
+ const gzipAsync = promisify(zlibGzip) as (
26
+ data: CompressInput,
27
+ ) => Promise<Uint8Array>
28
+
29
+ /**
30
+ * Payloads at or above this size compress off the request thread via
31
+ * `compressAsync`; below it, sync stays. The thread-pool round-trip has a
32
+ * fixed dispatch cost (~85us zstd, ~450us gzip on this machine) that a small
33
+ * payload's stall never earns back. Median per-call cost, Bun 1.3.14,
34
+ * measured 2026-08-06 (interleaved same-run A/B):
35
+ *
36
+ * size | zstd sync | zstd async | gzip sync | gzip async
37
+ * 2KB | 10.5us | 94.4us | 23.8us | 285.9us
38
+ * 16KB | 41.7us | 140.5us | 126.2us | 434.1us
39
+ * 32KB | 98.2us | 189.2us | ~300us | ~750us
40
+ * 64KB | 163.9us | 276.6us | 623.1us | 989.9us
41
+ * 135KB | 1022.5us | 1204.9us | 1382.2us | 1761.7us
42
+ * 1MB | 5804us | 6480us | 14469us | 17114us
43
+ * (32KB gzip: median of 5 x 200-iteration runs; single runs were noisy)
44
+ *
45
+ * At 32KB the sync cost crosses the dispatch overhead for zstd — the
46
+ * first-preference codec. gzip's raw-latency break-even sits nearer 64KB,
47
+ * but the sync figure is a *stall* shared by every concurrent request while
48
+ * the async figure is latency private to one caller, so the tie goes to
49
+ * offloading rather than a second per-codec constant.
50
+ */
51
+ export const ASYNC_COMPRESSION_MIN = 32 * 1024
52
+
53
+ /**
54
+ * `compress` is the sync variant (blocks the event loop for the durations
55
+ * above); `compressAsync` runs on a worker pool and only parks the caller.
56
+ * zstd: `Bun.zstdCompress` (native async, Bun >= 1.1.21). gzip: Bun 1.3.14
57
+ * has no async `Bun.gzip`, so `node:zlib`'s callback `gzip` — which runs on
58
+ * the libuv pool — is promisified instead. Both async variants were verified
59
+ * byte-identical to their sync counterparts on this Bun, so either path
60
+ * yields the same cached artifacts and the same response bodies.
61
+ */
62
+ export const COMPRESSION_MAP = [
63
+ {
64
+ encoding: 'zstd',
65
+ ext: '.zst',
66
+ compress: Bun.zstdCompressSync,
67
+ compressAsync: (data: CompressInput) => Bun.zstdCompress(data),
68
+ },
69
+ {
70
+ encoding: 'gzip',
71
+ ext: '.gz',
72
+ compress: Bun.gzipSync,
73
+ compressAsync: gzipAsync,
74
+ },
75
+ ]
76
+
77
+ type MixedArray<T> = T | T[]
78
+
79
+ function toArray<T>(val?: MixedArray<T>): T[] {
80
+ if (val === undefined) return []
81
+ return Array.isArray(val) ? val : [val]
82
+ }
83
+
84
+ /**
85
+ * Memoised: `resolve` measures ~16-20us on Bun/Windows (see `isForbidden`) and
86
+ * a CPU profile of the request path put `safeResolve` + `path.resolve` at over
87
+ * 40% of per-request self time. Caching is safe because the function is purely
88
+ * lexical — `path.resolve` reads no filesystem state, only `process.cwd()`.
89
+ * Relative arguments make entries cwd-dependent, and while nothing in the
90
+ * runtime calls `chdir`, tests do (config.broken.test.ts boots from temp
91
+ * fixture dirs) — so the cache is invalidated wholesale when cwd moves. The
92
+ * check is one `process.cwd()` read per call, measured at 12ns in Bun (it is
93
+ * cached natively), against the ~16us a resolve costs. The cache is an LRU,
94
+ * not a Map, because arguments derive from request paths (convention 6);
95
+ * constant keys like the cache-dir getters stay hot forever.
96
+ */
97
+ const resolveCache = new LRUCache<string, FileSystem.AbsolutePath>(4096)
98
+ let resolveCacheCwd = process.cwd()
99
+
100
+ function safeResolve(...paths: string[]): FileSystem.AbsolutePath {
101
+ const cwd = process.cwd()
102
+ if (cwd !== resolveCacheCwd) {
103
+ resolveCache.clear()
104
+ resolveCacheCwd = cwd
105
+ }
106
+ // NUL is invalid in a path on every platform, so joined keys cannot collide
107
+ // across different argument splits ('a b'+'c' vs 'a'+'b c').
108
+ const key = paths.length === 1 ? paths[0] : paths.join('\u0000')
109
+ const cached = resolveCache.get(key)
110
+ if (cached !== undefined) return cached
111
+ const resolved = resolve(...paths).replace(
112
+ /\\/g,
113
+ '/',
114
+ ) as FileSystem.AbsolutePath
115
+ resolveCache.set(key, resolved)
116
+ return resolved
117
+ }
118
+ // prettier-ignore
119
+ const compressable = new Set([
120
+ 'html',
121
+ 'htm',
122
+ 'xml',
123
+ 'css',
124
+ 'js',
125
+ 'jsx',
126
+ 'ts',
127
+ 'tsx',
128
+ 'json',
129
+ 'ttf',
130
+ 'otf',
131
+ 'txt',
132
+ 'text',
133
+ 'svg',
134
+ 'md',
135
+ 'wasm',
136
+ 'map',
137
+ 'csv',
138
+ 'yml',
139
+ 'yaml',
140
+ 'mdx',
141
+ ])
142
+
143
+ export namespace Glob {
144
+ export type Pattern = string | Bun.Glob
145
+ export type Patterns = Pattern | Pattern[]
146
+ export type PatternInfo = {
147
+ folder: string
148
+ ext?: MixedArray<string>
149
+ exclude?: Glob.Patterns
150
+ }
151
+
152
+ export type ArrayOfGlobs = {
153
+ globs: Bun.Glob[]
154
+ } & Bun.Glob
155
+
156
+ export function pattern(patternInfo: PatternInfo): Bun.Glob {
157
+ let { folder, ext, exclude } = patternInfo
158
+
159
+ ext = toArray(ext)
160
+ folder = folder ? `${safeResolve(folder)}/` : ''
161
+ ext = ext.length > 0 ? `**/*.{${ext.join(',')}}` : '**/*'
162
+ const excludeGlob = fromArray(toArray(exclude))
163
+ const globPattern = from(folder + ext)
164
+
165
+ return {
166
+ async *scan(options) {
167
+ for await (const entry of globPattern.scan(options)) {
168
+ if (excludeGlob.match(entry)) continue
169
+ yield entry
170
+ }
171
+ },
172
+
173
+ match(path: string) {
174
+ return globPattern.match(path) && !excludeGlob.match(path)
175
+ },
176
+
177
+ *scanSync(options) {
178
+ const entries = globPattern.scanSync(options)
179
+ for (const entry of entries) {
180
+ if (excludeGlob.match(entry)) continue
181
+ yield entry
182
+ }
183
+ },
184
+ }
185
+ }
186
+
187
+ export function from(pattern: Pattern): Bun.Glob {
188
+ if (pattern instanceof Bun.Glob) return pattern
189
+ return new Bun.Glob(pattern)
190
+ }
191
+
192
+ export function match(globs: Patterns, path: string): boolean {
193
+ return fromArray(toArray(globs)).match(safeResolve(path))
194
+ }
195
+
196
+ export function strings(...patterns: string[]): Bun.Glob {
197
+ // No empty-pattern fallback: the braces make `pattern` at least `"{}"`, so
198
+ // the length check it used to carry could never be false.
199
+ return new Bun.Glob(`{${patterns.join(',')}}`)
200
+ }
201
+
202
+ export function fromArray(globs: Pattern[]): ArrayOfGlobs {
203
+ const pattern: Bun.Glob[] = []
204
+ const strings: string[] = []
205
+
206
+ for (const p of globs) {
207
+ if (p instanceof Bun.Glob) pattern.push(p)
208
+ else if (is.string(p)) strings.push(p)
209
+ }
210
+
211
+ if (strings.length) pattern.push(Glob.strings(...strings))
212
+
213
+ return {
214
+ globs: pattern,
215
+ match(path: string) {
216
+ for (const g of this.globs) if (g.match(path)) return true
217
+ return false
218
+ },
219
+ async *scan(options) {
220
+ for (const g of this.globs) yield* g.scan(options)
221
+ },
222
+ *scanSync(options) {
223
+ for (const g of this.globs) yield* g.scanSync(options)
224
+ },
225
+ }
226
+ }
227
+
228
+ export function patterns(...patterns: Pattern[]): ArrayOfGlobs {
229
+ return fromArray(patterns)
230
+ }
231
+
232
+ export function fromExt(ext: MixedArray<string>, root = ''): Bun.Glob {
233
+ ext = toArray(ext)
234
+ root = root ? `${safeResolve(root)}/` : ''
235
+ if (ext.length === 0) return new Bun.Glob('')
236
+ return new Bun.Glob(`${root}**/*.{${ext.join(',')}}`)
237
+ }
238
+ }
239
+
240
+ export namespace FileSystem {
241
+ // Naming, not constraint — `string & {}` is `string`. These document what a
242
+ // parameter means at its declaration, which earns their keep. Four more
243
+ // (`RequestPath`, `DirectoryPath`, `FileName`, `FileExtension`) documented
244
+ // nothing, because nothing ever referenced them.
245
+ export type AbsolutePath = string & {}
246
+ export type RelativePath = string & {}
247
+
248
+ export async function* glob(pattern: Glob.Pattern, exclude?: Glob.Patterns) {
249
+ const glob = Glob.from(pattern)
250
+ const excludeGlob = Glob.fromArray(toArray(exclude))
251
+
252
+ for await (const entry of glob.scan()) {
253
+ if (excludeGlob.match(entry)) continue
254
+ yield entry as AbsolutePath
255
+ }
256
+ }
257
+
258
+ export const resolve = safeResolve
259
+ export const cwd = safeResolve(process.cwd())
260
+ export const parse = parsedPath
261
+ export function dirname(path: string) {
262
+ return nodeDirname(path).replace(/\\/g, '/')
263
+ }
264
+ export function relative(from: string, to: string) {
265
+ return nodeRelative(from, to).replace(/\\/g, '/')
266
+ }
267
+
268
+ /**
269
+ * Reads as UTF-8 text. Without the encoding this returns a Buffer, which
270
+ * every caller then hands to `JSON.parse` — legal at runtime because it
271
+ * coerces, but a type error at each call site, papered over in one of them
272
+ * with `as any`.
273
+ */
274
+ export function readFileSync(path: string): string | null {
275
+ if (!exists(path)) return null
276
+ return fsRfs(path, 'utf8')
277
+ }
278
+
279
+ export async function isDir(path: string) {
280
+ if (!exists(path)) return false
281
+ return (await Try(() => Bun.file(path).stat()))?.isDirectory() || false
282
+ }
283
+
284
+ export function exists(path: string | Bun.BunFile): boolean {
285
+ path = typeof path === 'string' ? path : path.name || ''
286
+ const file = Bun.file(path)
287
+ const lastMod = file.lastModified
288
+ return Boolean((lastMod && lastMod < Date.now()) || file.size > 0)
289
+ }
290
+
291
+ export function existsSync(path: string): boolean {
292
+ return nodeExistsSync(path)
293
+ }
294
+
295
+ /**
296
+ * True only for an existing regular *file* — a directory answers false.
297
+ * Sync because `findDynamicRoute` (a sync hot-path scan) is a caller; one
298
+ * stat, no BunFile allocation.
299
+ */
300
+ export function isFileSync(path: string): boolean {
301
+ return nodeStatSync(path, { throwIfNoEntry: false })?.isFile() ?? false
302
+ }
303
+
304
+ /**
305
+ * Parent directory of an **already-normalised** absolute path — the output
306
+ * shape `safeResolve` produces: forward slashes, no `.`/`..` segments, no
307
+ * trailing slash except at a root.
308
+ *
309
+ * Only correct for that input shape, which is why it is not exported.
310
+ * `isForbidden` is the sole caller and it normalises before the first call.
311
+ */
312
+ function parentOf(path: string): string {
313
+ const idx = path.lastIndexOf('/')
314
+ if (idx < 0) return path
315
+ // POSIX root: the parent of `/a` is `/`, not the empty string.
316
+ if (idx === 0) return '/'
317
+ // Windows drive root: the parent of `C:/a` is `C:/`, not `C:`.
318
+ //
319
+ // Both of these branches are defensive rather than covered. `isForbidden`
320
+ // stops as soon as the walk leaves `root`, and every root it is called
321
+ // with is a real serve directory well below a filesystem root, so the walk
322
+ // never reaches one — dropping them both leaves the equivalence test in
323
+ // `fs.test.ts` green. They are kept so `parentOf` is correct as written
324
+ // rather than correct only for its current caller.
325
+ if (path.charCodeAt(idx - 1) === 58 /* ':' */) return path.slice(0, idx + 1)
326
+ return path.slice(0, idx)
327
+ }
328
+
329
+ /**
330
+ * True when `pathToCheck`, or any directory between it and `root`, holds a
331
+ * `.forbidden` marker.
332
+ *
333
+ * This is a security guard, and it runs several times per request — from
334
+ * `router.ts` before anything else, then again from `getStatic`, `getRoute`
335
+ * (once per level of a nested route) and the dynamic-route cache. Each call
336
+ * walks every directory between the target and the serve root, so the
337
+ * per-level cost is multiplied twice over.
338
+ *
339
+ * The walk used to call `safeResolve` twice per level, once to build
340
+ * `<dir>/.forbidden` and once to step to the parent. Both are `node:path`
341
+ * `resolve`, which on Bun/Windows measures **~16-20us** — roughly 100x
342
+ * `path.dirname` (~0.2us), independent of the filesystem and of where the
343
+ * cwd lives, and not explained by native-call overhead (`Bun.nanoseconds()`
344
+ * is 0.045us). A depth-4 walk therefore paid ten `resolve` calls.
345
+ *
346
+ * Since `curr` is normalised once on entry, every value it takes afterwards
347
+ * is already in `resolve`'s own output shape, so both operations are exact
348
+ * string edits. That takes a depth-4 walk from ten `resolve` calls to two
349
+ * and leaves one `existsSync` per level as the only remaining syscall.
350
+ * `fs.test.ts` pins the two implementations as equivalent.
351
+ *
352
+ * The entry normalisation stays: `pathToCheck` reaches here straight from a
353
+ * URL pathname in `router.ts`, before any containment check, and collapsing
354
+ * its dot segments is what makes the rest of the walk safe.
355
+ *
356
+ * The remaining `existsSync` per level is deliberately **not** memoised
357
+ * *across requests*, which is the opposite call to the one `getMimeType`
358
+ * below makes. The difference is not the key — both are ultimately derived
359
+ * from a request path and both would need an `LRUCache` to stay bounded
360
+ * under convention 6 — it is what a stale entry costs. A stale MIME type
361
+ * serves the wrong `Content-Type`; a stale *negative* here serves a file an
362
+ * operator has marked forbidden, which is the exact outcome the marker
363
+ * exists to prevent.
364
+ *
365
+ * And there is nothing to invalidate against. Production has no watcher, and
366
+ * the `SIGHUP` handler installed in `core/init.ts` is a deliberate no-op, so
367
+ * a cached "not forbidden" would persist for the life of the process; a TTL
368
+ * would only bound that window, not close it, and an operator dropping a
369
+ * `.forbidden` file to pull content offline has no way to learn how long the
370
+ * window is. The LRU also fails exactly when it would matter — a client
371
+ * requesting varied paths evicts the useful upper-level entries, so the
372
+ * hit rate collapses under the load that motivated caching in the first
373
+ * place. `fs.test.ts` has a regression test that fails if this is added.
374
+ *
375
+ * What *is* deduplicated is the probe within one request. This guard runs
376
+ * 4-6x per request over overlapping subtrees (router, `getStatic`, every
377
+ * level of `getRoute`, the dynamic-route cache), each walk re-stating the
378
+ * same directories at ~50us per miss on Bun/Windows. A marker dropped
379
+ * *mid-request* has no meaningful semantics — the operator's guarantee is
380
+ * "the next request sees it" — so the raw probe results live on the current
381
+ * `hostStore` value (`HostContext.forbiddenProbes`), which worker.ts creates
382
+ * per request and never reuses. No store (tests, direct calls, startup)
383
+ * means no caching, bit-for-bit today's behavior.
384
+ *
385
+ * The memo stores the **per-level probe**, never the walk's verdict. The
386
+ * verdict depends on `root`, and callers walk the same directories against
387
+ * different roots (router uses the serve root, `getStatic` each candidate
388
+ * root, `getRoute` its own). A verdict cache would also poison itself: a
389
+ * walk that finds a marker at an upper level has recorded honest `false`
390
+ * probes for every level below it, and treating those as "clean from here
391
+ * up" would answer `false` for a subtree that is in fact forbidden.
392
+ */
393
+ export function isForbidden(pathToCheck: string, root: string): boolean {
394
+ if (!pathToCheck || !root) return false
395
+ let curr = safeResolve(pathToCheck)
396
+ const normalizedRoot = safeResolve(root)
397
+
398
+ const store = hostStore.getStore()
399
+ const seen = store ? (store.forbiddenProbes ??= new Map()) : null
400
+
401
+ while (
402
+ curr.startsWith(normalizedRoot) &&
403
+ curr.length >= normalizedRoot.length
404
+ ) {
405
+ // `curr` only ends in `/` when it is a root, where appending directly
406
+ // would produce `C://.forbidden`.
407
+ const forbiddenFile = curr.endsWith('/')
408
+ ? `${curr}.forbidden`
409
+ : `${curr}/.forbidden`
410
+
411
+ let marked = seen?.get(forbiddenFile)
412
+ if (marked === undefined) {
413
+ marked = probeForbidden(forbiddenFile)
414
+ seen?.set(forbiddenFile, marked)
415
+ }
416
+ if (marked) {
417
+ return true
418
+ }
419
+ const parent = parentOf(curr)
420
+ if (parent === curr) break
421
+ curr = parent
422
+ }
423
+ return false
424
+ }
425
+
426
+ let probeForbidden: (path: string) => boolean = nodeExistsSync
427
+
428
+ /**
429
+ * Test seam (convention 9): lets `fs.test.ts` count the syscalls the
430
+ * per-request dedup above is supposed to be saving, without module-mocking
431
+ * `node:fs` — which is process-global and never unwinds.
432
+ */
433
+ export function __setForbiddenProbe(fn: (path: string) => boolean) {
434
+ probeForbidden = fn
435
+ }
436
+
437
+ export function __resetForbiddenProbe() {
438
+ probeForbidden = nodeExistsSync
439
+ }
440
+
441
+ export async function mkdir(path: string) {
442
+ if (!exists(path)) await fsMkdir(path, { recursive: true })
443
+ }
444
+
445
+ export async function rm(
446
+ path: string,
447
+ options?: { recursive?: boolean; force?: boolean },
448
+ ) {
449
+ await fsRm(path, options).catch(() => {})
450
+ }
451
+
452
+ export async function* readdir(info?: Glob.PatternInfo, files = false) {
453
+ info ||= { folder: cwd }
454
+ const glob = Glob.pattern(info)
455
+ for await (let entry of glob.scan({ onlyFiles: files, absolute: true })) {
456
+ entry = FileSystem.resolve(entry)
457
+ const file = Bun.file(entry)
458
+ yield {
459
+ file,
460
+ stat: await file.stat(),
461
+ path: resolve(entry) as AbsolutePath,
462
+ }
463
+ }
464
+ }
465
+
466
+ export async function* files(info?: Glob.PatternInfo) {
467
+ for await (const entry of readdir(info)) {
468
+ if (entry.stat.isFile()) yield entry
469
+ }
470
+ }
471
+
472
+ export function isCompressible(ext: string): boolean {
473
+ const cleanExt = ext.toLowerCase().replace(/^\./, '')
474
+ const mimeType = getMimeType(cleanExt)
475
+ if (mimeType.startsWith('text/') || mimeType === 'application/json') {
476
+ return true
477
+ }
478
+
479
+ return compressable.has(cleanExt)
480
+ }
481
+
482
+ function validCache(file: Bun.BunFile, sourceMtime: number | null): boolean {
483
+ return exists(file) && (!sourceMtime || sourceMtime <= file.lastModified)
484
+ }
485
+
486
+ type FileContent = string | Uint8Array<ArrayBuffer> | ArrayBuffer
487
+
488
+ /**
489
+ * Builds currently running, keyed on the resolved raw path.
490
+ *
491
+ * `validCache` is a pure filesystem test with no record of work in progress,
492
+ * so N concurrent first-hits on a cold cache each saw "not cached", each ran
493
+ * `compiler()`, and each wrote the same three paths. Measured: 8 concurrent
494
+ * callers produced 8 compiler invocations. Every `.ts`/`.tsx`/`.vue` asset
495
+ * goes through here, so a traffic burst on a cold cache — or a cluster whose
496
+ * workers share one cache directory — paid the full transpile *plus* gzip
497
+ * *and* zstd once per concurrent request.
498
+ *
499
+ * Same memoised-promise idiom as `initDB`, `setupPlugins` and
500
+ * `getTranspiler`, keyed rather than singular, and deleted in a `finally` so
501
+ * a failed build is retried by the next caller rather than remembered. It is
502
+ * bounded by construction (convention 6): an entry exists only while its own
503
+ * build is running, never past it.
504
+ *
505
+ * The key is the path alone, not the path plus `sourceMtime`. A caller that
506
+ * joins mid-build can therefore receive a build started for a marginally
507
+ * older mtime — a window only as wide as one compile, which the next request
508
+ * re-validates and rebuilds. Including the mtime would close that window by
509
+ * letting two builds of the same file race each other onto the same three
510
+ * output paths, which is the worse trade.
511
+ */
512
+ const inFlightBuilds = new Map<string, Promise<Bun.BunFile | null>>()
513
+
514
+ export function getOrCreateCachedFile(
515
+ cacheDir: string,
516
+ cacheName: string,
517
+ sourceMtime: number | null,
518
+ compiler: () => MixedPromise<FileContent | null>,
519
+ compress = true,
520
+ ): Promise<Bun.BunFile | null> {
521
+ const rawPath = resolve(cacheDir, cacheName)
522
+
523
+ const pending = inFlightBuilds.get(rawPath)
524
+ if (pending) return pending
525
+
526
+ // No `await` between the lookup above and the insert below: that is the
527
+ // whole of the mutual exclusion, and adding one reopens the bug.
528
+ const task = buildCachedFile(
529
+ cacheDir,
530
+ cacheName,
531
+ rawPath,
532
+ sourceMtime,
533
+ compiler,
534
+ compress,
535
+ ).finally(() => inFlightBuilds.delete(rawPath))
536
+
537
+ inFlightBuilds.set(rawPath, task)
538
+ return task
539
+ }
540
+
541
+ async function buildCachedFile(
542
+ cacheDir: string,
543
+ cacheName: string,
544
+ rawPath: string,
545
+ sourceMtime: number | null,
546
+ compiler: () => MixedPromise<FileContent | null>,
547
+ compress: boolean,
548
+ ): Promise<Bun.BunFile | null> {
549
+ const ext = parse(cacheName).ext
550
+ const compressible = compress && isCompressible(ext)
551
+
552
+ const rawFile = Bun.file(rawPath)
553
+
554
+ if (compressible) {
555
+ for (const { ext: compExt } of COMPRESSION_MAP) {
556
+ const compFile = Bun.file(`${rawPath}${compExt}`)
557
+ if (validCache(compFile, sourceMtime)) return compFile
558
+ }
559
+
560
+ const content = await compiler()
561
+ if (content == null) return null
562
+
563
+ // Only on the build path: a warm hit was paying this stat on every
564
+ // request for nothing — the directory necessarily exists if the cached
565
+ // file it holds just validated.
566
+ await mkdir(cacheDir)
567
+
568
+ // Same cutoff as `ETag.sendText`: a cold-cache burst is exactly when
569
+ // the event loop is busiest, so large payloads compress on the pool;
570
+ // below the cutoff the sync stall is smaller than the dispatch cost.
571
+ const contentSize =
572
+ typeof content === 'string' ? content.length : content.byteLength
573
+
574
+ await Promise.all([
575
+ rawFile.write(content),
576
+ ...COMPRESSION_MAP.map(async ({ ext, compress, compressAsync }) => {
577
+ const compressedContent =
578
+ contentSize >= ASYNC_COMPRESSION_MIN
579
+ ? await compressAsync(content)
580
+ : (compress(content) as any)
581
+ const compressedFile = Bun.file(`${rawPath}${ext}`)
582
+ return compressedFile.write(compressedContent)
583
+ }),
584
+ ])
585
+
586
+ return Bun.file(rawPath + COMPRESSION_MAP[0].ext)
587
+ }
588
+
589
+ // The mirror of the compressed branch's check above. Without it a
590
+ // non-compressible entry (a .woff2 from the gstatic proxy, say) was
591
+ // rebuilt — for fonts, re-fetched upstream — on every single request.
592
+ if (validCache(rawFile, sourceMtime)) return rawFile
593
+
594
+ const content = await compiler()
595
+ if (content == null) return null
596
+ await mkdir(cacheDir)
597
+ await rawFile.write(content)
598
+ return rawFile
599
+ }
600
+
601
+ /**
602
+ * The extension→MIME mapping is static, but resolving it allocates a
603
+ * `Bun.BunFile` every call — ~1.4us, on the path of every static-file and
604
+ * compressed-asset response. Memoised behind an `LRUCache` rather than a
605
+ * plain Map because `ext` is ultimately derived from the request path, and
606
+ * an unbounded map keyed on client-supplied input is exactly what
607
+ * convention 6 forbids.
608
+ */
609
+ const mimeTypes = new LRUCache<string, string>(256)
610
+
611
+ export function getMimeType(ext: string): string {
612
+ const cached = mimeTypes.get(ext)
613
+ if (cached !== undefined) return cached
614
+
615
+ const type = Bun.file(`dummy.${ext}`).type || 'application/octet-stream'
616
+ mimeTypes.set(ext, type)
617
+ return type
618
+ }
619
+ }
620
+
621
+ export { FileSystem as fs }
@@ -0,0 +1,65 @@
1
+ import type { MapOf } from '../../types'
2
+
3
+ export async function processBody(req: Request): Promise<MapOf<any>> {
4
+ const getParsedBody = async (): Promise<MapOf<any>> => {
5
+ switch (req.method) {
6
+ case 'GET':
7
+ case 'HEAD': {
8
+ return getBodyFromURI(req)
9
+ }
10
+ default: {
11
+ return await getBodyFromReq(req)
12
+ }
13
+ }
14
+ }
15
+
16
+ const parsedBody = await getParsedBody().catch(() => ({}))
17
+ return parsedBody
18
+ }
19
+
20
+ function getBodyFromURI(req: Request): MapOf<any> {
21
+ const url = (req as any).__parsedUrl || new URL(req.url)
22
+ const searchParams = url.searchParams
23
+ return Object.fromEntries(searchParams.entries())
24
+ }
25
+
26
+ function isFormData(contentType: string): boolean {
27
+ return (
28
+ contentType.includes('application/x-www-form-urlencoded') ||
29
+ contentType.includes('multipart/form-data')
30
+ )
31
+ }
32
+
33
+ function isFileLike(contentType: string): boolean {
34
+ return (
35
+ contentType.includes('application/') ||
36
+ contentType.includes('audio/') ||
37
+ contentType.includes('video/') ||
38
+ contentType.includes('model/') ||
39
+ contentType.includes('image/')
40
+ )
41
+ }
42
+
43
+ async function getBodyFromReq(req: Request) {
44
+ const contentType = req.headers.get('content-type') || ''
45
+
46
+ if (contentType.includes('application/json')) {
47
+ return (await req.json()) as Promise<MapOf<any>>
48
+ }
49
+
50
+ if (isFormData(contentType)) {
51
+ const formData = await req.formData()
52
+ const data: MapOf<any> = {}
53
+
54
+ for (const [key, value] of formData.entries()) {
55
+ data[key] = key in data ? [data[key], value].flat() : value
56
+ }
57
+ return data
58
+ }
59
+
60
+ if (isFileLike(contentType)) {
61
+ return { file: await req.blob() }
62
+ }
63
+
64
+ return { data: await req.text() }
65
+ }