@bakery-framework/core 2.0.0-alpha.11 → 2.0.0-alpha.13
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/package.json +1 -4
- package/src/cache/shared-db.ts +72 -2
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +6 -0
- package/src/client/utils.ts +6 -1
- package/src/compiler/compiler.ts +7 -1
- package/src/core/bakery.ts +4 -1
- package/src/core/index.ts +13 -1
- package/src/core/jsx.ts +17 -2
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/handlers/assets/image.ts +34 -1
- package/src/handlers/core/$dynamic.ts +48 -5
- package/src/handlers/core/$error.ts +5 -2
- package/src/handlers/core/$registry.ts +12 -2
- package/src/logger/serve-log.ts +8 -0
- package/src/session.ts +22 -4
- package/src/utils/fs.ts +62 -4
- package/src/utils/http/etag.ts +24 -9
- package/src/utils/shared-pool.ts +22 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/core",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.13",
|
|
4
4
|
"description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
@@ -30,19 +30,16 @@
|
|
|
30
30
|
"exports": {
|
|
31
31
|
".": "./src/core/index.ts",
|
|
32
32
|
"./compiler": "./src/compiler/index.ts",
|
|
33
|
-
"./core": "./src/core/index.ts",
|
|
34
33
|
"./handlers": "./src/handlers/index.ts",
|
|
35
34
|
"./logger": "./src/logger/index.ts",
|
|
36
35
|
"./utils": "./src/utils/index.ts",
|
|
37
36
|
"./utils/common": "./src/utils/common/index.ts",
|
|
38
37
|
"./utils/http": "./src/utils/http/index.ts",
|
|
39
|
-
"./utils/isomorphic": "./src/utils/isomorphic/index.ts",
|
|
40
38
|
"./types": "./src/types.d.ts",
|
|
41
39
|
"./package.json": "./package.json",
|
|
42
40
|
"./startup": "./src/startup.ts",
|
|
43
41
|
"./tsconfig.app.json": "./tsconfig.app.json",
|
|
44
42
|
"./session": "./src/session.ts",
|
|
45
|
-
"./jsx": "./src/core/jsx.ts",
|
|
46
43
|
"./plugins": "./src/plugins/index.ts",
|
|
47
44
|
"./cache/lru": "./src/cache/lru.ts",
|
|
48
45
|
"./cache/shared-db": "./src/cache/shared-db.ts",
|
package/src/cache/shared-db.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { Database } from 'bun:sqlite'
|
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
import { Bakery } from '../core/bakery'
|
|
4
4
|
import { checkCacheVersion } from '../core/cache-version'
|
|
5
|
+
import { serveLog } from '../logger/serve-log'
|
|
5
6
|
import { fs } from '../utils'
|
|
7
|
+
import { Try } from '../utils/common/try'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* The tiered cache's spill-to-disk store — sessions and LRU overflow.
|
|
@@ -90,8 +92,76 @@ if (!stored) {
|
|
|
90
92
|
for (const { name } of tables) cacheDb.run(`DROP TABLE IF EXISTS "${name}"`)
|
|
91
93
|
cacheDb.run('UPDATE __schema SET version = ?', [SCHEMA_VERSION])
|
|
92
94
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
/**
|
|
96
|
+
* **WAL first, DELETE only where WAL is actually refused.**
|
|
97
|
+
*
|
|
98
|
+
* This was `process.platform === 'win32' ? 'DELETE' : 'WAL'`, a rule whose
|
|
99
|
+
* reason was never written down. The standing hypothesis was that a Windows
|
|
100
|
+
* network path cannot host WAL, and that much is true: WAL needs a shared
|
|
101
|
+
* `-shm` mapping, which SMB and WebDAV do not provide. The rule was wrong in
|
|
102
|
+
* its scope rather than its premise. It applied to every Windows install,
|
|
103
|
+
* including the ordinary case of a local disk where WAL works perfectly.
|
|
104
|
+
*
|
|
105
|
+
* The cost of being wrong was paid on the request path, because sessions
|
|
106
|
+
* write there. Measured on this machine, four interleaved rounds with a
|
|
107
|
+
* CPU-bound control that stayed flat at 29-34 ms:
|
|
108
|
+
*
|
|
109
|
+
* single autocommit write DELETE 3629 us WAL 36 us 100x
|
|
110
|
+
* 100-row transaction DELETE 3.91 ms WAL 0.13 ms 29x
|
|
111
|
+
*
|
|
112
|
+
* So the rule is replaced by an attempt and a check, which is what makes it
|
|
113
|
+
* safe to change without knowing why it was there. SQLite refuses WAL in
|
|
114
|
+
* exactly two observable ways, and both are handled here:
|
|
115
|
+
*
|
|
116
|
+
* - it **returns the unchanged mode** in the pragma's own result row, which
|
|
117
|
+
* is what an in-memory or anonymous-temp database does (`memory`); and
|
|
118
|
+
* - it **throws**, which is what a read-only database does.
|
|
119
|
+
*
|
|
120
|
+
* Measured, not assumed: both shapes were reproduced before this was written.
|
|
121
|
+
* A pragma that silently did nothing would make the fallback unsafe, and it
|
|
122
|
+
* does not - the returned row is the authority, so a filesystem that cannot
|
|
123
|
+
* host WAL lands on DELETE and says so once instead of being guessed at in
|
|
124
|
+
* advance.
|
|
125
|
+
*
|
|
126
|
+
* Returning the effective mode rather than the requested one matters for the
|
|
127
|
+
* same reason: the log line has to name what the database is actually doing.
|
|
128
|
+
*
|
|
129
|
+
* Exported as a test seam rather than driven through the module's own
|
|
130
|
+
* import-time call: this file opens the real cache database when it loads, so
|
|
131
|
+
* the only way to assert on a *refusal* is to hand the function a database
|
|
132
|
+
* that produces one. `:memory:` is such a case and is what the test uses.
|
|
133
|
+
*/
|
|
134
|
+
export function applyJournalMode(db: Database, file: string): string {
|
|
135
|
+
const asked = Try.return(
|
|
136
|
+
() =>
|
|
137
|
+
db
|
|
138
|
+
.query<{ journal_mode: string }, []>('PRAGMA journal_mode = WAL;')
|
|
139
|
+
.get()?.journal_mode ?? '',
|
|
140
|
+
(error: Error) => `threw: ${error.message}`,
|
|
141
|
+
)
|
|
142
|
+
if (asked.toLowerCase() === 'wal') return 'wal'
|
|
143
|
+
|
|
144
|
+
// Wrapped as well. A database that refuses WAL by throwing will refuse the
|
|
145
|
+
// fallback the same way when the mode it is already in is not DELETE, and
|
|
146
|
+
// an unopenable cache is a far worse outcome than an untuned one.
|
|
147
|
+
const fell = Try.return(
|
|
148
|
+
() =>
|
|
149
|
+
db
|
|
150
|
+
.query<{ journal_mode: string }, []>('PRAGMA journal_mode = DELETE;')
|
|
151
|
+
.get()?.journal_mode ?? 'unknown',
|
|
152
|
+
() =>
|
|
153
|
+
db.query<{ journal_mode: string }, []>('PRAGMA journal_mode;').get()
|
|
154
|
+
?.journal_mode ?? 'unknown',
|
|
155
|
+
)
|
|
156
|
+
serveLog.JOURNAL_WAL_REFUSED({
|
|
157
|
+
file,
|
|
158
|
+
answer: asked || '(no row)',
|
|
159
|
+
mode: fell,
|
|
160
|
+
})
|
|
161
|
+
return fell
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
applyJournalMode(cacheDb, dbFilePath)
|
|
95
165
|
cacheDb.run('PRAGMA synchronous = NORMAL;')
|
|
96
166
|
cacheDb.run('PRAGMA temp_store = memory;')
|
|
97
167
|
// `core/init.ts` installs THREAD_WORKER as an accessor holding a **boolean**,
|
package/src/cache/string.ts
CHANGED
|
@@ -41,6 +41,10 @@ class StringCache {
|
|
|
41
41
|
|
|
42
42
|
if (flushIntervalMs !== undefined) {
|
|
43
43
|
this.flushTimer = setInterval(() => this.flushToDisk(), flushIntervalMs)
|
|
44
|
+
// Unref'd for the reason `tiered.ts` gives: `Strings` is constructed at
|
|
45
|
+
// module scope, so importing `compiler/compiler.ts` pinned the event
|
|
46
|
+
// loop open for the life of the process.
|
|
47
|
+
this.flushTimer.unref?.()
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
registerCache(this)
|
package/src/cache/tiered.ts
CHANGED
|
@@ -133,6 +133,12 @@ export class TieredCache<K extends string | number, V> {
|
|
|
133
133
|
() => this.flushToDisk(),
|
|
134
134
|
this.opts.flushInterval,
|
|
135
135
|
)
|
|
136
|
+
// A flush timer must not be the reason a process cannot exit. Every
|
|
137
|
+
// cache here is module-level, so importing `session.ts` — or anything
|
|
138
|
+
// reaching it — left an interval holding the loop open forever: a script
|
|
139
|
+
// or a test that imported core printed its answer and then hung. The CLI
|
|
140
|
+
// never noticed because it calls `process.exit` on every path.
|
|
141
|
+
this.flushTimer.unref?.()
|
|
136
142
|
}
|
|
137
143
|
|
|
138
144
|
registerCache(this)
|
package/src/client/utils.ts
CHANGED
|
@@ -144,7 +144,12 @@ export async function request(
|
|
|
144
144
|
return data
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
/**
|
|
148
|
+
* File-local. It was exported and nothing imported it — `client/utils.ts` is
|
|
149
|
+
* not a published subpath, so the `export` widened nothing a consumer could
|
|
150
|
+
* reach and only made the name look like part of a surface.
|
|
151
|
+
*/
|
|
152
|
+
function formatHTML(html: string, indentWidth: number = 2): string {
|
|
148
153
|
if (!html) return ''
|
|
149
154
|
|
|
150
155
|
const cleanHtml = html
|
package/src/compiler/compiler.ts
CHANGED
|
@@ -80,7 +80,13 @@ function nextVirtualId(): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function preprocessImports(source: string, filePath: fs.AbsolutePath): string {
|
|
83
|
-
|
|
83
|
+
// The file's **directory**, not the file. A relative import resolves against
|
|
84
|
+
// the directory containing the importer, and resolving against the path
|
|
85
|
+
// itself produced `…/src/entry.ts/a.css` — which exists nowhere, so every
|
|
86
|
+
// `import './x.css'` from a compiled module registered a virtual asset that
|
|
87
|
+
// could only 404. It has been wrong since before the workspace split, with
|
|
88
|
+
// no test and neither app using the feature.
|
|
89
|
+
const fileDir = fs.dirname(filePath)
|
|
84
90
|
|
|
85
91
|
const matches = [...source.matchAll(RX_IMPORT)]
|
|
86
92
|
|
package/src/core/bakery.ts
CHANGED
|
@@ -69,7 +69,10 @@ export const Bakery: globalThis.Bakery = {
|
|
|
69
69
|
get version() {
|
|
70
70
|
return getAppVersion()
|
|
71
71
|
},
|
|
72
|
-
|
|
72
|
+
// No size: the pool's own layout is the default now. It used to be asked for
|
|
73
|
+
// a megabyte, of which 9,280 bytes were the layout and the rest a region
|
|
74
|
+
// nothing read. See the constructor in `utils/shared-pool.ts`.
|
|
75
|
+
sharedPool: new SharedMemoryPool(),
|
|
73
76
|
// Defined in `core/context.ts`, which is low enough that a module needing a
|
|
74
77
|
// path does not have to import `Bakery` to get one — reaching them through
|
|
75
78
|
// here is what closed the logger cycle. These stay the reading surface for
|
package/src/core/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { getConfig, NOOP } from './config'
|
|
|
8
8
|
// `./bakery`, which re-exports `hostStore` from exactly here — so naming it
|
|
9
9
|
// adds no module edge, only a name.
|
|
10
10
|
import { getFrameworkVersion } from './context'
|
|
11
|
-
import { createElement, Fragment, html } from './jsx'
|
|
11
|
+
import { createElement, Fragment, html, raw } from './jsx'
|
|
12
12
|
|
|
13
13
|
export const defineConfig = <T extends AppConfig>(config: T): T => config
|
|
14
14
|
export const definePlugin = _definePlugin
|
|
@@ -67,6 +67,18 @@ export {
|
|
|
67
67
|
encodeSSE,
|
|
68
68
|
Fragment,
|
|
69
69
|
getConfig,
|
|
70
|
+
/**
|
|
71
|
+
* Opt a string out of JSX escaping.
|
|
72
|
+
*
|
|
73
|
+
* Here because it had nowhere else to be. `createElement` escapes children
|
|
74
|
+
* unless they came from itself, so `raw` is the documented way to
|
|
75
|
+
* interpolate markup an application already trusts — and it was reachable
|
|
76
|
+
* only through a `./jsx` subpath that existed to alias one file. The
|
|
77
|
+
* routing guide pointed at `@bakery-framework/core/core/jsx`, which the
|
|
78
|
+
* export map never named at all, so the documented import could not resolve
|
|
79
|
+
* for a consumer either way.
|
|
80
|
+
*/
|
|
81
|
+
raw,
|
|
70
82
|
/**
|
|
71
83
|
* The version of `@bakery-framework/core` itself, read from its own manifest.
|
|
72
84
|
*
|
package/src/core/jsx.ts
CHANGED
|
@@ -127,9 +127,24 @@ export function html<P = {}>(render: RenderFn<P>) {
|
|
|
127
127
|
return rawDom
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// Both questions are about the first nine characters, so only the first
|
|
131
|
+
// nine are trimmed and lowercased. The form this replaces built two whole
|
|
132
|
+
// trimmed, lowercased copies of the document to answer them: on a 124 KB
|
|
133
|
+
// page that was 159 us per render against 0.17 us here, a 920x difference
|
|
134
|
+
// on a path every server-rendered page goes through. Measured interleaved
|
|
135
|
+
// with a CPU-bound control that stayed flat at 29-34 ms, and the two forms
|
|
136
|
+
// agree on 18 hand-picked shapes and 200,000 fuzzed strings.
|
|
137
|
+
//
|
|
138
|
+
// The search scans the leading whitespace and stops, so the cost is the
|
|
139
|
+
// indentation rather than the document. `<!doctype` is the longer of the
|
|
140
|
+
// two prefixes at nine characters, which is the whole window needed.
|
|
141
|
+
const startsAt = rawDom.search(/\S/)
|
|
142
|
+
const prefix =
|
|
143
|
+
startsAt < 0 ? '' : rawDom.slice(startsAt, startsAt + 9).toLowerCase()
|
|
144
|
+
|
|
145
|
+
if (prefix.startsWith('<html'))
|
|
131
146
|
return `<!DOCTYPE html>\n${rawDom}`
|
|
132
|
-
if (
|
|
147
|
+
if (prefix.startsWith('<!doctype')) return rawDom
|
|
133
148
|
|
|
134
149
|
let title = 'Document'
|
|
135
150
|
const dom = rawDom.replace(/<title>(.*?)<\/title>/i, (_, t) => {
|
package/src/core/paths.ts
CHANGED
|
@@ -16,7 +16,9 @@ import { FileSystem as fs } from '../utils/fs'
|
|
|
16
16
|
* Use `frameworkPath()` for files the framework ships. Use `Bakery.root` (cwd)
|
|
17
17
|
* for anything the application owns.
|
|
18
18
|
*/
|
|
19
|
-
|
|
19
|
+
/** File-local: `core/paths.ts` is not a published subpath and nothing
|
|
20
|
+
* outside this file read it. `frameworkPath()` below is the way in. */
|
|
21
|
+
const frameworkRoot: string = fs.resolve(import.meta.dir, '..')
|
|
20
22
|
|
|
21
23
|
/** Resolve a path against the framework's own root. */
|
|
22
24
|
export function frameworkPath(...segments: string[]): string {
|
package/src/core/plugins.ts
CHANGED
|
@@ -27,7 +27,8 @@ async function normalizePluginResult(result: Handler.Response) {
|
|
|
27
27
|
export namespace PluginHooks {
|
|
28
28
|
export async function setup() {
|
|
29
29
|
for (const plugin of getPlugins()) {
|
|
30
|
-
|
|
30
|
+
if (!plugin.setup) continue
|
|
31
|
+
const [err] = await Try.catch(() => plugin.setup!(Bakery.config))
|
|
31
32
|
if (err) {
|
|
32
33
|
serveLog.UNHANDLED_ERR({
|
|
33
34
|
error: `Plugin setup error (${plugin.name}): ${errorMsg(err)}`,
|
|
@@ -38,7 +39,15 @@ export namespace PluginHooks {
|
|
|
38
39
|
|
|
39
40
|
export async function onRequest(req: Request) {
|
|
40
41
|
for (const plugin of getPlugins()) {
|
|
41
|
-
|
|
42
|
+
// Skipped rather than awaited. `Try.catch` of an absent hook still
|
|
43
|
+
// allocates a promise and costs a microtask turn, and this loop runs on
|
|
44
|
+
// every request against every registered plugin - most of which declare
|
|
45
|
+
// no `onRequest` at all. Measured with four plugins and none declaring
|
|
46
|
+
// one, 1.33 us per request against 0.24 us; with one declaring it,
|
|
47
|
+
// 1.23 us against 0.60 us. Small, and `onError` below already reads
|
|
48
|
+
// this way, so the loops now share one idiom instead of three.
|
|
49
|
+
if (!plugin.onRequest) continue
|
|
50
|
+
const [err, result] = await Try.catch(plugin.onRequest(req))
|
|
42
51
|
if (err) {
|
|
43
52
|
serveLog.UNHANDLED_ERR({
|
|
44
53
|
error: `Plugin request error (${plugin.name}): ${errorMsg(err)}`,
|
|
@@ -57,7 +66,8 @@ export namespace PluginHooks {
|
|
|
57
66
|
|
|
58
67
|
export async function onRoute(req: Request) {
|
|
59
68
|
for (const plugin of getPlugins()) {
|
|
60
|
-
|
|
69
|
+
if (!plugin.onRoute) continue
|
|
70
|
+
const [err] = await Try.catch(() => plugin.onRoute!(req))
|
|
61
71
|
|
|
62
72
|
if (err) {
|
|
63
73
|
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
|
@@ -67,7 +77,8 @@ export namespace PluginHooks {
|
|
|
67
77
|
|
|
68
78
|
export async function onStart(server: any) {
|
|
69
79
|
for (const plugin of getPlugins()) {
|
|
70
|
-
|
|
80
|
+
if (!plugin.onStart) continue
|
|
81
|
+
const [err] = await Try.catch(() => plugin.onStart!(server))
|
|
71
82
|
|
|
72
83
|
if (err) {
|
|
73
84
|
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
|
@@ -7,7 +7,40 @@ import { response } from '../../utils/http'
|
|
|
7
7
|
import { Handler, type Route } from '../core/$base'
|
|
8
8
|
import { getStatic } from '../core/$static'
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Does this path name an image?
|
|
12
|
+
*
|
|
13
|
+
* **`[^/]*`, not `.*`, and the difference is a denial of service.** This read
|
|
14
|
+
* `/(.*)\\/(.*)(;(\\d+))?\\.(png|…)$/`, whose two greedy groups could each match
|
|
15
|
+
* the separator, so every `/` in the path doubled the ways the engine could
|
|
16
|
+
* split it. The work is superlinear and it is paid by paths that do **not**
|
|
17
|
+
* match, because a failing match is the one that has to try every split before
|
|
18
|
+
* it can say no — and `canHandle` runs on every route-cache miss, above the
|
|
19
|
+
* handlers that serve ordinary pages.
|
|
20
|
+
*
|
|
21
|
+
* Measured on Bun 1.4.0, one call, path of `/` + `a/` × n + `x.txt`:
|
|
22
|
+
*
|
|
23
|
+
* slashes chars old new
|
|
24
|
+
* 64 134 0.98 ms 0.00091 ms
|
|
25
|
+
* 128 262 7.05 ms 0.00146 ms
|
|
26
|
+
* 256 518 63.88 ms 0.00272 ms
|
|
27
|
+
* 512 1030 512.55 ms 0.00564 ms
|
|
28
|
+
*
|
|
29
|
+
* One unauthenticated request with a 1 KB path stalled the event loop for half
|
|
30
|
+
* a second, and the rate limiter cannot help at one request per second. A
|
|
31
|
+
* filename cannot contain a separator, so the second group never should have
|
|
32
|
+
* been able to: bounding it removes the ambiguity and the cost with it.
|
|
33
|
+
*
|
|
34
|
+
* The captures are gone because nothing read them — `canHandle` only calls
|
|
35
|
+
* `.test()`, and `IMAGE_CAPTURE` below is what parses the parts. The trailing
|
|
36
|
+
* `(;(\\d+))?` was dead for the same reason: `.*` already covered `;800`.
|
|
37
|
+
*
|
|
38
|
+
* Equivalence is not an argument from reading: old and new agree on a 40-path
|
|
39
|
+
* corpus and on 200,000 random strings over the alphabet that makes the shapes
|
|
40
|
+
* interesting ([ab/.;1png-_]), with zero disagreements. `image.test.ts` keeps
|
|
41
|
+
* both halves.
|
|
42
|
+
*/
|
|
43
|
+
const IS_IMAGE_REGEX = /\/[^/]*\.(?:png|jpg|jpeg|webp|gif|bmp)$/i
|
|
11
44
|
const IMAGE_CAPTURE = /^(.+?)([^/;.]+)(?:;(\d+))?\.([a-zA-Z0-9]+)$/i
|
|
12
45
|
|
|
13
46
|
export class ImageHandler extends Handler {
|
|
@@ -233,21 +233,59 @@ export class DynamicHandler extends Handler {
|
|
|
233
233
|
*/
|
|
234
234
|
static resolveRoute(path: string): Promise<Route.Info | null>
|
|
235
235
|
static async resolveRoute(path: string) {
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
return this.checkBlocked(await this.resolveRouteFile(path))
|
|
237
|
+
}
|
|
238
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Resolve `path` to a **literal file only** — never a dynamic or catch-all
|
|
241
|
+
* match.
|
|
242
|
+
*
|
|
243
|
+
* For the error handlers, which look up `error` and `error-<code>` pages by
|
|
244
|
+
* name. Asking the ordinary resolver for those was wrong twice over.
|
|
245
|
+
*
|
|
246
|
+
* **It served the wrong page.** `resolveRoute` falls through to dynamic
|
|
247
|
+
* matching, so an app with a root `[...slug].tsx` had every error page
|
|
248
|
+
* claimed by the catch-all, and `/blog/[id].tsx` answered a 404 under
|
|
249
|
+
* `/blog` by rendering a post with `id = 'error'`. An error page is a file
|
|
250
|
+
* an application put there on purpose; nothing about it should be pattern
|
|
251
|
+
* matched.
|
|
252
|
+
*
|
|
253
|
+
* **And it was most of the cost of a 404.** `DynamicErrorHandler` walks the
|
|
254
|
+
* path prefixes, so a miss ran two full resolutions per segment — each one a
|
|
255
|
+
* glob scan plus an `isForbidden` tree-walk, and each one guaranteed to fail
|
|
256
|
+
* for an app with no error pages. That is why a 404 cost ten to thirty times
|
|
257
|
+
* a served page and grew with depth: 64 segments measured at over ten
|
|
258
|
+
* seconds. Static-only makes each probe one glob and no dynamic scan.
|
|
259
|
+
*/
|
|
260
|
+
protected static resolveStaticRoute(path: string): Promise<Route.Info | null>
|
|
261
|
+
protected static async resolveStaticRoute(path: string) {
|
|
262
|
+
return this.checkBlocked(await this.resolveRouteFile(path, true))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The deny-list check both resolvers end in.
|
|
267
|
+
*
|
|
268
|
+
* Extracted rather than repeated: it is the clause that stops a resolved
|
|
269
|
+
* *file* from being served when the request path alone looked innocent (see
|
|
270
|
+
* `resolveRouteFile`'s note), and two copies is two places for it to be
|
|
271
|
+
* dropped from.
|
|
272
|
+
*/
|
|
273
|
+
private static checkBlocked(info: Route.Info | null) {
|
|
274
|
+
if (!info) return null
|
|
239
275
|
if (
|
|
240
276
|
this.servesFiles &&
|
|
241
277
|
matchBlockedCached(Bakery.config.blocked, `/${info.path}`)
|
|
242
278
|
) {
|
|
243
279
|
return null
|
|
244
280
|
}
|
|
245
|
-
|
|
246
281
|
return info
|
|
247
282
|
}
|
|
248
283
|
|
|
249
|
-
protected static resolveRouteFile(
|
|
250
|
-
|
|
284
|
+
protected static resolveRouteFile(
|
|
285
|
+
path: string,
|
|
286
|
+
staticOnly?: boolean,
|
|
287
|
+
): Promise<Route.Info | null>
|
|
288
|
+
protected static async resolveRouteFile(path: string, staticOnly = false) {
|
|
251
289
|
const cached = this.getCachedRoute(path)
|
|
252
290
|
if (cached) return cached
|
|
253
291
|
|
|
@@ -269,6 +307,11 @@ export class DynamicHandler extends Handler {
|
|
|
269
307
|
return this.cacheStaticRoute(path, staticInfo)
|
|
270
308
|
}
|
|
271
309
|
|
|
310
|
+
// A literal file was all that was asked for. The dynamic passes below are
|
|
311
|
+
// not merely skipped as an optimization — for an error page they would be
|
|
312
|
+
// wrong, and `resolveStaticRoute` says why.
|
|
313
|
+
if (staticOnly) return null
|
|
314
|
+
|
|
272
315
|
// `findDynamicRoute` already required `valid` and `!isForbidden` against
|
|
273
316
|
// `Bakery.serveRoot` before returning, so wrapping it in
|
|
274
317
|
// `validateCachedRoute` — which asserts exactly those two, against exactly
|
|
@@ -279,9 +279,12 @@ export class DynamicErrorHandler extends DynamicHandler {
|
|
|
279
279
|
|
|
280
280
|
const defsPage = `${prefix}/error`
|
|
281
281
|
const codePage = `${prefix}/error-${errors.errorCode}`
|
|
282
|
+
// `resolveStaticRoute`, never `resolveRoute`: an error page is a file by
|
|
283
|
+
// name, and letting the dynamic matcher answer meant a root catch-all
|
|
284
|
+
// claimed every one of them. See the note on that method.
|
|
282
285
|
const routeInfo =
|
|
283
|
-
(await super.
|
|
284
|
-
(await super.
|
|
286
|
+
(await super.resolveStaticRoute(codePage)) ||
|
|
287
|
+
(await super.resolveStaticRoute(defsPage))
|
|
285
288
|
if (routeInfo) return routeInfo
|
|
286
289
|
}
|
|
287
290
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LRUCache } from '../../cache/lru'
|
|
2
|
+
import { hostKey } from '../../core/bakery'
|
|
2
3
|
import type { Handler } from './$base'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -126,8 +127,17 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
126
127
|
// must not run twice. That constraint is what the branching encodes.
|
|
127
128
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cache-path + side-effect-sensitive probe loop
|
|
128
129
|
async resolve(path: string, req?: Request, ...rest: any[]) {
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
// The **resolved** host, not the header. `__hostname` is whatever arrived
|
|
131
|
+
// in `Host`, and `routeCache` is one bounded LRU shared by every tenant, so
|
|
132
|
+
// a client varying that header minted an entry per spelling and walked the
|
|
133
|
+
// real hosts' entries out of the cache for as long as it kept asking. The
|
|
134
|
+
// file cache carried the same bug and was fixed; this one kept it.
|
|
135
|
+
//
|
|
136
|
+
// `hostKey` resolves through the multi-host config the way every other
|
|
137
|
+
// per-tenant key does, so an unknown or unconfigured host collapses to a
|
|
138
|
+
// single entry instead of one per spelling. The shape is unchanged when
|
|
139
|
+
// there is no host: `hostKey` returns the bare path.
|
|
140
|
+
const pathId = `${this.id}:${hostKey(path)}`
|
|
131
141
|
const cached: any = HandlerMap.routeCache.get(pathId)
|
|
132
142
|
|
|
133
143
|
// Only tracked once there is a cache hit to skip past. Middleware has
|
package/src/logger/serve-log.ts
CHANGED
|
@@ -80,6 +80,13 @@ const serveMsgs = {
|
|
|
80
80
|
// marker was written and the next boot will try again. Worth a line rather
|
|
81
81
|
// than silence: something is holding those files open, and a stale compiled
|
|
82
82
|
// page surviving an upgrade is the failure the wipe exists to prevent.
|
|
83
|
+
// SQLite would not take WAL for this file, so it is running the DELETE
|
|
84
|
+
// journal instead. Worth one line rather than silence: on a local disk WAL
|
|
85
|
+
// is 100x faster per write and 29x on a transaction, and sessions write on
|
|
86
|
+
// the request path — so this line is the difference between "this
|
|
87
|
+
// filesystem cannot do better" and a deployment quietly paying that.
|
|
88
|
+
JOURNAL_WAL_REFUSED:
|
|
89
|
+
'W SQLite refused WAL for %y{file}%* (answered %y{answer}%*) — running the %y{mode}%* journal instead. Expected on a network path; on a local disk it costs every write.',
|
|
83
90
|
CACHE_WIPE_INCOMPLETE:
|
|
84
91
|
'W Cache directory could not be cleared for the version change — %y{dir}%* still contains %y{files}%*. Retrying on next start; close anything holding those files.',
|
|
85
92
|
} as const
|
|
@@ -137,6 +144,7 @@ const pluginMsgs = {
|
|
|
137
144
|
UNHANDLED_ERR: 'E Unhandled Plugin Error: %r{error}%*',
|
|
138
145
|
ANALYTICS_STORE_ERR: 'E Analytics store init failed: %r{error}%*',
|
|
139
146
|
DASHBOARD_BUNDLE_ERR: 'E Failed to bundle %ydashboard.js%*: %r{error}%*',
|
|
147
|
+
EXPLORER_QUERY_ERR: 'E Explorer %y{op}%* failed: %r{error}%*',
|
|
140
148
|
} as const
|
|
141
149
|
|
|
142
150
|
export const pluginLog = messageLogger(new Logger('plugins'), pluginMsgs)
|
package/src/session.ts
CHANGED
|
@@ -102,6 +102,19 @@ export class Session<
|
|
|
102
102
|
return Session.cache.count
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Attach the session cookie to a response built outside the pipeline.
|
|
107
|
+
*
|
|
108
|
+
* **Takes the `Request`, and that is not incidental.** `getCookie` reads the
|
|
109
|
+
* session through `hasDeferredValue(req, 'session')`, which looks for a
|
|
110
|
+
* symbol the router installs — so the value has to come from the real
|
|
111
|
+
* request. There used to be an instance form, `session.bind(res)`, that
|
|
112
|
+
* called this with `{ session: this }`: a fake carrying no symbol, so the
|
|
113
|
+
* check failed, `getCookie` returned an empty string, and the cookie was
|
|
114
|
+
* never appended. It was a documented method that could not work, and it is
|
|
115
|
+
* gone rather than repaired — the session alone does not know whether the
|
|
116
|
+
* request it belongs to issued a cookie this turn.
|
|
117
|
+
*/
|
|
105
118
|
public static bind(req: Request, response?: Response) {
|
|
106
119
|
if (!response) return response
|
|
107
120
|
|
|
@@ -423,10 +436,6 @@ export class Session<
|
|
|
423
436
|
return this
|
|
424
437
|
}
|
|
425
438
|
|
|
426
|
-
public bind(response?: Response) {
|
|
427
|
-
return Session.bind({ session: this } as any, response)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
439
|
public destroy(): void {
|
|
431
440
|
Session.delete(this.id)
|
|
432
441
|
}
|
|
@@ -548,6 +557,15 @@ const sessionPruneTimer = setInterval(
|
|
|
548
557
|
1000 * 60 * 15, // prune every 15 minutes
|
|
549
558
|
)
|
|
550
559
|
|
|
560
|
+
// Unref'd, like the two flush timers in `cache/`. A 15-minute prune is not a
|
|
561
|
+
// reason a process cannot exit, and this one is module-level: importing
|
|
562
|
+
// `session.ts` — which `core/index` does — held the event loop open for the
|
|
563
|
+
// life of any process that touched core. The CLI never saw it because every
|
|
564
|
+
// one of its paths ends in `process.exit`; a script, an embedder or a bare
|
|
565
|
+
// `bun -e` that imported the barrel printed its answer and then hung.
|
|
566
|
+
// `onShutdown` still clears it, which is what matters for an orderly stop.
|
|
567
|
+
sessionPruneTimer.unref?.()
|
|
568
|
+
|
|
551
569
|
Bakery.onShutdown(() => {
|
|
552
570
|
clearInterval(sessionPruneTimer)
|
|
553
571
|
})
|
package/src/utils/fs.ts
CHANGED
|
@@ -281,9 +281,29 @@ export namespace FileSystem {
|
|
|
281
281
|
return (await Try(() => Bun.file(path).stat()))?.isDirectory() || false
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Handed a `BunFile`, this used to take the path back off it and build a
|
|
286
|
+
* **second** one to stat. A `BunFile` caches its own stat, so the instance
|
|
287
|
+
* the caller already has is free to read and the fresh one is a syscall:
|
|
288
|
+
* one question about one file cost two stats and an allocation.
|
|
289
|
+
*
|
|
290
|
+
* It shows up wherever a caller asks both questions, which is the common
|
|
291
|
+
* shape - `validCache` below, `nm.ts`'s `exists(nmFile) ? nmFile.lastModified`,
|
|
292
|
+
* and both mtime comparisons in `image.ts`. Measured on this machine, four
|
|
293
|
+
* interleaved rounds against a CPU-bound control flat at 27-31 ms:
|
|
294
|
+
*
|
|
295
|
+
* validCache, file present 33.0 us -> 17.3 us 1.9x
|
|
296
|
+
* validCache, file absent 34.9 us -> 17.4 us 2.0x
|
|
297
|
+
*
|
|
298
|
+
* The predicate is unchanged, and the two clauses in it are not
|
|
299
|
+
* interchangeable. `lastModified` on a **missing** file answers roughly
|
|
300
|
+
* `Date.now()` rather than 0, which is what the `< Date.now()` guard is
|
|
301
|
+
* for; dropping it reports every absent file as present. The `size > 0`
|
|
302
|
+
* fallback then covers a file whose mtime is in the future, which clock
|
|
303
|
+
* skew on a network share produces.
|
|
304
|
+
*/
|
|
284
305
|
export function exists(path: string | Bun.BunFile): boolean {
|
|
285
|
-
|
|
286
|
-
const file = Bun.file(path)
|
|
306
|
+
const file = typeof path === 'string' ? Bun.file(path) : path
|
|
287
307
|
const lastMod = file.lastModified
|
|
288
308
|
return Boolean((lastMod && lastMod < Date.now()) || file.size > 0)
|
|
289
309
|
}
|
|
@@ -452,7 +472,41 @@ export namespace FileSystem {
|
|
|
452
472
|
return false
|
|
453
473
|
}
|
|
454
474
|
|
|
455
|
-
|
|
475
|
+
/**
|
|
476
|
+
* `statSync` with ENOENT suppressed, not `existsSync`.
|
|
477
|
+
*
|
|
478
|
+
* Node's `existsSync` is a `stat` with a `try`/`catch` around it, and on
|
|
479
|
+
* Bun/Windows the throw-and-catch for a path that is not there is most of
|
|
480
|
+
* what it costs. Asking for the stat directly and reading `undefined` for a
|
|
481
|
+
* miss skips that. Measured on this machine, four interleaved rounds
|
|
482
|
+
* against a CPU-bound control that stayed flat at 27-30 ms:
|
|
483
|
+
*
|
|
484
|
+
* existsSync 30.00 us guarded statSync 17.52 us 1.71x
|
|
485
|
+
*
|
|
486
|
+
* A miss is the case that matters: almost no directory holds a `.forbidden`
|
|
487
|
+
* marker, so every level of every walk takes this path. At four unique
|
|
488
|
+
* levels per request that is 50 us, at six 75 us.
|
|
489
|
+
*
|
|
490
|
+
* The `catch` is load-bearing rather than defensive habit. `throwIfNoEntry:
|
|
491
|
+
* false` suppresses **ENOENT only**, so a directory the process cannot stat
|
|
492
|
+
* still throws EACCES or EPERM where `existsSync` answers false - and a
|
|
493
|
+
* throw here would escape through `isForbidden` into the request. Answering
|
|
494
|
+
* false matches the behaviour this replaces exactly; the two forms agree on
|
|
495
|
+
* a missing marker, a present one, a marker that is a *directory*, a path
|
|
496
|
+
* several levels below anything that exists, a directory, and the empty
|
|
497
|
+
* string.
|
|
498
|
+
*/
|
|
499
|
+
function statProbe(path: string): boolean {
|
|
500
|
+
try {
|
|
501
|
+
return nodeStatSync(path, { throwIfNoEntry: false }) !== undefined
|
|
502
|
+
} catch {
|
|
503
|
+
// See above: a stat that fails for any reason other than absence is
|
|
504
|
+
// reported as absent, which is what `existsSync` did.
|
|
505
|
+
return false
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
let probeForbidden: (path: string) => boolean = statProbe
|
|
456
510
|
|
|
457
511
|
/**
|
|
458
512
|
* Test seam (convention 9): lets `fs.test.ts` count the syscalls the
|
|
@@ -464,7 +518,7 @@ export namespace FileSystem {
|
|
|
464
518
|
}
|
|
465
519
|
|
|
466
520
|
export function __resetForbiddenProbe() {
|
|
467
|
-
probeForbidden =
|
|
521
|
+
probeForbidden = statProbe
|
|
468
522
|
}
|
|
469
523
|
|
|
470
524
|
export async function mkdir(path: string) {
|
|
@@ -508,6 +562,10 @@ export namespace FileSystem {
|
|
|
508
562
|
return compressable.has(cleanExt)
|
|
509
563
|
}
|
|
510
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Two reads of one `BunFile`, which is one stat: `exists` no longer
|
|
567
|
+
* re-wraps the instance, so the second read comes off the cached stat.
|
|
568
|
+
*/
|
|
511
569
|
function validCache(file: Bun.BunFile, sourceMtime: number | null): boolean {
|
|
512
570
|
return exists(file) && (!sourceMtime || sourceMtime <= file.lastModified)
|
|
513
571
|
}
|
package/src/utils/http/etag.ts
CHANGED
|
@@ -243,17 +243,32 @@ export namespace ETag {
|
|
|
243
243
|
// in-memory Blob or a `sendText` string body ignores Range entirely
|
|
244
244
|
// (served whole, 200), which is why `.name` gates the claim.
|
|
245
245
|
//
|
|
246
|
-
// Skipped when Bun's own range path is about to answer (a GET carrying
|
|
247
|
-
// `Range`): that path appends its own `Accept-Ranges: bytes`
|
|
248
|
-
// 206/416, and setting it here too emitted `bytes, bytes`.
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// one
|
|
246
|
+
// Skipped when Bun's own range path is about to answer (a GET carrying a
|
|
247
|
+
// single-range `Range`): that path appends its own `Accept-Ranges: bytes`
|
|
248
|
+
// to the 206/416, and setting it here too emitted `bytes, bytes`.
|
|
249
|
+
//
|
|
250
|
+
// A **multipart** range (`bytes=0-1,10-11`) is carved back out of the
|
|
251
|
+
// skip: Bun does not do multipart — it serves the whole file as a 200 and
|
|
252
|
+
// appends nothing — so the skip left that response advertising nothing at
|
|
253
|
+
// all, which reads as "ranges not supported" to the one client that just
|
|
254
|
+
// demonstrated it wants them. Found by a downstream smoke test. The comma
|
|
255
|
+
// is the entire multipart grammar, so this cannot drift from Bun's own
|
|
256
|
+
// parse the way a real Range parser here could; RFC-legal multipart gets
|
|
257
|
+
// the 200-with-advertisement it deserves. What stays excluded-and-silent
|
|
258
|
+
// is a *malformed* single range (`Range: potato`) — also served whole by
|
|
259
|
+
// Bun, but that requester sent garbage, and mirroring Bun's full validity
|
|
260
|
+
// judgment here is exactly the second-parser drift this comment refuses.
|
|
261
|
+
// HEAD ignores `Range` and gets the header even when one is present.
|
|
262
|
+
const rangeValue = req?.headers.get('range')
|
|
254
263
|
if (
|
|
255
264
|
resolvedFile.name &&
|
|
256
|
-
!(
|
|
265
|
+
!(
|
|
266
|
+
req &&
|
|
267
|
+
req.method === 'GET' &&
|
|
268
|
+
rangeValue !== null &&
|
|
269
|
+
rangeValue !== undefined &&
|
|
270
|
+
!rangeValue.includes(',')
|
|
271
|
+
)
|
|
257
272
|
) {
|
|
258
273
|
headers['Accept-Ranges'] = 'bytes'
|
|
259
274
|
}
|
package/src/utils/shared-pool.ts
CHANGED
|
@@ -22,11 +22,29 @@ export class SharedMemoryPool {
|
|
|
22
22
|
header!: Int32Array
|
|
23
23
|
counters!: Int32Array
|
|
24
24
|
rateLimits!: Int32Array
|
|
25
|
-
dataPool!: Uint8Array
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* **The default is the layout's own size, not a megabyte.**
|
|
28
|
+
*
|
|
29
|
+
* It was `1024 * 1024`, and the layout uses 9,280 bytes of it: a 64-byte
|
|
30
|
+
* header, 1 KB of counters and 8 KB of rate-limit slots. The remaining
|
|
31
|
+
* 1,039,296 bytes were a `dataPool` region that **nothing read** - grep it
|
|
32
|
+
* across every package and app, and the only reference outside this file was
|
|
33
|
+
* a test asserting it was a `Uint8Array`. So 99.1% of a `SharedArrayBuffer`
|
|
34
|
+
* allocated at import in every server process existed for one assertion.
|
|
35
|
+
*
|
|
36
|
+
* The region is gone rather than shrunk. A scratch area with no consumer is
|
|
37
|
+
* not a feature waiting to be used; it is a number nobody can size, because
|
|
38
|
+
* there is nothing to size it against. Something that needs shared scratch
|
|
39
|
+
* later adds it along with the code that reads it.
|
|
40
|
+
*
|
|
41
|
+
* A larger size is still accepted and still honoured, because `bind()` reads
|
|
42
|
+
* the size out of the header rather than trusting `byteLength` - so a
|
|
43
|
+
* cluster master that allocates more and shares it still works.
|
|
44
|
+
*/
|
|
45
|
+
constructor(sizeOrBuffer: number | SharedArrayBuffer = BUFFER_START_OFFSET) {
|
|
28
46
|
if (typeof sizeOrBuffer === 'number') {
|
|
29
|
-
const size = Math.max(sizeOrBuffer, BUFFER_START_OFFSET
|
|
47
|
+
const size = Math.max(sizeOrBuffer, BUFFER_START_OFFSET)
|
|
30
48
|
this.buffer = new SharedArrayBuffer(size)
|
|
31
49
|
this.header = new Int32Array(this.buffer, 0, HEADER_INT_COUNT)
|
|
32
50
|
this.counters = new Int32Array(
|
|
@@ -39,12 +57,6 @@ export class SharedMemoryPool {
|
|
|
39
57
|
HEADER_BYTES + COUNTERS_BYTES,
|
|
40
58
|
RATE_LIMIT_SLOT_COUNT * 2,
|
|
41
59
|
)
|
|
42
|
-
this.dataPool = new Uint8Array(
|
|
43
|
-
this.buffer,
|
|
44
|
-
BUFFER_START_OFFSET,
|
|
45
|
-
size - BUFFER_START_OFFSET,
|
|
46
|
-
)
|
|
47
|
-
|
|
48
60
|
Atomics.store(this.header, 0, 0x42414b45)
|
|
49
61
|
Atomics.store(this.header, 1, size)
|
|
50
62
|
Atomics.store(this.header, 2, BUFFER_START_OFFSET)
|
|
@@ -70,12 +82,7 @@ export class SharedMemoryPool {
|
|
|
70
82
|
HEADER_BYTES + COUNTERS_BYTES,
|
|
71
83
|
RATE_LIMIT_SLOT_COUNT * 2,
|
|
72
84
|
)
|
|
73
|
-
|
|
74
|
-
this.dataPool = new Uint8Array(
|
|
75
|
-
this.buffer,
|
|
76
|
-
BUFFER_START_OFFSET,
|
|
77
|
-
size - BUFFER_START_OFFSET,
|
|
78
|
-
)
|
|
85
|
+
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
incrementCounter(slot: number, delta = 1): number {
|