@bakery-framework/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +89 -0
  3. package/package.json +69 -0
  4. package/src/cache/index.ts +8 -0
  5. package/src/cache/lru.ts +41 -0
  6. package/src/cache/shared-db.ts +51 -0
  7. package/src/cache/string.ts +150 -0
  8. package/src/cache/tiered.ts +493 -0
  9. package/src/client/globals.d.ts +74 -0
  10. package/src/client/livereload.ts +437 -0
  11. package/src/client/utils.ts +315 -0
  12. package/src/compiler/compiler.ts +263 -0
  13. package/src/compiler/dev-service.ts +660 -0
  14. package/src/compiler/index.ts +2 -0
  15. package/src/compiler/prompt-tracker.ts +36 -0
  16. package/src/compiler/tsconfig-sync.ts +71 -0
  17. package/src/core/bakery.ts +96 -0
  18. package/src/core/cache-version.ts +119 -0
  19. package/src/core/config.ts +296 -0
  20. package/src/core/context.ts +121 -0
  21. package/src/core/index.ts +61 -0
  22. package/src/core/init.ts +90 -0
  23. package/src/core/jsx.ts +152 -0
  24. package/src/core/paths.ts +24 -0
  25. package/src/core/plugins.ts +120 -0
  26. package/src/core/port.ts +73 -0
  27. package/src/global.d.ts +374 -0
  28. package/src/handlers/assets/google-font.ts +225 -0
  29. package/src/handlers/assets/image.ts +136 -0
  30. package/src/handlers/assets/nm.ts +73 -0
  31. package/src/handlers/assets/public.ts +17 -0
  32. package/src/handlers/assets/static.ts +86 -0
  33. package/src/handlers/assets/ts.ts +61 -0
  34. package/src/handlers/assets/tsx.ts +106 -0
  35. package/src/handlers/assets/virtual-asset.ts +104 -0
  36. package/src/handlers/core/$base.ts +256 -0
  37. package/src/handlers/core/$dynamic.ts +285 -0
  38. package/src/handlers/core/$error.ts +301 -0
  39. package/src/handlers/core/$middleware.ts +71 -0
  40. package/src/handlers/core/$mounts.ts +84 -0
  41. package/src/handlers/core/$registry.ts +153 -0
  42. package/src/handlers/core/$routing.ts +205 -0
  43. package/src/handlers/core/$static.ts +100 -0
  44. package/src/handlers/core/$websocket.ts +52 -0
  45. package/src/handlers/index.ts +21 -0
  46. package/src/handlers/routes/api.ts +95 -0
  47. package/src/handlers/routes/html.ts +95 -0
  48. package/src/handlers/routes/livereload.ts +54 -0
  49. package/src/handlers/routes/proxy.ts +74 -0
  50. package/src/logger/clients.ts +12 -0
  51. package/src/logger/index.ts +3 -0
  52. package/src/logger/logger.ts +375 -0
  53. package/src/logger/serve-log.ts +206 -0
  54. package/src/plugins/index.ts +15 -0
  55. package/src/plugins/routes.ts +110 -0
  56. package/src/plugins/types.ts +19 -0
  57. package/src/router.ts +351 -0
  58. package/src/session.ts +556 -0
  59. package/src/shared.d.ts +63 -0
  60. package/src/startup.ts +154 -0
  61. package/src/types.d.ts +111 -0
  62. package/src/utils/common/case.ts +11 -0
  63. package/src/utils/common/index.ts +5 -0
  64. package/src/utils/common/json.ts +35 -0
  65. package/src/utils/common/match.ts +6 -0
  66. package/src/utils/common/misc.ts +53 -0
  67. package/src/utils/common/try.ts +6 -0
  68. package/src/utils/constants.ts +153 -0
  69. package/src/utils/fs.ts +621 -0
  70. package/src/utils/http/body.ts +65 -0
  71. package/src/utils/http/csrf.ts +111 -0
  72. package/src/utils/http/dom.ts +238 -0
  73. package/src/utils/http/escape.ts +8 -0
  74. package/src/utils/http/etag.ts +318 -0
  75. package/src/utils/http/html.ts +525 -0
  76. package/src/utils/http/index.ts +8 -0
  77. package/src/utils/http/ip.ts +32 -0
  78. package/src/utils/http/response.ts +129 -0
  79. package/src/utils/index.ts +4 -0
  80. package/src/utils/isomorphic/case.ts +52 -0
  81. package/src/utils/isomorphic/escape.ts +43 -0
  82. package/src/utils/isomorphic/index.ts +15 -0
  83. package/src/utils/isomorphic/is.ts +36 -0
  84. package/src/utils/isomorphic/match.ts +50 -0
  85. package/src/utils/isomorphic/math.ts +11 -0
  86. package/src/utils/isomorphic/misc.ts +22 -0
  87. package/src/utils/isomorphic/stringify.ts +42 -0
  88. package/src/utils/isomorphic/try.ts +94 -0
  89. package/src/utils/jsonc.ts +10 -0
  90. package/src/utils/shared-pool.ts +193 -0
  91. package/tsconfig.app.json +34 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Cross-site request forgery guards.
