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