@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.
Files changed (78) hide show
  1. package/README.md +8 -8
  2. package/package.json +2 -5
  3. package/src/cache/lru.ts +2 -2
  4. package/src/cache/shared-db.ts +81 -11
  5. package/src/cache/string.ts +4 -0
  6. package/src/cache/tiered.ts +11 -5
  7. package/src/client/globals.d.ts +7 -7
  8. package/src/client/livereload.ts +2 -2
  9. package/src/client/utils.ts +11 -6
  10. package/src/compiler/compiler.ts +48 -44
  11. package/src/compiler/dev-service.ts +29 -30
  12. package/src/compiler/prompt-tracker.ts +2 -2
  13. package/src/compiler/tsconfig-sync.ts +130 -59
  14. package/src/core/bakery.ts +6 -3
  15. package/src/core/cache-version.ts +68 -18
  16. package/src/core/config.ts +16 -16
  17. package/src/core/context.ts +54 -8
  18. package/src/core/define-route.ts +5 -6
  19. package/src/core/index.ts +29 -8
  20. package/src/core/init.ts +38 -35
  21. package/src/core/jsx.ts +19 -4
  22. package/src/core/paths.ts +3 -1
  23. package/src/core/plugins.ts +15 -4
  24. package/src/core/port.ts +6 -6
  25. package/src/global.d.ts +36 -18
  26. package/src/handlers/assets/google-font.ts +6 -6
  27. package/src/handlers/assets/image.ts +36 -3
  28. package/src/handlers/assets/nm.ts +7 -7
  29. package/src/handlers/assets/static.ts +55 -12
  30. package/src/handlers/assets/ts.ts +3 -3
  31. package/src/handlers/assets/tsx.ts +1 -1
  32. package/src/handlers/assets/virtual-asset.ts +1 -1
  33. package/src/handlers/core/$base.ts +24 -8
  34. package/src/handlers/core/$dynamic.ts +66 -23
  35. package/src/handlers/core/$error.ts +29 -17
  36. package/src/handlers/core/$middleware.ts +6 -6
  37. package/src/handlers/core/$mounts.ts +2 -2
  38. package/src/handlers/core/$registry.ts +16 -6
  39. package/src/handlers/core/$routing.ts +18 -19
  40. package/src/handlers/core/$static.ts +11 -12
  41. package/src/handlers/routes/api.ts +5 -5
  42. package/src/handlers/routes/html.ts +2 -2
  43. package/src/handlers/routes/proxy.ts +1 -1
  44. package/src/logger/clients.ts +2 -2
  45. package/src/logger/logger.ts +4 -4
  46. package/src/logger/serve-log.ts +32 -18
  47. package/src/plugins/index.ts +1 -1
  48. package/src/plugins/routes.ts +7 -7
  49. package/src/plugins/types.ts +13 -11
  50. package/src/router.ts +12 -12
  51. package/src/session.ts +41 -23
  52. package/src/shared.d.ts +5 -5
  53. package/src/startup.ts +3 -3
  54. package/src/types.d.ts +10 -10
  55. package/src/utils/common/case.ts +1 -1
  56. package/src/utils/common/match.ts +1 -1
  57. package/src/utils/common/misc.ts +1 -1
  58. package/src/utils/constants.ts +15 -15
  59. package/src/utils/fs.ts +97 -39
  60. package/src/utils/http/authorize.ts +24 -17
  61. package/src/utils/http/cors.ts +6 -6
  62. package/src/utils/http/credential.ts +4 -4
  63. package/src/utils/http/csrf.ts +6 -7
  64. package/src/utils/http/dom.ts +9 -9
  65. package/src/utils/http/etag.ts +54 -13
  66. package/src/utils/http/html.ts +29 -20
  67. package/src/utils/http/ip.ts +1 -1
  68. package/src/utils/http/sse.ts +11 -11
  69. package/src/utils/http/url.ts +4 -4
  70. package/src/utils/http/validate.ts +3 -4
  71. package/src/utils/isomorphic/escape.ts +1 -1
  72. package/src/utils/isomorphic/is.ts +2 -2
  73. package/src/utils/isomorphic/misc.ts +1 -1
  74. package/src/utils/isomorphic/stringify.ts +2 -3
  75. package/src/utils/isomorphic/try.ts +1 -1
  76. package/src/utils/shared-pool.ts +23 -16
  77. package/tsconfig.app.json +3 -3
  78. package/tsconfig.vue.json +3 -3
