@bakery-framework/core 2.0.0-alpha.8 → 2.0.0-rc.1
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/README.md +8 -8
- package/package.json +2 -5
- package/src/cache/lru.ts +2 -2
- package/src/cache/shared-db.ts +81 -11
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +11 -5
- package/src/client/globals.d.ts +7 -7
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +11 -6
- package/src/compiler/compiler.ts +48 -44
- package/src/compiler/dev-service.ts +29 -30
- package/src/compiler/prompt-tracker.ts +2 -2
- package/src/compiler/tsconfig-sync.ts +130 -59
- package/src/core/bakery.ts +6 -3
- package/src/core/cache-version.ts +68 -18
- package/src/core/config.ts +16 -16
- package/src/core/context.ts +8 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +19 -7
- package/src/core/init.ts +38 -35
- package/src/core/jsx.ts +19 -4
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/core/port.ts +6 -6
- package/src/global.d.ts +36 -18
- package/src/handlers/assets/google-font.ts +6 -6
- package/src/handlers/assets/image.ts +36 -3
- package/src/handlers/assets/nm.ts +7 -7
- package/src/handlers/assets/static.ts +55 -12
- package/src/handlers/assets/ts.ts +3 -3
- package/src/handlers/assets/tsx.ts +1 -1
- package/src/handlers/assets/virtual-asset.ts +1 -1
- package/src/handlers/core/$base.ts +24 -8
- package/src/handlers/core/$dynamic.ts +66 -23
- package/src/handlers/core/$error.ts +29 -17
- package/src/handlers/core/$middleware.ts +6 -6
- package/src/handlers/core/$mounts.ts +2 -2
- package/src/handlers/core/$registry.ts +16 -6
- package/src/handlers/core/$routing.ts +18 -19
- package/src/handlers/core/$static.ts +11 -12
- package/src/handlers/routes/api.ts +5 -5
- package/src/handlers/routes/html.ts +2 -2
- package/src/handlers/routes/proxy.ts +1 -1
- package/src/logger/clients.ts +2 -2
- package/src/logger/logger.ts +4 -4
- package/src/logger/serve-log.ts +32 -18
- package/src/plugins/index.ts +1 -1
- package/src/plugins/routes.ts +7 -7
- package/src/plugins/types.ts +13 -11
- package/src/router.ts +12 -12
- package/src/session.ts +41 -23
- package/src/shared.d.ts +5 -5
- package/src/startup.ts +3 -3
- package/src/types.d.ts +10 -10
- package/src/utils/common/case.ts +1 -1
- package/src/utils/common/match.ts +1 -1
- package/src/utils/common/misc.ts +1 -1
- package/src/utils/constants.ts +15 -15
- package/src/utils/fs.ts +97 -39
- package/src/utils/http/authorize.ts +24 -17
- package/src/utils/http/cors.ts +6 -6
- package/src/utils/http/credential.ts +4 -4
- package/src/utils/http/csrf.ts +6 -7
- package/src/utils/http/dom.ts +9 -9
- package/src/utils/http/etag.ts +54 -13
- package/src/utils/http/html.ts +29 -20
- package/src/utils/http/ip.ts +1 -1
- package/src/utils/http/sse.ts +11 -11
- package/src/utils/http/url.ts +4 -4
- package/src/utils/http/validate.ts +3 -4
- package/src/utils/isomorphic/escape.ts +1 -1
- package/src/utils/isomorphic/is.ts +2 -2
- package/src/utils/isomorphic/misc.ts +1 -1
- package/src/utils/isomorphic/stringify.ts +2 -3
- package/src/utils/isomorphic/try.ts +1 -1
- package/src/utils/shared-pool.ts +23 -16
- package/tsconfig.app.json +3 -3
- package/tsconfig.vue.json +3 -3
package/src/utils/fs.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { gzip as zlibGzip } from 'node:zlib'
|
|
|
15
15
|
import { LRUCache } from '../cache/lru'
|
|
16
16
|
// A cycle on paper (`core/context` imports this file for `getAppVersion`),
|
|
17
17
|
// harmless in practice: neither module touches the other's bindings during
|
|
18
|
-
// evaluation
|
|
18
|
+
// evaluation. `hostStore` is only read inside `isForbidden`, at request time.
|
|
19
19
|
import { hostStore } from '../core/context'
|
|
20
20
|
import type { MixedPromise } from '../types'
|
|
21
21
|
import { is, Try } from './common'
|
|
@@ -42,7 +42,7 @@ const gzipAsync = promisify(zlibGzip) as (
|
|
|
42
42
|
* 1MB | 5804us | 6480us | 14469us | 17114us
|
|
43
43
|
* (32KB gzip: median of 5 x 200-iteration runs; single runs were noisy)
|
|
44
44
|
*
|
|
45
|
-
* At 32KB the sync cost crosses the dispatch overhead for zstd
|
|
45
|
+
* At 32KB the sync cost crosses the dispatch overhead for zstd, the
|
|
46
46
|
* first-preference codec. gzip's raw-latency break-even sits nearer 64KB,
|
|
47
47
|
* but the sync figure is a *stall* shared by every concurrent request while
|
|
48
48
|
* the async figure is latency private to one caller, so the tie goes to
|
|
@@ -54,8 +54,8 @@ export const ASYNC_COMPRESSION_MIN = 32 * 1024
|
|
|
54
54
|
* `compress` is the sync variant (blocks the event loop for the durations
|
|
55
55
|
* above); `compressAsync` runs on a worker pool and only parks the caller.
|
|
56
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`
|
|
58
|
-
* the libuv pool
|
|
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
59
|
* byte-identical to their sync counterparts on this Bun, so either path
|
|
60
60
|
* yields the same cached artifacts and the same response bodies.
|
|
61
61
|
*/
|
|
@@ -82,13 +82,13 @@ function toArray<T>(val?: MixedArray<T>): T[] {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
85
|
+
* Memoized: `resolve` measures ~16-20us on Bun/Windows (see `isForbidden`) and
|
|
86
86
|
* a CPU profile of the request path put `safeResolve` + `path.resolve` at over
|
|
87
87
|
* 40% of per-request self time. Caching is safe because the function is purely
|
|
88
|
-
* lexical
|
|
88
|
+
* lexical: `path.resolve` reads no filesystem state, only `process.cwd()`.
|
|
89
89
|
* Relative arguments make entries cwd-dependent, and while nothing in the
|
|
90
90
|
* runtime calls `chdir`, tests do (config.broken.test.ts boots from temp
|
|
91
|
-
* fixture dirs)
|
|
91
|
+
* fixture dirs), so the cache is invalidated wholesale when cwd moves. The
|
|
92
92
|
* check is one `process.cwd()` read per call, measured at 12ns in Bun (it is
|
|
93
93
|
* cached natively), against the ~16us a resolve costs. The cache is an LRU,
|
|
94
94
|
* not a Map, because arguments derive from request paths (convention 6);
|
|
@@ -238,7 +238,7 @@ export namespace Glob {
|
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
export namespace FileSystem {
|
|
241
|
-
// Naming, not constraint
|
|
241
|
+
// Naming, not constraint: `string & {}` is `string`. These document what a
|
|
242
242
|
// parameter means at its declaration, which earns their keep. Four more
|
|
243
243
|
// (`RequestPath`, `DirectoryPath`, `FileName`, `FileExtension`) documented
|
|
244
244
|
// nothing, because nothing ever referenced them.
|
|
@@ -267,7 +267,7 @@ export namespace FileSystem {
|
|
|
267
267
|
|
|
268
268
|
/**
|
|
269
269
|
* Reads as UTF-8 text. Without the encoding this returns a Buffer, which
|
|
270
|
-
* every caller then hands to `JSON.parse
|
|
270
|
+
* every caller then hands to `JSON.parse`: legal at runtime because it
|
|
271
271
|
* coerces, but a type error at each call site, papered over in one of them
|
|
272
272
|
* with `as any`.
|
|
273
273
|
*/
|
|
@@ -281,9 +281,29 @@ export namespace FileSystem {
|
|
|
281
281
|
return (await Try(() => Bun.file(path).stat()))?.isDirectory() || false
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Handed a `BunFile`, this used to take the path back off it and build a
|
|
286
|
+
* **second** one to stat. A `BunFile` caches its own stat, so the instance
|
|
287
|
+
* the caller already has is free to read and the fresh one is a syscall:
|
|
288
|
+
* one question about one file cost two stats and an allocation.
|
|
289
|
+
*
|
|
290
|
+
* It shows up wherever a caller asks both questions, which is the common
|
|
291
|
+
* shape - `validCache` below, `nm.ts`'s `exists(nmFile) ? nmFile.lastModified`,
|
|
292
|
+
* and both mtime comparisons in `image.ts`. Measured on this machine, four
|
|
293
|
+
* interleaved rounds against a CPU-bound control flat at 27-31 ms:
|
|
294
|
+
*
|
|
295
|
+
* validCache, file present 33.0 us -> 17.3 us 1.9x
|
|
296
|
+
* validCache, file absent 34.9 us -> 17.4 us 2.0x
|
|
297
|
+
*
|
|
298
|
+
* The predicate is unchanged, and the two clauses in it are not
|
|
299
|
+
* interchangeable. `lastModified` on a **missing** file answers roughly
|
|
300
|
+
* `Date.now()` rather than 0, which is what the `< Date.now()` guard is
|
|
301
|
+
* for; dropping it reports every absent file as present. The `size > 0`
|
|
302
|
+
* fallback then covers a file whose mtime is in the future, which clock
|
|
303
|
+
* skew on a network share produces.
|
|
304
|
+
*/
|
|
284
305
|
export function exists(path: string | Bun.BunFile): boolean {
|
|
285
|
-
|
|
286
|
-
const file = Bun.file(path)
|
|
306
|
+
const file = typeof path === 'string' ? Bun.file(path) : path
|
|
287
307
|
const lastMod = file.lastModified
|
|
288
308
|
return Boolean((lastMod && lastMod < Date.now()) || file.size > 0)
|
|
289
309
|
}
|
|
@@ -293,7 +313,7 @@ export namespace FileSystem {
|
|
|
293
313
|
}
|
|
294
314
|
|
|
295
315
|
/**
|
|
296
|
-
* True only for an existing regular *file
|
|
316
|
+
* True only for an existing regular *file*: a directory answers false.
|
|
297
317
|
* Sync because `findDynamicRoute` (a sync hot-path scan) is a caller; one
|
|
298
318
|
* stat, no BunFile allocation.
|
|
299
319
|
*/
|
|
@@ -302,12 +322,12 @@ export namespace FileSystem {
|
|
|
302
322
|
}
|
|
303
323
|
|
|
304
324
|
/**
|
|
305
|
-
* Parent directory of an **already-
|
|
325
|
+
* Parent directory of an **already-normalized** absolute path: the output
|
|
306
326
|
* shape `safeResolve` produces: forward slashes, no `.`/`..` segments, no
|
|
307
327
|
* trailing slash except at a root.
|
|
308
328
|
*
|
|
309
329
|
* Only correct for that input shape, which is why it is not exported.
|
|
310
|
-
* `isForbidden` is the sole caller and it
|
|
330
|
+
* `isForbidden` is the sole caller and it normalizes before the first call.
|
|
311
331
|
*/
|
|
312
332
|
function parentOf(path: string): string {
|
|
313
333
|
const idx = path.lastIndexOf('/')
|
|
@@ -319,7 +339,7 @@ export namespace FileSystem {
|
|
|
319
339
|
// Both of these branches are defensive rather than covered. `isForbidden`
|
|
320
340
|
// stops as soon as the walk leaves `root`, and every root it is called
|
|
321
341
|
// with is a real serve directory well below a filesystem root, so the walk
|
|
322
|
-
// never reaches one
|
|
342
|
+
// never reaches one: dropping them both leaves the equivalence test in
|
|
323
343
|
// `fs.test.ts` green. They are kept so `parentOf` is correct as written
|
|
324
344
|
// rather than correct only for its current caller.
|
|
325
345
|
if (path.charCodeAt(idx - 1) === 58 /* ':' */) return path.slice(0, idx + 1)
|
|
@@ -330,7 +350,7 @@ export namespace FileSystem {
|
|
|
330
350
|
* True when `pathToCheck`, or any directory between it and `root`, holds a
|
|
331
351
|
* `.forbidden` marker.
|
|
332
352
|
*
|
|
333
|
-
* This is a security guard, and it runs several times per request
|
|
353
|
+
* This is a security guard, and it runs several times per request, from
|
|
334
354
|
* `router.ts` before anything else, then again from `getStatic`, `getRoute`
|
|
335
355
|
* (once per level of a nested route) and the dynamic-route cache. Each call
|
|
336
356
|
* walks every directory between the target and the serve root, so the
|
|
@@ -338,26 +358,26 @@ export namespace FileSystem {
|
|
|
338
358
|
*
|
|
339
359
|
* The walk used to call `safeResolve` twice per level, once to build
|
|
340
360
|
* `<dir>/.forbidden` and once to step to the parent. Both are `node:path`
|
|
341
|
-
* `resolve`, which on Bun/Windows measures **~16-20us
|
|
361
|
+
* `resolve`, which on Bun/Windows measures **~16-20us**: roughly 100x
|
|
342
362
|
* `path.dirname` (~0.2us), independent of the filesystem and of where the
|
|
343
363
|
* cwd lives, and not explained by native-call overhead (`Bun.nanoseconds()`
|
|
344
364
|
* is 0.045us). A depth-4 walk therefore paid ten `resolve` calls.
|
|
345
365
|
*
|
|
346
|
-
* Since `curr` is
|
|
366
|
+
* Since `curr` is normalized once on entry, every value it takes afterwards
|
|
347
367
|
* is already in `resolve`'s own output shape, so both operations are exact
|
|
348
368
|
* string edits. That takes a depth-4 walk from ten `resolve` calls to two
|
|
349
369
|
* and leaves one `existsSync` per level as the only remaining syscall.
|
|
350
370
|
* `fs.test.ts` pins the two implementations as equivalent.
|
|
351
371
|
*
|
|
352
|
-
* The entry
|
|
372
|
+
* The entry normalization stays: `pathToCheck` reaches here straight from a
|
|
353
373
|
* URL pathname in `router.ts`, before any containment check, and collapsing
|
|
354
374
|
* its dot segments is what makes the rest of the walk safe.
|
|
355
375
|
*
|
|
356
|
-
* The remaining `existsSync` per level is deliberately **not**
|
|
376
|
+
* The remaining `existsSync` per level is deliberately **not** memoized
|
|
357
377
|
* *across requests*, which is the opposite call to the one `getMimeType`
|
|
358
|
-
* below makes. The difference is not the key
|
|
378
|
+
* below makes. The difference is not the key: both are ultimately derived
|
|
359
379
|
* from a request path and both would need an `LRUCache` to stay bounded
|
|
360
|
-
* under convention 6
|
|
380
|
+
* under convention 6: it is what a stale entry costs. A stale MIME type
|
|
361
381
|
* serves the wrong `Content-Type`; a stale *negative* here serves a file an
|
|
362
382
|
* operator has marked forbidden, which is the exact outcome the marker
|
|
363
383
|
* exists to prevent.
|
|
@@ -367,7 +387,7 @@ export namespace FileSystem {
|
|
|
367
387
|
* a cached "not forbidden" would persist for the life of the process; a TTL
|
|
368
388
|
* would only bound that window, not close it, and an operator dropping a
|
|
369
389
|
* `.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
|
|
390
|
+
* window is. The LRU also fails exactly when it would matter: a client
|
|
371
391
|
* requesting varied paths evicts the useful upper-level entries, so the
|
|
372
392
|
* hit rate collapses under the load that motivated caching in the first
|
|
373
393
|
* place. `fs.test.ts` has a regression test that fails if this is added.
|
|
@@ -376,8 +396,8 @@ export namespace FileSystem {
|
|
|
376
396
|
* 4-6x per request over overlapping subtrees (router, `getStatic`, every
|
|
377
397
|
* level of `getRoute`, the dynamic-route cache), each walk re-stating the
|
|
378
398
|
* same directories at ~50us per miss on Bun/Windows. A marker dropped
|
|
379
|
-
* *mid-request* has no meaningful semantics
|
|
380
|
-
* "the next request sees it"
|
|
399
|
+
* *mid-request* has no meaningful semantics (the operator's guarantee is
|
|
400
|
+
* "the next request sees it"), so the raw probe results live on the current
|
|
381
401
|
* `hostStore` value (`HostContext.forbiddenProbes`), which worker.ts creates
|
|
382
402
|
* per request and never reuses. No store (tests, direct calls, startup)
|
|
383
403
|
* means no caching, bit-for-bit today's behavior.
|
|
@@ -399,7 +419,7 @@ export namespace FileSystem {
|
|
|
399
419
|
//
|
|
400
420
|
// The walk below is bounded by `curr.startsWith(normalizedRoot)`, so an
|
|
401
421
|
// out-of-root path skipped the loop body entirely and fell through to
|
|
402
|
-
// `return false
|
|
422
|
+
// `return false`, "not forbidden". That is a guard failing *open* on the
|
|
403
423
|
// one input it most needs to refuse, and it made every caller that relied
|
|
404
424
|
// on this for containment (all of them: `constants.ts` said so in as many
|
|
405
425
|
// words) incapable of catching an escape.
|
|
@@ -409,12 +429,12 @@ export namespace FileSystem {
|
|
|
409
429
|
// read the pattern as drive-absolute, ignored `cwd`, and matched files at
|
|
410
430
|
// `C:\`. `getRoute` resolved one, asked this function, was told "allowed",
|
|
411
431
|
// and returned an `Info` pointing six levels above the serve root. The glob
|
|
412
|
-
// is fixed too, but the glob was only how it was reached
|
|
432
|
+
// is fixed too, but the glob was only how it was reached: a resolved file
|
|
413
433
|
// outside the root has to be refused here whatever produced it.
|
|
414
434
|
//
|
|
415
435
|
// The separator suffix matters: a bare `startsWith` would also accept a
|
|
416
436
|
// sibling directory whose name merely begins with the root's. A root that
|
|
417
|
-
// is already a filesystem root ends in `/` and must not gain a second one
|
|
437
|
+
// is already a filesystem root ends in `/` and must not gain a second one:
|
|
418
438
|
// `C://` matches nothing, which would make every path under `C:/` read as
|
|
419
439
|
// an escape. `fs.test.ts` drives both through its root corpus.
|
|
420
440
|
const rootPrefix = normalizedRoot.endsWith('/')
|
|
@@ -452,19 +472,53 @@ export namespace FileSystem {
|
|
|
452
472
|
return false
|
|
453
473
|
}
|
|
454
474
|
|
|
455
|
-
|
|
475
|
+
/**
|
|
476
|
+
* `statSync` with ENOENT suppressed, not `existsSync`.
|
|
477
|
+
*
|
|
478
|
+
* Node's `existsSync` is a `stat` with a `try`/`catch` around it, and on
|
|
479
|
+
* Bun/Windows the throw-and-catch for a path that is not there is most of
|
|
480
|
+
* what it costs. Asking for the stat directly and reading `undefined` for a
|
|
481
|
+
* miss skips that. Measured on this machine, four interleaved rounds
|
|
482
|
+
* against a CPU-bound control that stayed flat at 27-30 ms:
|
|
483
|
+
*
|
|
484
|
+
* existsSync 30.00 us guarded statSync 17.52 us 1.71x
|
|
485
|
+
*
|
|
486
|
+
* A miss is the case that matters: almost no directory holds a `.forbidden`
|
|
487
|
+
* marker, so every level of every walk takes this path. At four unique
|
|
488
|
+
* levels per request that is 50 us, at six 75 us.
|
|
489
|
+
*
|
|
490
|
+
* The `catch` is load-bearing rather than defensive habit. `throwIfNoEntry:
|
|
491
|
+
* false` suppresses **ENOENT only**, so a directory the process cannot stat
|
|
492
|
+
* still throws EACCES or EPERM where `existsSync` answers false - and a
|
|
493
|
+
* throw here would escape through `isForbidden` into the request. Answering
|
|
494
|
+
* false matches the behavior this replaces exactly; the two forms agree on
|
|
495
|
+
* a missing marker, a present one, a marker that is a *directory*, a path
|
|
496
|
+
* several levels below anything that exists, a directory, and the empty
|
|
497
|
+
* string.
|
|
498
|
+
*/
|
|
499
|
+
function statProbe(path: string): boolean {
|
|
500
|
+
try {
|
|
501
|
+
return nodeStatSync(path, { throwIfNoEntry: false }) !== undefined
|
|
502
|
+
} catch {
|
|
503
|
+
// See above: a stat that fails for any reason other than absence is
|
|
504
|
+
// reported as absent, which is what `existsSync` did.
|
|
505
|
+
return false
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
let probeForbidden: (path: string) => boolean = statProbe
|
|
456
510
|
|
|
457
511
|
/**
|
|
458
512
|
* Test seam (convention 9): lets `fs.test.ts` count the syscalls the
|
|
459
513
|
* per-request dedup above is supposed to be saving, without module-mocking
|
|
460
|
-
* `node:fs
|
|
514
|
+
* `node:fs`, which is process-global and never unwinds.
|
|
461
515
|
*/
|
|
462
516
|
export function __setForbiddenProbe(fn: (path: string) => boolean) {
|
|
463
517
|
probeForbidden = fn
|
|
464
518
|
}
|
|
465
519
|
|
|
466
520
|
export function __resetForbiddenProbe() {
|
|
467
|
-
probeForbidden =
|
|
521
|
+
probeForbidden = statProbe
|
|
468
522
|
}
|
|
469
523
|
|
|
470
524
|
export async function mkdir(path: string) {
|
|
@@ -508,6 +562,10 @@ export namespace FileSystem {
|
|
|
508
562
|
return compressable.has(cleanExt)
|
|
509
563
|
}
|
|
510
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Two reads of one `BunFile`, which is one stat: `exists` no longer
|
|
567
|
+
* re-wraps the instance, so the second read comes off the cached stat.
|
|
568
|
+
*/
|
|
511
569
|
function validCache(file: Bun.BunFile, sourceMtime: number | null): boolean {
|
|
512
570
|
return exists(file) && (!sourceMtime || sourceMtime <= file.lastModified)
|
|
513
571
|
}
|
|
@@ -521,11 +579,11 @@ export namespace FileSystem {
|
|
|
521
579
|
* so N concurrent first-hits on a cold cache each saw "not cached", each ran
|
|
522
580
|
* `compiler()`, and each wrote the same three paths. Measured: 8 concurrent
|
|
523
581
|
* callers produced 8 compiler invocations. Every `.ts`/`.tsx`/`.vue` asset
|
|
524
|
-
* goes through here, so a traffic burst on a cold cache
|
|
525
|
-
* workers share one cache directory
|
|
582
|
+
* goes through here, so a traffic burst on a cold cache (or a cluster whose
|
|
583
|
+
* workers share one cache directory) paid the full transpile *plus* gzip
|
|
526
584
|
* *and* zstd once per concurrent request.
|
|
527
585
|
*
|
|
528
|
-
* Same
|
|
586
|
+
* Same memoized-promise idiom as `initDB`, `setupPlugins` and
|
|
529
587
|
* `getTranspiler`, keyed rather than singular, and deleted in a `finally` so
|
|
530
588
|
* a failed build is retried by the next caller rather than remembered. It is
|
|
531
589
|
* bounded by construction (convention 6): an entry exists only while its own
|
|
@@ -533,7 +591,7 @@ export namespace FileSystem {
|
|
|
533
591
|
*
|
|
534
592
|
* The key is the path alone, not the path plus `sourceMtime`. A caller that
|
|
535
593
|
* joins mid-build can therefore receive a build started for a marginally
|
|
536
|
-
* older mtime
|
|
594
|
+
* older mtime: a window only as wide as one compile, which the next request
|
|
537
595
|
* re-validates and rebuilds. Including the mtime would close that window by
|
|
538
596
|
* letting two builds of the same file race each other onto the same three
|
|
539
597
|
* output paths, which is the worse trade.
|
|
@@ -590,7 +648,7 @@ export namespace FileSystem {
|
|
|
590
648
|
if (content == null) return null
|
|
591
649
|
|
|
592
650
|
// Only on the build path: a warm hit was paying this stat on every
|
|
593
|
-
// request for nothing
|
|
651
|
+
// request for nothing, the directory necessarily exists if the cached
|
|
594
652
|
// file it holds just validated.
|
|
595
653
|
await mkdir(cacheDir)
|
|
596
654
|
|
|
@@ -617,7 +675,7 @@ export namespace FileSystem {
|
|
|
617
675
|
|
|
618
676
|
// The mirror of the compressed branch's check above. Without it a
|
|
619
677
|
// non-compressible entry (a .woff2 from the gstatic proxy, say) was
|
|
620
|
-
// rebuilt
|
|
678
|
+
// rebuilt (for fonts, re-fetched upstream) on every single request.
|
|
621
679
|
if (validCache(rawFile, sourceMtime)) return rawFile
|
|
622
680
|
|
|
623
681
|
const content = await compiler()
|
|
@@ -629,8 +687,8 @@ export namespace FileSystem {
|
|
|
629
687
|
|
|
630
688
|
/**
|
|
631
689
|
* The extension→MIME mapping is static, but resolving it allocates a
|
|
632
|
-
* `Bun.BunFile` every call
|
|
633
|
-
* compressed-asset response.
|
|
690
|
+
* `Bun.BunFile` every call, ~1.4us, on the path of every static-file and
|
|
691
|
+
* compressed-asset response. Memoized behind an `LRUCache` rather than a
|
|
634
692
|
* plain Map because `ext` is ultimately derived from the request path, and
|
|
635
693
|
* an unbounded map keyed on client-supplied input is exactly what
|
|
636
694
|
* convention 6 forbids.
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* One implementation, deliberately in core, for the same reason
|
|
5
5
|
* `credential.ts` is here: the dashboard, db-explorer and analytics plugins
|
|
6
6
|
* each need this guard, and per-plugin copies of security code drift. These
|
|
7
|
-
* three had already drifted
|
|
7
|
+
* three had already drifted: one coerced its predicate's return with
|
|
8
8
|
* `Boolean`, one gated on `!DEV` where the others read `PROD`, one swallowed a
|
|
9
9
|
* `getClientIp` throw into an empty string and carried on. Each divergence is
|
|
10
10
|
* resolved below toward the fail-closed reading, and each is marked as a
|
|
11
11
|
* decision rather than left to look like a style choice.
|
|
12
12
|
*
|
|
13
|
-
* It owns *only* the predicate
|
|
13
|
+
* It owns *only* the predicate: no logins, no sessions, no backoff. The shared
|
|
14
14
|
* key path is `credential.ts`; a plugin that offers both doors composes them.
|
|
15
15
|
*
|
|
16
16
|
* `getClientIp` is imported from `./ip` directly, never through this
|
|
@@ -37,7 +37,7 @@ export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
|
|
|
37
37
|
* `'localhost'` used to be a member, back when the request's *hostname* was
|
|
38
38
|
* compared against this set as well. A peer address is never the string
|
|
39
39
|
* `localhost`, and accepting it meant `X-Forwarded-For: localhost` counted as
|
|
40
|
-
* loopback under `trustProxy
|
|
40
|
+
* loopback under `trustProxy`, and, worse, that `new URL(req.url).hostname`
|
|
41
41
|
* did too. Bun builds that from the client's own `Host` header and
|
|
42
42
|
* `DEFAULT_HOST` is 0.0.0.0, so any peer on the LAN could send
|
|
43
43
|
* `Host: localhost` and be handed a database browser.
|
|
@@ -51,7 +51,7 @@ export function isLoopback(req: Request): boolean {
|
|
|
51
51
|
// DECISION (divergence 3): a throw returns `false` immediately rather than
|
|
52
52
|
// falling through with `ip = ''`. `getClientIp` reads config and the live
|
|
53
53
|
// server, either of which may be absent (tests, early boot). The two spell
|
|
54
|
-
// the same answer today, because `LOOPBACK.has('')` is false
|
|
54
|
+
// the same answer today, because `LOOPBACK.has('')` is false, but only by
|
|
55
55
|
// coincidence, and the fall-through invites a later edit that adds a second
|
|
56
56
|
// source of evidence below this line and silently consults it on the
|
|
57
57
|
// indeterminate path. Returning here says the answer is settled: no address
|
|
@@ -71,31 +71,38 @@ export function isLoopback(req: Request): boolean {
|
|
|
71
71
|
*/
|
|
72
72
|
export function defaultAuthorize(req: Request): boolean {
|
|
73
73
|
// DECISION (divergence 2): gate on `PROD`, not `!DEV`, and read it at call
|
|
74
|
-
// time
|
|
74
|
+
// time. The mode flags are accessors on `process.env` (`core/init.ts`), so
|
|
75
75
|
// they are process state and tests flip them.
|
|
76
76
|
//
|
|
77
77
|
// The two spellings look interchangeable because `init.ts` derives them as
|
|
78
78
|
// complements (`PROD = !isDev && !--dev-worker`). They are not, because they
|
|
79
79
|
// are *independently settable* accessors and the test fixtures set them one
|
|
80
|
-
// at a time: `asDev` flips `DEV` alone and leaves the ambient `PROD`
|
|
81
|
-
// under `bun test` is `true`
|
|
80
|
+
// at a time: `asDev` flips `DEV` alone and leaves the ambient `PROD` (which
|
|
81
|
+
// under `bun test` is `true`) in place. In that state `!DEV` admits a
|
|
82
82
|
// loopback caller and `PROD` denies, so `PROD` is both the fail-closed
|
|
83
83
|
// reading and the one that literally names the condition it means, rather
|
|
84
84
|
// than inferring production from the absence of development.
|
|
85
85
|
//
|
|
86
|
-
// Hence
|
|
87
|
-
// place a bare `PROD` gate would be
|
|
88
|
-
// exist until `core/init.ts` has run, and
|
|
89
|
-
// through to the loopback check
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
86
|
+
// Hence a test for the *positive* development marker rather than a truthiness
|
|
87
|
+
// test on `PROD`, which is the one place a bare `PROD` gate would be weaker
|
|
88
|
+
// than `!DEV`. The flags do not exist until `core/init.ts` has run, and
|
|
89
|
+
// `if (undefined)` falls straight through to the loopback check, so an
|
|
90
|
+
// uninitialized process would open the door an initialized production one
|
|
91
|
+
// keeps shut. Unset is not evidence of development; it is no evidence at all.
|
|
92
|
+
//
|
|
93
|
+
// This read `PROD !== false` while the flags were real booleans. Bun 1.4
|
|
94
|
+
// rejects accessor descriptors on `process.env`, so they are `'1'`/`''`
|
|
95
|
+
// strings now (see `core/init.ts`), and `!== false` is true for *every*
|
|
96
|
+
// string, including the `''` a development server sets, so the gate would
|
|
97
|
+
// have denied on loopback in development while still looking correct.
|
|
98
|
+
// `DEV === '1'` is the same statement in the encoding that survives: only a
|
|
99
|
+
// booted development server sets it, and production (`''`) and never-booted
|
|
100
|
+
// (`undefined`) both fail it.
|
|
94
101
|
//
|
|
95
102
|
// `isLoopback` would very likely deny on its own there, having no config or
|
|
96
103
|
// server to read an address from. That is a second line of defence, not this
|
|
97
104
|
// one's excuse: a guard should not depend on another guard's failure mode.
|
|
98
|
-
if (import.meta.env.
|
|
105
|
+
if (import.meta.env.DEV !== '1') return false
|
|
99
106
|
return isLoopback(req)
|
|
100
107
|
}
|
|
101
108
|
|
|
@@ -116,7 +123,7 @@ export async function isAuthorized(
|
|
|
116
123
|
try {
|
|
117
124
|
// DECISION (divergence 1): `=== true`, never `Boolean(...)`. The predicate
|
|
118
125
|
// comes from application code and `AuthorizeFn`'s return type is only
|
|
119
|
-
// advice
|
|
126
|
+
// advice: an untyped, transpiled or `as any` predicate can hand back
|
|
120
127
|
// anything. `Boolean` admits every truthy non-boolean, so a check that
|
|
121
128
|
// answers with a status string denies on `""` and *grants* on `"no"`, and
|
|
122
129
|
// one that answers with a count grants on any non-zero. Admission is the
|
package/src/utils/http/cors.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-origin resource sharing.
|
|
3
3
|
*
|
|
4
|
-
* Bakery already ships the pieces of an API server
|
|
5
|
-
* CSRF, rate limiting
|
|
4
|
+
* Bakery already ships the pieces of an API server (`ApiHandler`, sessions,
|
|
5
|
+
* CSRF, rate limiting), and had no way to serve one to a browser on another
|
|
6
6
|
* origin. This is that.
|
|
7
7
|
*
|
|
8
8
|
* Two halves, and both are needed: a preflight `OPTIONS` has to be answered
|
|
@@ -22,8 +22,8 @@ export interface CorsOptions {
|
|
|
22
22
|
/**
|
|
23
23
|
* Origins allowed to read responses.
|
|
24
24
|
*
|
|
25
|
-
* `'*'` is
|
|
26
|
-
* `credentials
|
|
25
|
+
* `'*'` is honored literally, and is refused in combination with
|
|
26
|
+
* `credentials`. See `resolveOrigin`. A function receives the request's
|
|
27
27
|
* `Origin` and returns the value to echo, or `null` to deny.
|
|
28
28
|
*/
|
|
29
29
|
origin: string | string[] | ((origin: string) => string | null)
|
|
@@ -45,7 +45,7 @@ const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
|
|
|
45
45
|
* The value for `Access-Control-Allow-Origin`, or `null` to send nothing.
|
|
46
46
|
*
|
|
47
47
|
* **`'*'` with credentials is refused rather than silently downgraded.** The
|
|
48
|
-
* browser rejects that pairing anyway, so
|
|
48
|
+
* browser rejects that pairing anyway, so honoring it would produce a request
|
|
49
49
|
* that fails in the client with a CORS error and a server that believes it
|
|
50
50
|
* allowed the call. Echoing the origin instead would be a *quiet widening* of
|
|
51
51
|
* what the app asked for. Returning null makes the misconfiguration visible as
|
|
@@ -97,7 +97,7 @@ export function corsHeaders(
|
|
|
97
97
|
/**
|
|
98
98
|
* The response to a preflight, or `null` if this is not one.
|
|
99
99
|
*
|
|
100
|
-
* A preflight is `OPTIONS` *with* `Access-Control-Request-Method
|
|
100
|
+
* A preflight is `OPTIONS` *with* `Access-Control-Request-Method`: plain
|
|
101
101
|
* `OPTIONS` is a normal request and must fall through to routing, or an app
|
|
102
102
|
* with its own OPTIONS route would find it shadowed.
|
|
103
103
|
*
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* One implementation, deliberately in core: the db-explorer and analytics
|
|
6
6
|
* plugins each need it and per-plugin copies of security code drift. It owns
|
|
7
|
-
* *only* the comparison
|
|
7
|
+
* *only* the comparison: no logins, no sessions, no backoff.
|
|
8
8
|
*
|
|
9
9
|
* A credential is presented three ways, in this order of preference:
|
|
10
10
|
*
|
|
11
11
|
* - `x-<name>` header (scriptable, never logged by default)
|
|
12
12
|
* - `Authorization: Bearer <credential>`
|
|
13
13
|
* - `?<name>=<credential>` query, for a human opening a URL in a browser
|
|
14
|
-
* who cannot set a header
|
|
14
|
+
* who cannot set a header. The caller is expected to strip it from the
|
|
15
15
|
* URL client-side; it *does* reach server logs, which is documented, and
|
|
16
16
|
* the header forms exist for anything automated.
|
|
17
17
|
*
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* variable unset must mean "off", not "open".
|
|
21
21
|
*
|
|
22
22
|
* The compare is constant-time. A plain `===` on a secret is a timing oracle,
|
|
23
|
-
* and `timingSafeEqual` costs one line
|
|
23
|
+
* and `timingSafeEqual` costs one line, but it throws on length mismatch, so
|
|
24
24
|
* the length is checked first (which leaks only the length, never the bytes).
|
|
25
25
|
*/
|
|
26
26
|
|
|
@@ -41,7 +41,7 @@ export function credentialMatches(
|
|
|
41
41
|
/**
|
|
42
42
|
* Pull a presented credential off a request: the `x-<name>` header, a Bearer
|
|
43
43
|
* token, or the `<name>` query parameter, in that order. Returns `null` when
|
|
44
|
-
* none is present. `name` is the header/query key
|
|
44
|
+
* none is present. `name` is the header/query key: `db-key`, `analytics-key`.
|
|
45
45
|
*/
|
|
46
46
|
export function readCredential(req: Request, name: string): string | null {
|
|
47
47
|
const header = req.headers.get(`x-${name}`)
|
package/src/utils/http/csrf.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Cross-site request forgery guards.
|
|
3
3
|
*
|
|
4
4
|
* A `SameSite=Lax` cookie is still sent on top-level cross-site *navigations*,
|
|
5
|
-
* so it is not on its own sufficient to protect a state-changing endpoint
|
|
5
|
+
* so it is not on its own sufficient to protect a state-changing endpoint:
|
|
6
6
|
* `<a href="/api/admin/delete?id=5">` or a cross-origin `<form>` POST both
|
|
7
7
|
* arrive with the victim's session attached.
|
|
8
8
|
*/
|
|
@@ -17,7 +17,7 @@ const SAME_SITE_VALUES = new Set(['same-origin', 'same-site', 'none'])
|
|
|
17
17
|
/**
|
|
18
18
|
* Returns a reason string when the request looks cross-site, or `null` when it
|
|
19
19
|
* is safe to process. Requests with neither `Origin` nor `Sec-Fetch-Site` (curl,
|
|
20
|
-
* server-to-server, older clients) are allowed
|
|
20
|
+
* server-to-server, older clients) are allowed: browsers always send at least
|
|
21
21
|
* one of them on a cross-origin request.
|
|
22
22
|
*/
|
|
23
23
|
export function checkSameOrigin(req: Request, url: URL): string | null {
|
|
@@ -46,7 +46,7 @@ export function checkCsrf(req: Request, url: URL): string | null {
|
|
|
46
46
|
/**
|
|
47
47
|
* An attacker-supplied header value, made safe to put in a reason string.
|
|
48
48
|
*
|
|
49
|
-
* Reason strings are logged, and the logger renders one record per line
|
|
49
|
+
* Reason strings are logged, and the logger renders one record per line, so
|
|
50
50
|
* an `Origin` carrying a newline could forge additional log entries. Anything
|
|
51
51
|
* outside printable ASCII goes, and the result is capped: a rejection reason
|
|
52
52
|
* is a diagnostic, not a transcript.
|
|
@@ -57,14 +57,13 @@ function safeEcho(value: string): string {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Origin guard for WebSocket upgrades
|
|
60
|
+
* Origin guard for WebSocket upgrades: deliberately stricter than
|
|
61
61
|
* `checkSameOrigin`, and applied in `upgradeWebsocket` so every socket
|
|
62
62
|
* inherits it rather than each handler having to remember.
|
|
63
63
|
*
|
|
64
64
|
* WebSockets are exempt from the same-origin policy. Any page the developer
|
|
65
65
|
* visits can open `ws://localhost:3000/_livereload`, and until this existed
|
|
66
|
-
* one `{"type":"subscribe_logger"}` bought a live feed of the server log
|
|
67
|
-
* failing SQL, table and column names, absolute source paths — plus the
|
|
66
|
+
* one `{"type":"subscribe_logger"}` bought a live feed of the server log ( * failing SQL, table and column names, absolute source paths) plus the
|
|
68
67
|
* ability to reload every open browser. The only thing that separates the
|
|
69
68
|
* app's own page from an attacker's is `Origin`, which the browser sets on
|
|
70
69
|
* every handshake and page script cannot override.
|
|
@@ -89,7 +88,7 @@ function safeEcho(value: string): string {
|
|
|
89
88
|
*
|
|
90
89
|
* An **absent** `Origin` is allowed, and that is a decision rather than an
|
|
91
90
|
* oversight. A browser never omits it on a handshake, so its absence means the
|
|
92
|
-
* peer is not a browser
|
|
91
|
+
* peer is not a browser: curl, a test, a CLI, a health check. Such a client
|
|
93
92
|
* can also set `Origin` to whatever it likes, so refusing the absent case buys
|
|
94
93
|
* no security at all while breaking every non-browser client. This check is
|
|
95
94
|
* worth exactly what a browser's inability to forge the header is worth, and
|
package/src/utils/http/dom.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { Try } from '../common/try'
|
|
|
7
7
|
import { fs } from '../fs'
|
|
8
8
|
import { escapeScriptJson } from '../isomorphic/escape'
|
|
9
9
|
|
|
10
|
-
// Keyed by hostname, which comes from the Host header
|
|
10
|
+
// Keyed by hostname, which comes from the Host header: bounded so a client
|
|
11
11
|
// cannot grow it without limit by varying that header per request.
|
|
12
12
|
const headBodyCache = new LRUCache<string, { head: string; body: string }>(64)
|
|
13
13
|
|
|
@@ -22,12 +22,12 @@ let depMap = ''
|
|
|
22
22
|
const hostDepMaps = new Map<string, string>()
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
25
|
+
* Normalize one import-map entry, for both the process-level map and the
|
|
26
26
|
* per-host maps.
|
|
27
27
|
*
|
|
28
28
|
* One helper, two callers, deliberately: they used to be separate copies that
|
|
29
|
-
* had drifted into testing different things
|
|
30
|
-
* the other on the entry value
|
|
29
|
+
* had drifted into testing different things (one switched on the entry key,
|
|
30
|
+
* the other on the entry value), so the same input normalized two ways
|
|
31
31
|
* depending on which path saw it.
|
|
32
32
|
*
|
|
33
33
|
* `@client/utils` is keyed on the *key*: it is a live alias, the default
|
|
@@ -75,12 +75,12 @@ let installedCache: Promise<string[]> | null = null
|
|
|
75
75
|
*
|
|
76
76
|
* Read from `node_modules` rather than from `dependencies`, and the difference
|
|
77
77
|
* is the whole point: a package can be installed and imported without being
|
|
78
|
-
* declared
|
|
78
|
+
* declared (a transitive one, or a dependency someone forgot to add), and the
|
|
79
79
|
* browser's failure for a specifier the map misses names its own rule rather
|
|
80
80
|
* than the missing entry: *"Failed to resolve module specifier 'pkg'. Relative
|
|
81
81
|
* references must start with either "/", "./", or "../"."*
|
|
82
82
|
*
|
|
83
|
-
* Cheap
|
|
83
|
+
* Cheap: one `readdir` per scope, no `package.json` reads, and memoized per
|
|
84
84
|
* process besides: the import map reads it at boot, `bundleModule` on every
|
|
85
85
|
* bundle (as its `external` list), and a `readdir` sweep per bundle is pure
|
|
86
86
|
* waste. A dev restart is a new process, so an install still shows up.
|
|
@@ -122,7 +122,7 @@ async function readInstalledPackages(): Promise<string[]> {
|
|
|
122
122
|
*
|
|
123
123
|
* **Resolution happens at the other end of the URL, and rewriting happens
|
|
124
124
|
* nowhere.** An entry maps a package to `/_nm/<name>`; `NMHandler` hands that to
|
|
125
|
-
* `Bun.build`, which applies real browser resolution
|
|
125
|
+
* `Bun.build`, which applies real browser resolution: `exports` maps,
|
|
126
126
|
* conditions, the `browser` field. Naming an entry file here instead would mean
|
|
127
127
|
* reimplementing all of that, badly.
|
|
128
128
|
*
|
|
@@ -168,7 +168,7 @@ export namespace DOMTools {
|
|
|
168
168
|
|
|
169
169
|
// Was a hand-rolled `JSON.stringify(...).replace(/</g, ...)`, which is the
|
|
170
170
|
// same job `escapeScriptJson` already does for every other inline-script
|
|
171
|
-
// payload
|
|
171
|
+
// payload: its docstring even said "Mirrors utils/http/dom.ts". The two
|
|
172
172
|
// had drifted: this copy never escaped U+2028/U+2029.
|
|
173
173
|
return `<script>window.__PAGE_PARAMS__ = ${escapeScriptJson(newParams)}</script>`
|
|
174
174
|
}
|
|
@@ -190,7 +190,7 @@ export namespace DOMTools {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* ResponseInit carrying `data`'s status and headers, minus Content-Length
|
|
193
|
+
* ResponseInit carrying `data`'s status and headers, minus Content-Length:
|
|
194
194
|
* injection changes the body length, so a stale value must not survive into
|
|
195
195
|
* the rebuilt Response. Shared by the buffered and streamed injection paths
|
|
196
196
|
* so the two cannot drift on which headers survive.
|