@bakery-framework/core 2.0.0-alpha.9 → 2.0.0-rc.2
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 +54 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +29 -8
- 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/startup.ts
CHANGED
|
@@ -39,7 +39,7 @@ let pluginSetup: Promise<void> | null = null
|
|
|
39
39
|
* Calling both meant every non-clustered worker set its plugins up twice.
|
|
40
40
|
* Handler and mount registration is idempotent so that went unnoticed, but the
|
|
41
41
|
* analytics plugin registers a shutdown hook and kicks off `loadAnalyticsData()`
|
|
42
|
-
* from `setup()
|
|
42
|
+
* from `setup()`, so it got two of each. Memoizing the promise (not a boolean)
|
|
43
43
|
* also makes a concurrent second caller await the first run rather than race it.
|
|
44
44
|
*/
|
|
45
45
|
export function setupPlugins(): Promise<void> {
|
|
@@ -96,7 +96,7 @@ export async function runStartupBanner(): Promise<void> {
|
|
|
96
96
|
const host = Bakery.config.host
|
|
97
97
|
|
|
98
98
|
// Ground truth first. The banner's job is to print where the server is
|
|
99
|
-
// listening, and `Bakery.server.port` is what the OS actually gave us
|
|
99
|
+
// listening, and `Bakery.server.port` is what the OS actually gave us,
|
|
100
100
|
// which is the only correct answer under `PORT=0`, and the only *honest*
|
|
101
101
|
// one if the bound port and the requested one ever part company again.
|
|
102
102
|
// `resolvePort` covers the callers that print a banner without a server
|
|
@@ -134,7 +134,7 @@ export async function runStartupBanner(): Promise<void> {
|
|
|
134
134
|
// constant, and any user-supplied value replaces the reference. The
|
|
135
135
|
// default limiter 429s load tests and shared-NAT offices with nothing
|
|
136
136
|
// anywhere saying it exists, so this line is its one announcement. An
|
|
137
|
-
// app-configured value prints nothing
|
|
137
|
+
// app-configured value prints nothing: their choice, their knowledge.
|
|
138
138
|
if (Bakery.config.rateLimit === DEFAULT_RATE_LIMIT) {
|
|
139
139
|
serveLog.RATE_LIMIT_DEFAULT(DEFAULT_RATE_LIMIT)
|
|
140
140
|
}
|
package/src/types.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* These were declared in `declare global`, which is fine inside one repo and a
|
|
5
5
|
* hazard the moment the packages are published: the names are generic enough
|
|
6
6
|
* that a consuming app is likely to define its own `MapOf`, and a global type
|
|
7
|
-
* alias cannot be merged or opted out of
|
|
7
|
+
* alias cannot be merged or opted out of, the app just gets a redeclaration
|
|
8
8
|
* error with nowhere to put the fix.
|
|
9
9
|
*
|
|
10
10
|
* Moving them cost 24 files an `import type` line and cost consumers nothing:
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* that they were always internal plumbing rather than DX.
|
|
13
13
|
*
|
|
14
14
|
* `verbatimModuleSyntax` makes the compiler reject a value-position import of
|
|
15
|
-
* these, so nothing can accidentally introduce a runtime module edge
|
|
15
|
+
* these, so nothing can accidentally introduce a runtime module edge, which
|
|
16
16
|
* matters because `client/utils.ts` is compiled into the browser bundle.
|
|
17
17
|
*/
|
|
18
18
|
export type MapOf<T> = { [key: string]: T }
|
|
@@ -20,7 +20,7 @@ export type MapOf<T> = { [key: string]: T }
|
|
|
20
20
|
/** A value, or a function producing it. */
|
|
21
21
|
export type Wrapped<T, Args extends any[] = []> = T | ((...args: Args) => T)
|
|
22
22
|
|
|
23
|
-
/** Awaited or not
|
|
23
|
+
/** Awaited or not: the shape almost every framework hook returns. */
|
|
24
24
|
export type MixedPromise<T> = Promise<T> | T
|
|
25
25
|
|
|
26
26
|
type ResolveMatchReturn<V, R> = R extends any
|
|
@@ -54,12 +54,12 @@ export type Match<D extends symbol> = {
|
|
|
54
54
|
/**
|
|
55
55
|
* The `body` a route module's default export receives: declared params merge
|
|
56
56
|
* over a permissive base. `body.id` is `string` once you declare it, while
|
|
57
|
-
* `body.anythingElse` stays reachable as `any
|
|
57
|
+
* `body.anythingElse` stays reachable as `any`: the parse rules in
|
|
58
58
|
* `utils/http/body.ts` mean the framework genuinely cannot know every key, so
|
|
59
59
|
* locking the object down would be a lie in the other direction.
|
|
60
60
|
*
|
|
61
61
|
* There is no filename-literal inference (`[id].ts` does not conjure
|
|
62
|
-
* `{ id: string }` on its own
|
|
62
|
+
* `{ id: string }` on its own. That needs codegen); the contract is that the
|
|
63
63
|
* author declares the shape once, at the `defineRoute` / `html` call, instead
|
|
64
64
|
* of annotating the whole signature or settling for `any`.
|
|
65
65
|
*/
|
|
@@ -71,13 +71,13 @@ export type RouteBody<P = {}> = P & MapOf<any>
|
|
|
71
71
|
* directory under the `!` form).
|
|
72
72
|
*
|
|
73
73
|
* Exists so a route over mixed or unknown segments can say
|
|
74
|
-
* `defineRoute<MapOf<RouteParam>>` instead of hand-writing the union
|
|
74
|
+
* `defineRoute<MapOf<RouteParam>>` instead of hand-writing the union, and so
|
|
75
75
|
* the union has one definition to change if it ever grows.
|
|
76
76
|
*/
|
|
77
77
|
export type RouteParam = string | string[]
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* What a route module may actually return
|
|
80
|
+
* What a route module may actually return. Read off `processResponse`
|
|
81
81
|
* (`router.ts`) and `ApiHandler.handle`: a `Response`, a `BunFile` (streamed
|
|
82
82
|
* with an ETag), the JSON envelope, a plain data object or array (JSON-encoded
|
|
83
83
|
* as-is), a string (HTML-sniffed) or number, or nothing (204; a 404 under
|
|
@@ -85,10 +85,10 @@ export type RouteParam = string | string[]
|
|
|
85
85
|
*
|
|
86
86
|
* Deliberately not `object` (which `Handler.Response` uses): `object` absorbs
|
|
87
87
|
* `Response`, `BunFile` and `JsonResponse` into one member and the union stops
|
|
88
|
-
* saying anything
|
|
88
|
+
* saying anything, the same trap `routeTable()`'s dispatch avoids. `MapOf<any>`
|
|
89
89
|
* is the narrowest thing that covers "a plain data object"; a target index
|
|
90
90
|
* signature of `any` does still accept class instances, but that only matters
|
|
91
|
-
* for inference targets
|
|
91
|
+
* for inference targets: this union is a constraint on what an author may
|
|
92
92
|
* return, and nothing consumes a route module's return type downstream, so the
|
|
93
93
|
* members stay legible instead of collapsing.
|
|
94
94
|
*/
|
|
@@ -107,7 +107,7 @@ export type RouteResponse = MixedPromise<
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* A typed route module default export. `$dynamic.executeModule` calls it as
|
|
110
|
-
* `(req, body, Bakery.server)
|
|
110
|
+
* `(req, body, Bakery.server)`: the third argument is optional here because
|
|
111
111
|
* `Bakery.server` is itself optional before `Bun.serve` runs, and almost no
|
|
112
112
|
* handler wants it.
|
|
113
113
|
*
|
package/src/utils/common/case.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Case` moved to `utils/isomorphic/case
|
|
2
|
+
* `Case` moved to `utils/isomorphic/case`: the client bundle carried an
|
|
3
3
|
* identical copy. `toHash` stays here: it calls `Bun.hash`, so it is server-only
|
|
4
4
|
* and must not be pulled into the isomorphic layer.
|
|
5
5
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Moved to `utils/isomorphic/match` so the browser bundle and the server share
|
|
3
|
-
* one implementation
|
|
3
|
+
* one implementation: the prototype-chain fix (`Object.hasOwn` rather than
|
|
4
4
|
* `in`) previously had to be applied to both copies separately.
|
|
5
5
|
*/
|
|
6
6
|
export * from '../isomorphic/match'
|
package/src/utils/common/misc.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `is`, `Math2` and `throws` moved to `utils/isomorphic
|
|
2
|
+
* `is`, `Math2` and `throws` moved to `utils/isomorphic/`: the client bundle
|
|
3
3
|
* carried its own copies of all three. They are re-exported here so existing
|
|
4
4
|
* `@server/utils` importers are unaffected.
|
|
5
5
|
*
|
package/src/utils/constants.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const DEFAULT_RATE_LIMIT = { max: 100, refill: 10 }
|
|
|
12
12
|
* Files that must never be served off disk.
|
|
13
13
|
*
|
|
14
14
|
* These are matched against the file a *file-serving* handler is about to
|
|
15
|
-
* return, not against every request path
|
|
15
|
+
* return, not against every request path. See `servesFiles` in `router.ts`.
|
|
16
16
|
* An API route or a proxied path is a name, not a file, and never had any
|
|
17
17
|
* business being tested against this list.
|
|
18
18
|
*
|
|
@@ -22,8 +22,8 @@ export const DEFAULT_RATE_LIMIT = { max: 100, refill: 10 }
|
|
|
22
22
|
* on that attempt.
|
|
23
23
|
*
|
|
24
24
|
* Note what is *not* here: an extension-wide ban on JSON. It caught every
|
|
25
|
-
* JSON document an app might legitimately publish
|
|
26
|
-
* `.well-known` document
|
|
25
|
+
* JSON document an app might legitimately publish: a web app manifest, a
|
|
26
|
+
* `.well-known` document, and since `blocked` in config can only ever append,
|
|
27
27
|
* an app had no way to opt back out. What the list is actually protecting is
|
|
28
28
|
* the handful of JSON files that describe the project, so those are named.
|
|
29
29
|
*/
|
|
@@ -46,9 +46,9 @@ export const DEFAULT_BLOCKED_GLOBS = [
|
|
|
46
46
|
// trailing `**` collapses to a single segment: `{**/node_modules/**}` matches
|
|
47
47
|
// `/node_modules/a` and then stops, so `/node_modules/pkg/index.js` was not
|
|
48
48
|
// blocked by anything. Each pattern still matched on its own, which is why
|
|
49
|
-
// it survived
|
|
49
|
+
// it survived: the defect only exists in the form the config actually uses.
|
|
50
50
|
// `.cache` is the framework's disposable working dir and `bakery/` holds the
|
|
51
|
-
// database and its backups
|
|
51
|
+
// database and its backups: the visible one is the precious one, so that a
|
|
52
52
|
// dotfile sweep cannot reach the data. `bakery/` being un-dotted is why it
|
|
53
53
|
// needs naming here explicitly: `*.db` covers the database file itself, but
|
|
54
54
|
// not the `.ts` schema snapshots under `bakery/backups/`.
|
|
@@ -67,17 +67,17 @@ export const DEFAULT_BLOCKED_GLOBS = [
|
|
|
67
67
|
/**
|
|
68
68
|
* A request path folded to the form the *filesystem* would resolve it to.
|
|
69
69
|
*
|
|
70
|
-
* Two
|
|
70
|
+
* Two normalizations, both of them things the OS does and the glob does not:
|
|
71
71
|
*
|
|
72
72
|
* - **Case.** NTFS and APFS are case-insensitive, so `/PACKAGE.JSON` opens the
|
|
73
|
-
* same bytes as `/package.json
|
|
73
|
+
* same bytes as `/package.json`, but `Bun.Glob` is case-sensitive, so only
|
|
74
74
|
* the second one was blocked. Every file this list protects was reachable by
|
|
75
75
|
* holding down shift, and the static pipeline was verified to serve
|
|
76
76
|
* `/STYLES/GLOBAL.CSS` and `/styles/global.css` as the same 170 bytes.
|
|
77
77
|
* - **Trailing dots and spaces.** Win32 strips both from each path component,
|
|
78
78
|
* so `/package.json.` and `/package.json ` are the same file too.
|
|
79
79
|
*
|
|
80
|
-
* `.`, `..` and any all-dots component are left intact
|
|
80
|
+
* `.`, `..` and any all-dots component are left intact: trimming those would
|
|
81
81
|
* rewrite a relative segment into something else, and containment is
|
|
82
82
|
* `fs.isForbidden`'s job, not this function's.
|
|
83
83
|
*/
|
|
@@ -93,14 +93,14 @@ export function normalizeBlockedPath(path: string): string {
|
|
|
93
93
|
* Each path component truncated at its first `:`.
|
|
94
94
|
*
|
|
95
95
|
* Win32 resolves `x.ts::$DATA` to the bytes of `x.ts` and
|
|
96
|
-
* `dir::$INDEX_ALLOCATION` to `dir`
|
|
96
|
+
* `dir::$INDEX_ALLOCATION` to `dir` (NTFS alternate-data-stream syntax), and
|
|
97
97
|
* `new URL()` keeps the suffix in `pathname`, so a glob anchored on the real
|
|
98
98
|
* name missed while the filesystem opened the file anyway. The directory form
|
|
99
99
|
* matters independently: it defeats the `dir/**` patterns that the file form
|
|
100
100
|
* cannot reach.
|
|
101
101
|
*
|
|
102
102
|
* Only ever used as an *extra* match candidate (see `matchBlocked`), never as
|
|
103
|
-
* a replacement
|
|
103
|
+
* a replacement: colons are legal in POSIX filenames, and a real file named
|
|
104
104
|
* `a:b.db` must stay blocked by `*.db` rather than being tested as `a`.
|
|
105
105
|
*/
|
|
106
106
|
function stripStreamSuffix(path: string): string {
|
|
@@ -117,18 +117,18 @@ function stripStreamSuffix(path: string): string {
|
|
|
117
117
|
/**
|
|
118
118
|
* Whether `path` is refused by the blocked globs.
|
|
119
119
|
*
|
|
120
|
-
* Matched twice: once as written, once
|
|
121
|
-
* different reasons. The
|
|
120
|
+
* Matched twice: once as written, once normalized. Both are needed, and for
|
|
121
|
+
* different reasons. The normalized pass is what closes the case and
|
|
122
122
|
* trailing-dot variants above. The verbatim pass is what keeps an app's own
|
|
123
|
-
* `blocked: ['**\/Secrets.txt']` working
|
|
123
|
+
* `blocked: ['**\/Secrets.txt']` working, `initConfig` appends user patterns
|
|
124
124
|
* to this list untouched, so folding only the path would quietly stop matching
|
|
125
125
|
* the very patterns the app wrote.
|
|
126
126
|
*
|
|
127
127
|
* On a case-*sensitive* filesystem this over-blocks: a file genuinely named
|
|
128
128
|
* `/PACKAGE.JSON`, distinct from `/package.json`, is refused. That is the
|
|
129
|
-
* intended direction to be wrong in, and it is the reason this
|
|
129
|
+
* intended direction to be wrong in, and it is the reason this normalizes the
|
|
130
130
|
* path rather than resolving the file. The router runs this check before it
|
|
131
|
-
* knows which file
|
|
131
|
+
* knows which file (often before there is a file at all), so "ask the
|
|
132
132
|
* filesystem for the real name" is not available at the point the answer is
|
|
133
133
|
* needed, and would cost a `realpath` per request where it was.
|
|
134
134
|
*/
|
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.
|