@@ -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 — one coerced its predicate's return with
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 — no logins, no sessions, no backoff. The shared
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` — and, worse, that `new URL(req.url).hostname`
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 — but only by
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 — the mode flags are accessors on `process.env` (`core/init.ts`), so
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` — which
81
- // under `bun test` is `true` — in place. In that state `!DEV` admits a
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 `!== false` rather than a plain truthiness test, which is the one
87
- // place a bare `PROD` gate would be *weaker* than `!DEV`. The flag does not
88
- // exist until `core/init.ts` has run, and `if (undefined)` falls straight
89
- // through to the loopback check — so an uninitialised process would open the
90
- // door that an initialised production one keeps shut. `init.ts` defines
91
- // `PROD` as a real boolean (`accessor(!isDev && !hasDevWorkerArg)`), so
92
- // "explicitly false" is a statement only a booted development server can
93
- // make. Unset is not evidence of development; it is no evidence at all.
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.PROD !== false) return false
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 — an untyped, transpiled or `as any` predicate can hand back
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
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Cross-origin resource sharing.
3
3
  *
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
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 honoured literally, and is refused in combination with
26
- * `credentials` — see `resolveOrigin`. A function receives the request's
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 honouring it would produce a request
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` — plain
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 — no logins, no sessions, no backoff.
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 — the caller is expected to strip it from the
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 — but it throws on length mismatch, so
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 — `db-key`, `analytics-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}`)
@@ -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 — browsers always send at least
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 — so
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 — deliberately stricter than
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 — curl, a test, a CLI, a health check. Such a client
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
@@ -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 — bounded so a client
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
- * Normalise one import-map entry, for both the process-level map and the
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 — one switched on the entry key,
30
- * the other on the entry value — so the same input normalised two ways
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 — a transitive one, or a dependency someone forgot to add — and the
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 — one `readdir` per scope, no `package.json` reads — and memoised per
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 — `exports` maps,
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 — its docstring even said "Mirrors utils/http/dom.ts". The two
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.
@@ -17,7 +17,7 @@ export namespace ETag {
17
17
  return `W/"${hash.toString(36)}"`
18
18
  }
19
19
 
20
- /** The one writer of the file-etag format — `fromFile` and the negotiation
20
+ /** The one writer of the file-etag format: `fromFile` and the negotiation
21
21
  * memo below must agree byte-for-byte, or a warm hit would break every
22
22
  * `If-None-Match` a cold response handed out. */
23
23
  function fileTag(size: number, mtime: number): string {
@@ -78,8 +78,8 @@ export namespace ETag {
78
78
 
79
79
  /**
80
80
  * Per-base-path record of which compressed siblings exist, anchored on the
81
- * base file's mtime. `getOrCreateCachedFile` — the only writer of these
82
- * files — writes the raw file and every variant together in one
81
+ * base file's mtime. `getOrCreateCachedFile` (the only writer of these
82
+ * files) writes the raw file and every variant together in one
83
83
  * `Promise.all`, so the base mtime changes whenever the variant set does;
84
84
  * that makes the mtime the invalidation signal, and no write-side hook is
85
85
  * needed (fs.ts stays unaware of this module, keeping the dependency arrow
@@ -92,7 +92,7 @@ export namespace ETag {
92
92
  *
93
93
  * Only *complete* variant sets are recorded. A partial set means either a
94
94
  * build caught mid-write (the trio lands over a few ms) or user-authored
95
- * precompressed siblings next to a served `.gz` — in both cases the set can
95
+ * precompressed siblings next to a served `.gz`: in both cases the set can
96
96
  * change without the base mtime moving, so those fall back to per-request
97
97
  * probing, which is exactly the pre-memo behavior.
98
98
  */
@@ -102,8 +102,7 @@ export namespace ETag {
102
102
  /**
103
103
  * One stat, and the values are kept rather than discarded: a `BunFile`
104
104
  * caches its stat on first property access (~66us fresh, ~40ns after), so
105
- * routing the probe through `fs.exists` — which stats a throwaway instance —
106
- * made every served variant pay a second, identical stat in `fromFile`.
105
+ * routing the probe through `fs.exists` (which stats a throwaway instance) * made every served variant pay a second, identical stat in `fromFile`.
107
106
  * Same existence predicate as `fs.exists`; note a missing file reports
108
107
  * size 0 and a far-future sentinel `lastModified`, never 0.
109
108
  */
@@ -119,7 +118,7 @@ export namespace ETag {
119
118
 
120
119
  /**
121
120
  * Test seam (convention 9): lets etag.test.ts count sibling probes without
122
- * module-mocking `bun` or `node:fs` — which is process-global and never
121
+ * module-mocking `bun` or `node:fs`, which is process-global and never
123
122
  * unwinds. Same shape as `fs.__setForbiddenProbe`.
124
123
  */
125
124
  export function __setVariantProbe(fn: (path: string) => VariantStat | null) {
@@ -136,7 +135,7 @@ export namespace ETag {
136
135
  negotiationMemo.clear()
137
136
  }
138
137
 
139
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: content negotiation — one branch per encoding outcome
138
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: content negotiation, one branch per encoding outcome
140
139
  function negotiateFile(file: Bun.BunFile, req?: Request) {
141
140
  const fileName = file.name || 'file'
142
141
 
@@ -174,7 +173,7 @@ export namespace ETag {
174
173
  )
175
174
 
176
175
  // The memo is consulted only for a base that exists right now, and only
177
- // when its recorded mtime matches the anchor exactly — a wiped cache
176
+ // when its recorded mtime matches the anchor exactly: a wiped cache
178
177
  // dir (missing base reports the far-future sentinel mtime) or any
179
178
  // rewrite falls through to a fresh probe.
180
179
  let entry = baseExists ? negotiationMemo.get(basePath) : undefined
@@ -231,13 +230,55 @@ export namespace ETag {
231
230
  if (conditionalRes) return conditionalRes
232
231
  }
233
232
 
233
+ // Range handling itself lives in Bun.serve, not here: any Response whose
234
+ // body is a *path-backed* BunFile is sliced by the runtime (206 with
235
+ // Content-Range on a satisfiable single range, 416 past EOF), including
236
+ // the negotiated compressed variants above (the range then addresses the
237
+ // encoded bytes, which is what RFC 9110 says a range means). What the
238
+ // runtime does not do is advertise: a plain 200 or a HEAD said nothing,
239
+ // so players that probe HEAD for `Accept-Ranges` before attempting seeks
240
+ // never tried. Advertised here because this is the one funnel every
241
+ // file-serving handler's BunFile passes through, and only here: an
242
+ // in-memory Blob or a `sendText` string body ignores Range entirely
243
+ // (served whole, 200), which is why `.name` gates the claim.
244
+ //
245
+ // Skipped when Bun's own range path is about to answer (a GET carrying a
246
+ // single-range `Range`): that path appends its own `Accept-Ranges: bytes`
247
+ // to the 206/416, and setting it here too emitted `bytes, bytes`.
248
+ //
249
+ // A **multipart** range (`bytes=0-1,10-11`) is carved back out of the
250
+ // skip: Bun does not do multipart (it serves the whole file as a 200 and
251
+ // appends nothing), so the skip left that response advertising nothing at
252
+ // all, which reads as "ranges not supported" to the one client that just
253
+ // demonstrated it wants them. Found by a downstream smoke test. The comma
254
+ // is the entire multipart grammar, so this cannot drift from Bun's own
255
+ // parse the way a real Range parser here could; RFC-legal multipart gets
256
+ // the 200-with-advertisement it deserves. What stays excluded-and-silent
257
+ // is a *malformed* single range (`Range: potato`), also served whole by
258
+ // Bun, but that requester sent garbage, and mirroring Bun's full validity
259
+ // judgment here is exactly the second-parser drift this comment refuses.
260
+ // HEAD ignores `Range` and gets the header even when one is present.
261
+ const rangeValue = req?.headers.get('range')
262
+ if (
263
+ resolvedFile.name &&
264
+ !(
265
+ req &&
266
+ req.method === 'GET' &&
267
+ rangeValue !== null &&
268
+ rangeValue !== undefined &&
269
+ !rangeValue.includes(',')
270
+ )
271
+ ) {
272
+ headers['Accept-Ranges'] = 'bytes'
273
+ }
274
+
234
275
  return new Response(resolvedFile, { headers })
235
276
  }
236
277
 
237
278
  /**
238
- * Returns synchronously on every path that does not compress — no request,
279
+ * Returns synchronously on every path that does not compress: no request,
239
280
  * small body, unsupported Accept-Encoding, and crucially the 304
240
- * short-circuit — and returns a Promise only when compression actually
281
+ * short-circuit, and returns a Promise only when compression actually
241
282
  * runs on a body large enough to offload (`ASYNC_COMPRESSION_MIN`). The
242
283
  * one call site that can receive either (`processResponse`) awaits.
243
284
  */
@@ -265,7 +306,7 @@ export namespace ETag {
265
306
  'Content-Type': type,
266
307
  }
267
308
 
268
- // Negotiation only — no compression yet. The ext suffix the etag embeds
309
+ // Negotiation only: no compression yet. The ext suffix the etag embeds
269
310
  // is decided by the Accept-Encoding header alone, so the 304 check can
270
311
  // run first: a client that already holds the body must not cost a
271
312
  // zstd pass whose output is then thrown away.
@@ -302,7 +343,7 @@ export namespace ETag {
302
343
 
303
344
  // Off the request thread for large bodies: the sync pass measured
304
345
  // ~700us of event-loop stall per 135KB response. Below the cutoff the
305
- // pool round-trip costs more than the stall it removes — numbers on
346
+ // pool round-trip costs more than the stall it removes: numbers on
306
347
  // `ASYNC_COMPRESSION_MIN` in fs.ts.
307
348
  if (encoderAsync && text.length >= ASYNC_COMPRESSION_MIN) {
308
349
  return encoderAsync(text).then(
@@ -4,7 +4,16 @@ import { Try } from '../common/try'
4
4
  import { DOMTools, headBodyCache } from './dom'
5
5
  import { ETag } from './etag'
6
6
 
7
- function injectBrand(res: Response) {
7
+ /**
8
+ * Mark `res` as already carrying the head/body injects, so `injectIfHtml` ( * and therefore `processResponse`, which every response funnels through) * hands it back untouched.
9
+ *
10
+ * Exported for the one caller that means "never", not "already":
11
+ * `DefaultErrorHandler` brands its production error page so the import map is
12
+ * not spliced into it. That map names every installed package (an inventory
13
+ * of the app's module layout), and the page carries no scripts for it (or the
14
+ * client bundle) to serve anyway.
15
+ */
16
+ export function injectBrand(res: Response) {
8
17
  return Object.defineProperty(res, '__injected__', {
9
18
  value: true,
10
19
  enumerable: false,
@@ -19,7 +28,7 @@ function isInjected(res: Response) {
19
28
  * Rebuild `res` with `status`, carrying the injection brand across.
20
29
  *
21
30
  * `Response.status` is read-only, so changing it means constructing a new
22
- * Response — and a plain rebuild silently drops the `__injected__` marker,
31
+ * Response, and a plain rebuild silently drops the `__injected__` marker,
23
32
  * which is the only thing stopping `processResponse` running the page through
24
33
  * `injectIfHtml` a second time and emitting two import maps and two copies of
25
34
  * the client bundle. That is why this lives next to the brand rather than at
@@ -40,7 +49,7 @@ export function withStatus(res: Response, status: number): Response {
40
49
  /**
41
50
  * Server-controlled markup injected into the document. Deliberately a separate
42
51
  * argument from `params`: `params` is request-derived (for GET it is the query
43
- * string), so `$$head`-style keys arriving from a client must never be honoured.
52
+ * string), so `$$head`-style keys arriving from a client must never be honored.
44
53
  */
45
54
  export interface HtmlInjects {
46
55
  head?: string
@@ -73,7 +82,7 @@ export interface HtmlInjects {
73
82
  * have sent
74
83
  *
75
84
  * Buffered responses carry a strong content ETag for the 304 machinery.
76
- * Streamed responses carry none — see `streamedResponse` for why that is not
85
+ * Streamed responses carry none. See `streamedResponse` for why that is not
77
86
  * faked.
78
87
  */
79
88
  export const STREAM_THRESHOLD_BYTES = 64 * 1024
@@ -85,7 +94,7 @@ export async function injectIfHtml(
85
94
  ): Promise<Response | null> {
86
95
  if (data instanceof Response && isInjected(data)) return data
87
96
 
88
- // {{param}} substitution (and only that — the __PAGE_PARAMS__ script is a
97
+ // {{param}} substitution (and only that: the __PAGE_PARAMS__ script is a
89
98
  // head fragment either way) needs the whole document in one string, so a
90
99
  // params-bearing call takes the buffered path regardless of size.
91
100
  const hasParams = !!params && Object.keys(params).length > 0
@@ -123,7 +132,7 @@ export async function injectIfHtml(
123
132
  return bufferedResponse(content, params, injects, responseInit)
124
133
  }
125
134
 
126
- /** The one writer of the buffered injection Response — main path and the
135
+ /** The one writer of the buffered injection Response: main path and the
127
136
  * streamed path's fallback both come through here. */
128
137
  function bufferedResponse(
129
138
  content: string,
@@ -178,7 +187,7 @@ function getConfigInjects() {
178
187
  /**
179
188
  * The final head/body fragments for one document: config injects + the
180
189
  * __PAGE_PARAMS__ script + per-call injects, with `<!--prio-->` resolved.
181
- * Computed once per response, up front — the streamed path must not defer this
190
+ * Computed once per response, up front: the streamed path must not defer this
182
191
  * into a pull callback, where the AsyncLocalStorage host context that
183
192
  * `getConfigInjects` and `DOMTools.importMap` read may no longer be live.
184
193
  */
@@ -227,7 +236,7 @@ function spliceBodyEnd(html: string, body: string): string {
227
236
  * each window and the final carry. */
228
237
  function rewriteFontsUrls(html: string): string {
229
238
  // Both alternatives in RX_GFONTS require the literal `fonts.goog`, so this
230
- // substring check cannot skip a document the regex would have matched — and
239
+ // substring check cannot skip a document the regex would have matched, and
231
240
  // most documents have no Google Fonts link at all.
232
241
  if (!html.includes('fonts.goog')) return html
233
242
  return html.replace(RX_GFONTS, () => GFONTS_REWRITE)
@@ -261,8 +270,8 @@ export function assembleHtml(
261
270
  // The exact same three rewrites as `assembleHtml`, in the same order, without
262
271
  // holding the document in memory. The head splice happens on a bounded probe
263
272
  // (the tag is near the start of any real document) using the same
264
- // `spliceHead`; the body-end insert and the fonts rewrite — the two passes
265
- // that force a scan to the end of the document — run as chunk-boundary-safe
273
+ // `spliceHead`; the body-end insert and the fonts rewrite (the two passes
274
+ // that force a scan to the end of the document) run as chunk-boundary-safe
266
275
  // text stages over the same regexes. Content inserted by the body stage flows
267
276
  // through the fonts stage but not back through the body stage, which is
268
277
  // exactly the buffered path's head → body → fonts ordering.
@@ -272,7 +281,7 @@ export function assembleHtml(
272
281
  * that can be left dangling at a chunk boundary. */
273
282
  const BODY_END_HOLDBACK = '</body>'.length - 1
274
283
 
275
- /** Longest text RX_GFONTS can match, minus one — any incomplete match prefix
284
+ /** Longest text RX_GFONTS can match, minus one: any incomplete match prefix
276
285
  * at a window edge is at most this long, so holding back this many characters
277
286
  * guarantees no match is ever split across two windows. */
278
287
  const FONTS_HOLDBACK = 'https://fonts.googleapis.com/css2'.length - 1
@@ -281,7 +290,7 @@ const FONTS_HOLDBACK = 'https://fonts.googleapis.com/css2'.length - 1
281
290
  * Read the response body until either EOF or enough bytes to commit to
282
291
  * streaming. Streaming needs two things the probe establishes: proof the body
283
292
  * is actually large (unless Content-Length already said so), and a `<head…>`
284
- * match — the no-head fallback prepends to the very start of the document,
293
+ * match, the no-head fallback prepends to the very start of the document,
285
294
  * which a stream that has begun emitting can no longer do. Anything that fails
286
295
  * those checks is drained and handed to the buffered path, whose output is the
287
296
  * long-standing behavior.
@@ -367,7 +376,7 @@ async function* tailText(
367
376
  }
368
377
  } finally {
369
378
  // Reached on normal EOF (no-op) and when the consumer cancels the response
370
- // mid-stream — in which case the source must be cancelled too.
379
+ // mid-stream: in which case the source must be canceled too.
371
380
  await Try(() => reader.cancel())
372
381
  }
373
382
  }
@@ -382,9 +391,9 @@ async function* withPrefix(
382
391
 
383
392
  /**
384
393
  * Streaming equivalent of `spliceBodyEnd`: insert before the first literal
385
- * `</body>` (in original casing — the insert slices around the match rather
394
+ * `</body>` (in original casing, the insert slices around the match rather
386
395
  * than reconstructing it), else append at the very end. Once inserted the
387
- * stage is a passthrough, so content it inserted is never rescanned — matching
396
+ * stage is a passthrough, so content it inserted is never rescanned: matching
388
397
  * the buffered path's single non-global replace.
389
398
  */
390
399
  async function* bodyEndStage(
@@ -424,7 +433,7 @@ async function* bodyEndStage(
424
433
  /**
425
434
  * Streaming equivalent of `rewriteFontsUrls`. Complete matches that start in
426
435
  * the emittable region are rewritten (a complete match cannot be invalidated
427
- * or extended by later input — the pattern has no unbounded or trailing
436
+ * or extended by later input: the pattern has no unbounded or trailing
428
437
  * parts); matches or match prefixes inside the holdback stay in the carry for
429
438
  * the next window, and the final carry goes through the shared
430
439
  * `rewriteFontsUrls` itself.
@@ -452,7 +461,7 @@ async function* fontsStage(
452
461
 
453
462
  // The same fast gate the buffered path uses: no `fonts.goog` in the
454
463
  // window means no match in it, and the emit/carry split below does not
455
- // depend on match presence — a match *prefix* dangling at the window edge
464
+ // depend on match presence, a match *prefix* dangling at the window edge
456
465
  // sits inside the holdback either way.
457
466
  if (window.includes('fonts.goog')) {
458
467
  rx.lastIndex = 0
@@ -477,8 +486,8 @@ async function* fontsStage(
477
486
  /**
478
487
  * Wrap the staged text into a streaming Response.
479
488
  *
480
- * Deliberately no ETag: a content hash would require buffering the body — the
481
- * thing this path exists to avoid — and deriving one from anything less (say,
489
+ * Deliberately no ETag: a content hash would require buffering the body (the
490
+ * thing this path exists to avoid), and deriving one from anything less (say,
482
491
  * source file size+mtime) would keep serving 304s after the config-injected
483
492
  * head/body fragments change. Large streamed pages are also where conditional
484
493
  * revalidation matters least. Callers relying on the 304 machinery get it on
@@ -494,7 +503,7 @@ function streamedResponse(
494
503
  // Buffered responses get `Cache-Control: no-cache` from ETag.sendResponse
495
504
  // (guarded the same way: only when the handler set nothing itself). With no
496
505
  // ETag that hook never fires here, and a validator-less page left without a
497
- // cache policy is open to heuristic caching — a stale page, not just a
506
+ // cache policy is open to heuristic caching: a stale page, not just a
498
507
  // missed 304.
499
508
  if (!headers.has('Cache-Control')) {
500
509
  headers.set('Cache-Control', 'no-cache')
@@ -15,7 +15,7 @@ const TRUSTED_HEADERS = [
15
15
  export function getClientIp(req: Request): string {
16
16
  // `Bakery.config`, not `getConfig()`: this runs inside the request's host
17
17
  // store, and the process config is the wrong answer under `hosts`. It was
18
- // the only reader of the two that disagreed — `getHostname` and the
18
+ // the only reader of the two that disagreed: `getHostname` and the
19
19
  // session's `Secure` flag both read the host's `trustProxy`, so one host
20
20
  // could have its hostname and cookie resolved from its own config while
21
21
  // its client IP was resolved from the base one.