3
+ *
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 —
6
+ * `<a href="/api/admin/delete?id=5">` or a cross-origin `<form>` POST both
7
+ * arrive with the victim's session attached.
8
+ */
9
+
10
+ import { Try } from '../isomorphic/try'
11
+
12
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
13
+
14
+ /** `Sec-Fetch-Site` values that mean the request did not come from another site. */
15
+ const SAME_SITE_VALUES = new Set(['same-origin', 'same-site', 'none'])
16
+
17
+ /**
18
+ * Returns a reason string when the request looks cross-site, or `null` when it
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
21
+ * one of them on a cross-origin request.
22
+ */
23
+ export function checkSameOrigin(req: Request, url: URL): string | null {
24
+ const fetchSite = req.headers.get('sec-fetch-site')
25
+ if (fetchSite && !SAME_SITE_VALUES.has(fetchSite)) {
26
+ return `cross-site request rejected (Sec-Fetch-Site: ${fetchSite})`
27
+ }
28
+
29
+ const origin = req.headers.get('origin')
30
+ if (origin && origin !== 'null' && origin !== url.origin) {
31
+ return `cross-origin request rejected (Origin: ${origin})`
32
+ }
33
+
34
+ return null
35
+ }
36
+
37
+ /**
38
+ * Guard for endpoints that may change state. Safe methods pass through; unsafe
39
+ * ones must be same-origin.
40
+ */
41
+ export function checkCsrf(req: Request, url: URL): string | null {
42
+ if (SAFE_METHODS.has(req.method)) return null
43
+ return checkSameOrigin(req, url)
44
+ }
45
+
46
+ /**
47
+ * An attacker-supplied header value, made safe to put in a reason string.
48
+ *
49
+ * Reason strings are logged, and the logger renders one record per line — so
50
+ * an `Origin` carrying a newline could forge additional log entries. Anything
51
+ * outside printable ASCII goes, and the result is capped: a rejection reason
52
+ * is a diagnostic, not a transcript.
53
+ */
54
+ function safeEcho(value: string): string {
55
+ const clean = value.replace(/[^\x20-\x7e]/g, '')
56
+ return clean.length > 128 ? `${clean.slice(0, 128)}...` : clean
57
+ }
58
+
59
+ /**
60
+ * Origin guard for WebSocket upgrades — deliberately stricter than
61
+ * `checkSameOrigin`, and applied in `upgradeWebsocket` so every socket
62
+ * inherits it rather than each handler having to remember.
63
+ *
64
+ * WebSockets are exempt from the same-origin policy. Any page the developer
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
68
+ * ability to reload every open browser. The only thing that separates the
69
+ * app's own page from an attacker's is `Origin`, which the browser sets on
70
+ * every handshake and page script cannot override.
71
+ *
72
+ * Three deliberate differences from `checkSameOrigin`:
73
+ *
74
+ * - **`Origin: null` is rejected.** That is what a sandboxed iframe, a `data:`
75
+ * URL and a `file://` page send, so treating it as "no origin" would hand
76
+ * the socket straight back to `<iframe sandbox srcdoc="...">` on the
77
+ * attacker's page. It falls out of the parse below: `new URL('null')`
78
+ * throws, and an unparseable origin fails closed.
79
+ * - **`Sec-Fetch-Site` is not consulted.** Browsers do not send it on the
80
+ * handshake, so a value that *is* present came from a non-browser client
81
+ * and is worth nothing.
82
+ * - **Hostnames are compared, not full origins.** A TLS-terminating reverse
83
+ * proxy leaves the request looking like `http://app.example:3000` while the
84
+ * browser reports `https://app.example`; comparing origins would refuse
85
+ * every socket in that (very ordinary) deployment. The residual is an
86
+ * attacker who already controls another port or scheme on the *same*
87
+ * hostname, which is a different and much smaller threat than "any website
88
+ * the developer visits".
89
+ *
90
+ * An **absent** `Origin` is allowed, and that is a decision rather than an
91
+ * 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
93
+ * can also set `Origin` to whatever it likes, so refusing the absent case buys
94
+ * no security at all while breaking every non-browser client. This check is
95
+ * worth exactly what a browser's inability to forge the header is worth, and
96
+ * handlers that need more (`/_analytics_ws` is the in-repo example) still
97
+ * authenticate in their own `canHandle`.
98
+ */
99
+ export function checkWebSocketOrigin(req: Request, url: URL): string | null {
100
+ const origin = req.headers.get('origin')
101
+ if (!origin) return null
102
+
103
+ const rejected = `cross-origin WebSocket upgrade rejected (Origin: ${safeEcho(origin)})`
104
+
105
+ const parsed = Try(() => new URL(origin))
106
+ if (!parsed) return rejected
107
+
108
+ return parsed.hostname.toLowerCase() === url.hostname.toLowerCase()
109
+ ? null
110
+ : rejected
111
+ }
@@ -0,0 +1,238 @@
1
+ import { LRUCache } from '../../cache/lru'
2
+ import { Bakery, hostStore } from '../../core/bakery'
3
+ import type { MapOf } from '../../types'
4
+ import { is } from '../common/misc'
5
+ import { Try } from '../common/try'
6
+ import { fs } from '../fs'
7
+ import { escapeScriptJson } from '../isomorphic/escape'
8
+
9
+ // Keyed by hostname, which comes from the Host header — bounded so a client
10
+ // cannot grow it without limit by varying that header per request.
11
+ const headBodyCache = new LRUCache<string, { head: string; body: string }>(64)
12
+
13
+ export function clearHeadBodyCache() {
14
+ headBodyCache.clear()
15
+ }
16
+
17
+ export { headBodyCache }
18
+
19
+ type PackageJson = {
20
+ name: string
21
+ version: string
22
+ main?: string
23
+ module?: string
24
+ browser?: string | MapOf<string>
25
+ dependencies?: MapOf<string>
26
+ devDependencies?: MapOf<string>
27
+ }
28
+
29
+ let depMap = ''
30
+
31
+ const hostDepMaps = new Map<string, string>()
32
+
33
+ /**
34
+ * Normalise one import-map entry, for both the process-level map and the
35
+ * per-host maps.
36
+ *
37
+ * There used to be two copies of this, and they had drifted into disagreeing
38
+ * about *what* they tested: `initHostImportMaps` switched on the entry key,
39
+ * `initImportMap` tested the entry value. `{ foo: './.server/client/utils' }`
40
+ * was rewritten by one path and silently left alone by the other. One helper,
41
+ * two callers, so they cannot disagree again.
42
+ *
43
+ * The `.server/client/utils` special cases both copies carried are gone rather
44
+ * than reconciled. `.server/` is the pre-split layout: those two string
45
+ * literals were the last references to it anywhere in `packages`, `apps`,
46
+ * `docs` or `tests`, and the directory they name no longer exists, so a value
47
+ * pointing at it is a broken path either way. It normalises as an ordinary
48
+ * relative specifier now.
49
+ *
50
+ * `@client/utils` stays, and stays keyed on the *key*: it is a live alias — it
51
+ * is the default `importMap` entry in `core/config.ts` — and the browser
52
+ * runtime is served from a fixed URL, so the target is not the app's to choose.
53
+ */
54
+ function normalizeImportEntry(key: string, value: unknown): [string, string] {
55
+ const cleanKey = key.replace(/\*$/, '')
56
+ const cleanVal = String(value).replace(/\*$/, '')
57
+
58
+ if (cleanKey === '@client/utils') return [cleanKey, '/_client/utils.js']
59
+
60
+ return [
61
+ cleanKey,
62
+ cleanVal.replace(/^\.(?=\/)/, '').replace(/^(?!(?:\/|https?:\/\/))/, '/'),
63
+ ]
64
+ }
65
+
66
+ export function initHostImportMaps() {
67
+ hostDepMaps.clear()
68
+ clearHeadBodyCache()
69
+ const hosts = Bakery.config.hosts
70
+ if (!hosts || !Object.keys(hosts).length) return
71
+
72
+ const base = JSON.parse(depMap)
73
+ const npmImports = base.imports || {}
74
+
75
+ for (const [hostname, entry] of Object.entries(hosts)) {
76
+ if (!entry.importMap) continue
77
+
78
+ const imports: Record<string, string> = { ...npmImports }
79
+ for (const [k, v] of Object.entries(entry.importMap)) {
80
+ const [key, value] = normalizeImportEntry(k, v)
81
+ imports[key] = value
82
+ }
83
+
84
+ hostDepMaps.set(hostname, JSON.stringify({ imports }))
85
+ }
86
+ }
87
+
88
+ function resolveDepModule(pkgData: PackageJson, baseMod: string): string {
89
+ if (is.string(pkgData.browser)) return pkgData.browser as string
90
+
91
+ if (is.object(pkgData.browser)) {
92
+ const cleanBase = baseMod.replace(/^\.\//, '')
93
+ const browserField = pkgData.browser as MapOf<string>
94
+ const lookupKeys = [baseMod, `./${cleanBase}`, cleanBase]
95
+ const matchedOverride = lookupKeys.find(key => browserField[key])
96
+
97
+ return matchedOverride ? browserField[matchedOverride] : baseMod
98
+ }
99
+
100
+ return baseMod
101
+ }
102
+
103
+ export async function initImportMap() {
104
+ const pkgContent = await Try(() =>
105
+ Bun.file(fs.resolve(fs.cwd, 'package.json')).json(),
106
+ )
107
+ const pkg: any = pkgContent || {}
108
+ const map = Bakery.config.importMap || {}
109
+ const deps = pkg.dependencies || {}
110
+
111
+ const resolvedMap: MapOf<string> = {}
112
+
113
+ const imports = await Promise.all(
114
+ Object.keys(deps).map(async dep => {
115
+ const pkgData = await Try(
116
+ Bun.file(`./node_modules/${dep}/package.json`).json(),
117
+ )
118
+
119
+ return { dep, pkgData: pkgData as PackageJson | null }
120
+ }),
121
+ )
122
+
123
+ for (const { dep, pkgData } of imports) {
124
+ if (!pkgData) continue
125
+
126
+ const actualName = pkgData.name || dep
127
+ resolvedMap[`${actualName}/`] = `/_nm/${actualName}/`
128
+
129
+ const baseMod = pkgData.module || pkgData.main || 'index.js'
130
+ const mod = resolveDepModule(pkgData, baseMod)
131
+
132
+ const finalMod = typeof mod === 'string' ? mod : 'index.js'
133
+ resolvedMap[actualName] =
134
+ `/_nm/${actualName}/${finalMod.replace(/^\.\//, '')}`
135
+ }
136
+
137
+ for (const [k, v] of Object.entries(map)) {
138
+ const [key, value] = normalizeImportEntry(k, v)
139
+ resolvedMap[key] = value
140
+ }
141
+
142
+ depMap = JSON.stringify({ imports: resolvedMap })
143
+ }
144
+
145
+ export namespace DOMTools {
146
+ export function importMap() {
147
+ const hostname = hostStore.getStore()?.hostname
148
+ const map = (hostname && hostDepMaps.get(hostname)) || depMap
149
+ return `<script type="importmap">${map}</script>`
150
+ }
151
+
152
+ export function params(params: MapOf<string>) {
153
+ const newParams: MapOf<string> = {}
154
+
155
+ for (const [k, v] of Object.entries(params)) {
156
+ if (k.startsWith('$$')) continue
157
+ newParams[k] = v
158
+ }
159
+
160
+ // Was a hand-rolled `JSON.stringify(...).replace(/</g, ...)`, which is the
161
+ // same job `escapeScriptJson` already does for every other inline-script
162
+ // payload — its docstring even said "Mirrors utils/http/dom.ts". The two
163
+ // had drifted: this copy never escaped U+2028/U+2029.
164
+ return `<script>window.__PAGE_PARAMS__ = ${escapeScriptJson(newParams)}</script>`
165
+ }
166
+
167
+ type HTMLContent = {
168
+ content: string
169
+ responseInit: ResponseInit & { headers?: any }
170
+ }
171
+
172
+ const RX_IS_HTML = /<[a-z/][\s\S]*>/i
173
+ const RX_IS_SVG_XML = /^\s*<(\?xml|svg|math)/i
174
+
175
+ /** Content types `injectIfHtml` treats as an HTML document. */
176
+ export function isHTMLContentType(contentType: string): boolean {
177
+ return (
178
+ contentType.startsWith('text/html') ||
179
+ contentType.startsWith('application/xhtml+xml')
180
+ )
181
+ }
182
+
183
+ /**
184
+ * ResponseInit carrying `data`'s status and headers, minus Content-Length —
185
+ * injection changes the body length, so a stale value must not survive into
186
+ * the rebuilt Response. Shared by the buffered and streamed injection paths
187
+ * so the two cannot drift on which headers survive.
188
+ */
189
+ export function htmlResponseInit(
190
+ data: Response,
191
+ ): ResponseInit & { headers: Headers } {
192
+ const headers = new Headers(data.headers)
193
+ headers.delete('content-length')
194
+ return { status: data.status, statusText: data.statusText, headers }
195
+ }
196
+
197
+ async function checkBlobHtml(data: Blob): Promise<string> {
198
+ const type = data.type || ''
199
+ const isHtml = isHTMLContentType(type)
200
+
201
+ return isHtml ? await data.text() : ''
202
+ }
203
+
204
+ async function checkResponseHtml(
205
+ data: Response,
206
+ ): Promise<{ html: string; init: ResponseInit }> {
207
+ const contentType = data.headers.get('content-type') || ''
208
+
209
+ if (!isHTMLContentType(contentType)) return { html: '', init: {} }
210
+
211
+ return {
212
+ html: await data.text(),
213
+ init: htmlResponseInit(data),
214
+ }
215
+ }
216
+
217
+ export async function isHTML(
218
+ data: string | Response | Blob,
219
+ ): Promise<HTMLContent> {
220
+ if (is.string(data)) {
221
+ const sample = (data as string).slice(0, 512)
222
+ const isHtml = RX_IS_HTML.test(sample) && !RX_IS_SVG_XML.test(sample)
223
+ return { content: isHtml ? (data as string) : '', responseInit: {} }
224
+ }
225
+
226
+ if (data instanceof Blob) {
227
+ const html = await checkBlobHtml(data as Blob)
228
+ return { content: html, responseInit: {} }
229
+ }
230
+
231
+ if (data instanceof Response) {
232
+ const res = await checkResponseHtml(data as Response)
233
+ return { content: res.html, responseInit: res.init }
234
+ }
235
+
236
+ return { content: '', responseInit: {} }
237
+ }
238
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Server-facing path for the escaping primitives.
3
+ *
4
+ * The implementation lives in `utils/isomorphic/escape` because the browser
5
+ * bundle needs it too; server code imports it from here (or from `@server/utils`)
6
+ * so callers don't have to know which side of that boundary it sits on.
7
+ */
8
+ export { escapeHtml, escapeScriptJson } from '../isomorphic/escape'
@@ -0,0 +1,318 @@
1
+ import { LRUCache } from '../../cache/lru'
2
+ import type { MapOf, MixedPromise } from '../../types'
3
+ import { ASYNC_COMPRESSION_MIN, COMPRESSION_MAP, fs } from '../fs'
4
+
5
+ export namespace ETag {
6
+ function tag(response: Response): Response {
7
+ ;(response as any).__notModified__ = true
8
+ return response
9
+ }
10
+
11
+ export function isNotModified(response: Response): boolean {
12
+ return !!(response as any).__notModified__
13
+ }
14
+
15
+ export function fromText(content: string | Uint8Array): string {
16
+ const hash = Bun.hash(content)
17
+ return `W/"${hash.toString(36)}"`
18
+ }
19
+
20
+ /** The one writer of the file-etag format — `fromFile` and the negotiation
21
+ * memo below must agree byte-for-byte, or a warm hit would break every
22
+ * `If-None-Match` a cold response handed out. */
23
+ function fileTag(size: number, mtime: number): string {
24
+ return `W/"${size.toString(36)}-${(mtime || 0).toString(36)}"`
25
+ }
26
+
27
+ export function fromFile(file: Bun.BunFile): string {
28
+ return fileTag(file.size, file.lastModified)
29
+ }
30
+
31
+ export function check(req: Request, etag: string): Response | null {
32
+ const ifNoneMatch = req.headers.get('if-none-match')
33
+ if (ifNoneMatch) {
34
+ const clientEtags = ifNoneMatch
35
+ .split(',')
36
+ .map(s => s.trim().replace(/^W\//, ''))
37
+ const cleanEtag = etag.replace(/^W\//, '')
38
+
39
+ if (clientEtags.includes(cleanEtag) || clientEtags.includes('*')) {
40
+ return tag(
41
+ new Response(null, {
42
+ status: 304,
43
+ headers: {
44
+ ETag: etag,
45
+ 'Cache-Control': 'no-cache',
46
+ },
47
+ }),
48
+ )
49
+ }
50
+ }
51
+ return null
52
+ }
53
+
54
+ export function sendResponse(req: Request, response: Response): Response {
55
+ const etag = response.headers.get('ETag')
56
+ if (!etag) return response
57
+
58
+ // getSetCookie() keeps each cookie separate. `.get()` joins them with ", ",
59
+ // which then gets written back as one malformed header.
60
+ const cookies = response.headers.getSetCookie()
61
+ response = isNotModified(response) ? response : check(req, etag) || response
62
+
63
+ if (!response.headers.has('Cache-Control')) {
64
+ response.headers.set('Cache-Control', 'no-cache')
65
+ }
66
+
67
+ if (cookies.length && !response.headers.has('Set-Cookie')) {
68
+ for (const cookie of cookies) {
69
+ response.headers.append('Set-Cookie', cookie)
70
+ }
71
+ }
72
+
73
+ return response
74
+ }
75
+
76
+ type VariantStat = { size: number; mtime: number }
77
+ type VariantRecord = { encoding: string; ext: string } & VariantStat
78
+
79
+ /**
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
83
+ * `Promise.all`, so the base mtime changes whenever the variant set does;
84
+ * that makes the mtime the invalidation signal, and no write-side hook is
85
+ * needed (fs.ts stays unaware of this module, keeping the dependency arrow
86
+ * fs ← etag one-way).
87
+ *
88
+ * An `LRUCache`, not a Map: base paths derive from request paths via the
89
+ * hostKey'd cache names, and an unbounded map keyed on client-derived input
90
+ * is what convention 6 forbids. 512 entries covers the working set of
91
+ * distinct cached assets at ~100 bytes each.
92
+ *
93
+ * Only *complete* variant sets are recorded. A partial set means either a
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
96
+ * change without the base mtime moving, so those fall back to per-request
97
+ * probing, which is exactly the pre-memo behavior.
98
+ */
99
+ type NegotiationEntry = { mtime: number; variants: VariantRecord[] }
100
+ const negotiationMemo = new LRUCache<string, NegotiationEntry>(512)
101
+
102
+ /**
103
+ * One stat, and the values are kept rather than discarded: a `BunFile`
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`.
107
+ * Same existence predicate as `fs.exists`; note a missing file reports
108
+ * size 0 and a far-future sentinel `lastModified`, never 0.
109
+ */
110
+ function statVariant(path: string): VariantStat | null {
111
+ const file = Bun.file(path)
112
+ const mtime = file.lastModified
113
+ const size = file.size
114
+ if ((mtime && mtime < Date.now()) || size > 0) return { size, mtime }
115
+ return null
116
+ }
117
+
118
+ let probeVariant: (path: string) => VariantStat | null = statVariant
119
+
120
+ /**
121
+ * 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
123
+ * unwinds. Same shape as `fs.__setForbiddenProbe`.
124
+ */
125
+ export function __setVariantProbe(fn: (path: string) => VariantStat | null) {
126
+ probeVariant = fn
127
+ }
128
+
129
+ export function __resetVariantProbe() {
130
+ probeVariant = statVariant
131
+ }
132
+
133
+ /** Test seam: the memo is process-global and mtime-keyed, so tests that
134
+ * rebuild fixtures at the same path must start from a clean slate. */
135
+ export function __clearNegotiationMemo() {
136
+ negotiationMemo.clear()
137
+ }
138
+
139
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: content negotiation — one branch per encoding outcome
140
+ function negotiateFile(file: Bun.BunFile, req?: Request) {
141
+ const fileName = file.name || 'file'
142
+
143
+ const matchedExt = COMPRESSION_MAP.find(c => fileName.endsWith(c.ext))?.ext
144
+
145
+ if (!req || !matchedExt) {
146
+ return {
147
+ resolvedFile: file,
148
+ fileHeaders: {} as MapOf<any>,
149
+ etag: undefined as string | undefined,
150
+ }
151
+ }
152
+
153
+ const basePath = fileName.slice(0, -matchedExt.length)
154
+ const ext = basePath.split('.').pop() || ''
155
+ const acceptEncoding = req.headers.get('Accept-Encoding') || ''
156
+
157
+ const fileHeaders: MapOf<any> = {
158
+ 'Content-Type': fs.getMimeType(ext),
159
+ 'Cache-Control': 'no-cache',
160
+ }
161
+
162
+ const baseFile = Bun.file(basePath)
163
+ let resolvedFile = baseFile
164
+ let etag: string | undefined
165
+
166
+ if (fs.isCompressible(ext)) {
167
+ // The freshness anchor, and the only stat this function performs on a
168
+ // warm hit. The instance doubles as the identity-path `resolvedFile`,
169
+ // so `fromFile` in `sendFile` reads the same cached stat rather than
170
+ // paying a second one.
171
+ const baseMtime = baseFile.lastModified
172
+ const baseExists = Boolean(
173
+ (baseMtime && baseMtime < Date.now()) || baseFile.size > 0,
174
+ )
175
+
176
+ // 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
178
+ // dir (missing base reports the far-future sentinel mtime) or any
179
+ // rewrite falls through to a fresh probe.
180
+ let entry = baseExists ? negotiationMemo.get(basePath) : undefined
181
+ if (entry && entry.mtime !== baseMtime) {
182
+ entry = undefined
183
+ }
184
+
185
+ if (!entry) {
186
+ const variants: VariantRecord[] = []
187
+ for (const { encoding, ext: compExt } of COMPRESSION_MAP) {
188
+ const stat = probeVariant(`${basePath}${compExt}`)
189
+ if (stat) variants.push({ encoding, ext: compExt, ...stat })
190
+ }
191
+ entry = { mtime: baseMtime, variants }
192
+ if (baseExists && variants.length === COMPRESSION_MAP.length) {
193
+ negotiationMemo.set(basePath, entry)
194
+ } else {
195
+ // A deleted base (cache-dir wipe) or a partial set: drop any stale
196
+ // record so nothing ever resolves to a remembered variant whose
197
+ // files are gone.
198
+ negotiationMemo.delete(basePath)
199
+ }
200
+ }
201
+
202
+ // `variants` is in COMPRESSION_MAP order, so client preference
203
+ // resolution is unchanged from the probe loop it replaces.
204
+ for (const variant of entry.variants) {
205
+ if (acceptEncoding.includes(variant.encoding)) {
206
+ resolvedFile = Bun.file(`${basePath}${variant.ext}`)
207
+ fileHeaders['Content-Encoding'] = variant.encoding
208
+ etag = fileTag(variant.size, variant.mtime)
209
+ break
210
+ }
211
+ }
212
+ }
213
+
214
+ return { resolvedFile, fileHeaders, etag }
215
+ }
216
+
217
+ export function sendFile(file: Bun.BunFile, req?: Request): Response {
218
+ const {
219
+ resolvedFile,
220
+ fileHeaders,
221
+ etag: negotiated,
222
+ } = negotiateFile(file, req)
223
+
224
+ const headers: MapOf<any> = fileHeaders
225
+
226
+ const etag = negotiated || ETag.fromFile(resolvedFile)
227
+ headers.ETag = etag
228
+
229
+ if (req) {
230
+ const conditionalRes = ETag.check(req, etag)
231
+ if (conditionalRes) return conditionalRes
232
+ }
233
+
234
+ return new Response(resolvedFile, { headers })
235
+ }
236
+
237
+ /**
238
+ * Returns synchronously on every path that does not compress — no request,
239
+ * small body, unsupported Accept-Encoding, and crucially the 304
240
+ * short-circuit — and returns a Promise only when compression actually
241
+ * runs on a body large enough to offload (`ASYNC_COMPRESSION_MIN`). The
242
+ * one call site that can receive either (`processResponse`) awaits.
243
+ */
244
+ export function sendText(
245
+ text: string,
246
+ req?: Request,
247
+ type = '',
248
+ status = 200,
249
+ ): MixedPromise<Response> {
250
+ type ||= 'text/plain; charset=utf-8'
251
+ if (
252
+ typeof status !== 'number' ||
253
+ Number.isNaN(status) ||
254
+ status < 100 ||
255
+ status > 599
256
+ ) {
257
+ status = 200
258
+ }
259
+
260
+ let appliedExt = ''
261
+ let encoder: ((text: string) => Uint8Array) | null = null
262
+ let encoderAsync: ((text: string) => Promise<Uint8Array>) | null = null
263
+ let encodingName = ''
264
+ const headers: MapOf<any> = {
265
+ 'Content-Type': type,
266
+ }
267
+
268
+ // Negotiation only — no compression yet. The ext suffix the etag embeds
269
+ // is decided by the Accept-Encoding header alone, so the 304 check can
270
+ // run first: a client that already holds the body must not cost a
271
+ // zstd pass whose output is then thrown away.
272
+ if (req && text.length > 1024) {
273
+ const acceptEncoding = req.headers.get('Accept-Encoding') || ''
274
+
275
+ for (const {
276
+ encoding,
277
+ ext,
278
+ compress,
279
+ compressAsync,
280
+ } of COMPRESSION_MAP) {
281
+ if (acceptEncoding.includes(encoding) && compress) {
282
+ encoder = compress as any
283
+ encoderAsync = compressAsync as any
284
+ encodingName = encoding
285
+ appliedExt = ext
286
+ break
287
+ }
288
+ }
289
+ }
290
+
291
+ const baseEtag = ETag.fromText(text)
292
+ const finalEtag = appliedExt ? `${baseEtag}${appliedExt}` : baseEtag
293
+ headers.ETag = finalEtag
294
+
295
+ if (req) {
296
+ const conditionalRes = ETag.check(req, finalEtag)
297
+ if (conditionalRes) return conditionalRes
298
+ }
299
+
300
+ if (encoder) {
301
+ headers['Content-Encoding'] = encodingName
302
+
303
+ // Off the request thread for large bodies: the sync pass measured
304
+ // ~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
306
+ // `ASYNC_COMPRESSION_MIN` in fs.ts.
307
+ if (encoderAsync && text.length >= ASYNC_COMPRESSION_MIN) {
308
+ return encoderAsync(text).then(
309
+ payload => new Response(payload as any, { headers, status }),
310
+ )
311
+ }
312
+
313
+ return new Response(encoder(text) as any, { headers, status })
314
+ }
315
+
316
+ return new Response(text, { headers, status })
317
+ }
318
+ }