@bakery-framework/core 2.0.0-alpha.9 → 2.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +2 -5
- package/src/cache/lru.ts +2 -2
- package/src/cache/shared-db.ts +81 -11
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +11 -5
- package/src/client/globals.d.ts +7 -7
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +11 -6
- package/src/compiler/compiler.ts +48 -44
- package/src/compiler/dev-service.ts +29 -30
- package/src/compiler/prompt-tracker.ts +2 -2
- package/src/compiler/tsconfig-sync.ts +130 -59
- package/src/core/bakery.ts +6 -3
- package/src/core/cache-version.ts +68 -18
- package/src/core/config.ts +16 -16
- package/src/core/context.ts +54 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +29 -8
- package/src/core/init.ts +38 -35
- package/src/core/jsx.ts +19 -4
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/core/port.ts +6 -6
- package/src/global.d.ts +36 -18
- package/src/handlers/assets/google-font.ts +6 -6
- package/src/handlers/assets/image.ts +36 -3
- package/src/handlers/assets/nm.ts +7 -7
- package/src/handlers/assets/static.ts +55 -12
- package/src/handlers/assets/ts.ts +3 -3
- package/src/handlers/assets/tsx.ts +1 -1
- package/src/handlers/assets/virtual-asset.ts +1 -1
- package/src/handlers/core/$base.ts +24 -8
- package/src/handlers/core/$dynamic.ts +66 -23
- package/src/handlers/core/$error.ts +29 -17
- package/src/handlers/core/$middleware.ts +6 -6
- package/src/handlers/core/$mounts.ts +2 -2
- package/src/handlers/core/$registry.ts +16 -6
- package/src/handlers/core/$routing.ts +18 -19
- package/src/handlers/core/$static.ts +11 -12
- package/src/handlers/routes/api.ts +5 -5
- package/src/handlers/routes/html.ts +2 -2
- package/src/handlers/routes/proxy.ts +1 -1
- package/src/logger/clients.ts +2 -2
- package/src/logger/logger.ts +4 -4
- package/src/logger/serve-log.ts +32 -18
- package/src/plugins/index.ts +1 -1
- package/src/plugins/routes.ts +7 -7
- package/src/plugins/types.ts +13 -11
- package/src/router.ts +12 -12
- package/src/session.ts +41 -23
- package/src/shared.d.ts +5 -5
- package/src/startup.ts +3 -3
- package/src/types.d.ts +10 -10
- package/src/utils/common/case.ts +1 -1
- package/src/utils/common/match.ts +1 -1
- package/src/utils/common/misc.ts +1 -1
- package/src/utils/constants.ts +15 -15
- package/src/utils/fs.ts +97 -39
- package/src/utils/http/authorize.ts +24 -17
- package/src/utils/http/cors.ts +6 -6
- package/src/utils/http/credential.ts +4 -4
- package/src/utils/http/csrf.ts +6 -7
- package/src/utils/http/dom.ts +9 -9
- package/src/utils/http/etag.ts +54 -13
- package/src/utils/http/html.ts +29 -20
- package/src/utils/http/ip.ts +1 -1
- package/src/utils/http/sse.ts +11 -11
- package/src/utils/http/url.ts +4 -4
- package/src/utils/http/validate.ts +3 -4
- package/src/utils/isomorphic/escape.ts +1 -1
- package/src/utils/isomorphic/is.ts +2 -2
- package/src/utils/isomorphic/misc.ts +1 -1
- package/src/utils/isomorphic/stringify.ts +2 -3
- package/src/utils/isomorphic/try.ts +1 -1
- package/src/utils/shared-pool.ts +23 -16
- package/tsconfig.app.json +3 -3
- package/tsconfig.vue.json +3 -3
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* One implementation, deliberately in core, for the same reason
|
|
5
5
|
* `credential.ts` is here: the dashboard, db-explorer and analytics plugins
|
|
6
6
|
* each need this guard, and per-plugin copies of security code drift. These
|
|
7
|
-
* three had already drifted
|
|
7
|
+
* three had already drifted: one coerced its predicate's return with
|
|
8
8
|
* `Boolean`, one gated on `!DEV` where the others read `PROD`, one swallowed a
|
|
9
9
|
* `getClientIp` throw into an empty string and carried on. Each divergence is
|
|
10
10
|
* resolved below toward the fail-closed reading, and each is marked as a
|
|
11
11
|
* decision rather than left to look like a style choice.
|
|
12
12
|
*
|
|
13
|
-
* It owns *only* the predicate
|
|
13
|
+
* It owns *only* the predicate: no logins, no sessions, no backoff. The shared
|
|
14
14
|
* key path is `credential.ts`; a plugin that offers both doors composes them.
|
|
15
15
|
*
|
|
16
16
|
* `getClientIp` is imported from `./ip` directly, never through this
|
|
@@ -37,7 +37,7 @@ export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
|
|
|
37
37
|
* `'localhost'` used to be a member, back when the request's *hostname* was
|
|
38
38
|
* compared against this set as well. A peer address is never the string
|
|
39
39
|
* `localhost`, and accepting it meant `X-Forwarded-For: localhost` counted as
|
|
40
|
-
* loopback under `trustProxy
|
|
40
|
+
* loopback under `trustProxy`, and, worse, that `new URL(req.url).hostname`
|
|
41
41
|
* did too. Bun builds that from the client's own `Host` header and
|
|
42
42
|
* `DEFAULT_HOST` is 0.0.0.0, so any peer on the LAN could send
|
|
43
43
|
* `Host: localhost` and be handed a database browser.
|
|
@@ -51,7 +51,7 @@ export function isLoopback(req: Request): boolean {
|
|
|
51
51
|
// DECISION (divergence 3): a throw returns `false` immediately rather than
|
|
52
52
|
// falling through with `ip = ''`. `getClientIp` reads config and the live
|
|
53
53
|
// server, either of which may be absent (tests, early boot). The two spell
|
|
54
|
-
// the same answer today, because `LOOPBACK.has('')` is false
|
|
54
|
+
// the same answer today, because `LOOPBACK.has('')` is false, but only by
|
|
55
55
|
// coincidence, and the fall-through invites a later edit that adds a second
|
|
56
56
|
// source of evidence below this line and silently consults it on the
|
|
57
57
|
// indeterminate path. Returning here says the answer is settled: no address
|
|
@@ -71,31 +71,38 @@ export function isLoopback(req: Request): boolean {
|
|
|
71
71
|
*/
|
|
72
72
|
export function defaultAuthorize(req: Request): boolean {
|
|
73
73
|
// DECISION (divergence 2): gate on `PROD`, not `!DEV`, and read it at call
|
|
74
|
-
// time
|
|
74
|
+
// time. The mode flags are accessors on `process.env` (`core/init.ts`), so
|
|
75
75
|
// they are process state and tests flip them.
|
|
76
76
|
//
|
|
77
77
|
// The two spellings look interchangeable because `init.ts` derives them as
|
|
78
78
|
// complements (`PROD = !isDev && !--dev-worker`). They are not, because they
|
|
79
79
|
// are *independently settable* accessors and the test fixtures set them one
|
|
80
|
-
// at a time: `asDev` flips `DEV` alone and leaves the ambient `PROD`
|
|
81
|
-
// under `bun test` is `true`
|
|
80
|
+
// at a time: `asDev` flips `DEV` alone and leaves the ambient `PROD` (which
|
|
81
|
+
// under `bun test` is `true`) in place. In that state `!DEV` admits a
|
|
82
82
|
// loopback caller and `PROD` denies, so `PROD` is both the fail-closed
|
|
83
83
|
// reading and the one that literally names the condition it means, rather
|
|
84
84
|
// than inferring production from the absence of development.
|
|
85
85
|
//
|
|
86
|
-
// Hence
|
|
87
|
-
// place a bare `PROD` gate would be
|
|
88
|
-
// exist until `core/init.ts` has run, and
|
|
89
|
-
// through to the loopback check
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
86
|
+
// Hence a test for the *positive* development marker rather than a truthiness
|
|
87
|
+
// test on `PROD`, which is the one place a bare `PROD` gate would be weaker
|
|
88
|
+
// than `!DEV`. The flags do not exist until `core/init.ts` has run, and
|
|
89
|
+
// `if (undefined)` falls straight through to the loopback check, so an
|
|
90
|
+
// uninitialized process would open the door an initialized production one
|
|
91
|
+
// keeps shut. Unset is not evidence of development; it is no evidence at all.
|
|
92
|
+
//
|
|
93
|
+
// This read `PROD !== false` while the flags were real booleans. Bun 1.4
|
|
94
|
+
// rejects accessor descriptors on `process.env`, so they are `'1'`/`''`
|
|
95
|
+
// strings now (see `core/init.ts`), and `!== false` is true for *every*
|
|
96
|
+
// string, including the `''` a development server sets, so the gate would
|
|
97
|
+
// have denied on loopback in development while still looking correct.
|
|
98
|
+
// `DEV === '1'` is the same statement in the encoding that survives: only a
|
|
99
|
+
// booted development server sets it, and production (`''`) and never-booted
|
|
100
|
+
// (`undefined`) both fail it.
|
|
94
101
|
//
|
|
95
102
|
// `isLoopback` would very likely deny on its own there, having no config or
|
|
96
103
|
// server to read an address from. That is a second line of defence, not this
|
|
97
104
|
// one's excuse: a guard should not depend on another guard's failure mode.
|
|
98
|
-
if (import.meta.env.
|
|
105
|
+
if (import.meta.env.DEV !== '1') return false
|
|
99
106
|
return isLoopback(req)
|
|
100
107
|
}
|
|
101
108
|
|
|
@@ -116,7 +123,7 @@ export async function isAuthorized(
|
|
|
116
123
|
try {
|
|
117
124
|
// DECISION (divergence 1): `=== true`, never `Boolean(...)`. The predicate
|
|
118
125
|
// comes from application code and `AuthorizeFn`'s return type is only
|
|
119
|
-
// advice
|
|
126
|
+
// advice: an untyped, transpiled or `as any` predicate can hand back
|
|
120
127
|
// anything. `Boolean` admits every truthy non-boolean, so a check that
|
|
121
128
|
// answers with a status string denies on `""` and *grants* on `"no"`, and
|
|
122
129
|
// one that answers with a count grants on any non-zero. Admission is the
|
package/src/utils/http/cors.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-origin resource sharing.
|
|
3
3
|
*
|
|
4
|
-
* Bakery already ships the pieces of an API server
|
|
5
|
-
* CSRF, rate limiting
|
|
4
|
+
* Bakery already ships the pieces of an API server (`ApiHandler`, sessions,
|
|
5
|
+
* CSRF, rate limiting), and had no way to serve one to a browser on another
|
|
6
6
|
* origin. This is that.
|
|
7
7
|
*
|
|
8
8
|
* Two halves, and both are needed: a preflight `OPTIONS` has to be answered
|
|
@@ -22,8 +22,8 @@ export interface CorsOptions {
|
|
|
22
22
|
/**
|
|
23
23
|
* Origins allowed to read responses.
|
|
24
24
|
*
|
|
25
|
-
* `'*'` is
|
|
26
|
-
* `credentials
|
|
25
|
+
* `'*'` is honored literally, and is refused in combination with
|
|
26
|
+
* `credentials`. See `resolveOrigin`. A function receives the request's
|
|
27
27
|
* `Origin` and returns the value to echo, or `null` to deny.
|
|
28
28
|
*/
|
|
29
29
|
origin: string | string[] | ((origin: string) => string | null)
|
|
@@ -45,7 +45,7 @@ const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
|
|
|
45
45
|
* The value for `Access-Control-Allow-Origin`, or `null` to send nothing.
|
|
46
46
|
*
|
|
47
47
|
* **`'*'` with credentials is refused rather than silently downgraded.** The
|
|
48
|
-
* browser rejects that pairing anyway, so
|
|
48
|
+
* browser rejects that pairing anyway, so honoring it would produce a request
|
|
49
49
|
* that fails in the client with a CORS error and a server that believes it
|
|
50
50
|
* allowed the call. Echoing the origin instead would be a *quiet widening* of
|
|
51
51
|
* what the app asked for. Returning null makes the misconfiguration visible as
|
|
@@ -97,7 +97,7 @@ export function corsHeaders(
|
|
|
97
97
|
/**
|
|
98
98
|
* The response to a preflight, or `null` if this is not one.
|
|
99
99
|
*
|
|
100
|
-
* A preflight is `OPTIONS` *with* `Access-Control-Request-Method
|
|
100
|
+
* A preflight is `OPTIONS` *with* `Access-Control-Request-Method`: plain
|
|
101
101
|
* `OPTIONS` is a normal request and must fall through to routing, or an app
|
|
102
102
|
* with its own OPTIONS route would find it shadowed.
|
|
103
103
|
*
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* One implementation, deliberately in core: the db-explorer and analytics
|
|
6
6
|
* plugins each need it and per-plugin copies of security code drift. It owns
|
|
7
|
-
* *only* the comparison
|
|
7
|
+
* *only* the comparison: no logins, no sessions, no backoff.
|
|
8
8
|
*
|
|
9
9
|
* A credential is presented three ways, in this order of preference:
|
|
10
10
|
*
|
|
11
11
|
* - `x-<name>` header (scriptable, never logged by default)
|
|
12
12
|
* - `Authorization: Bearer <credential>`
|
|
13
13
|
* - `?<name>=<credential>` query, for a human opening a URL in a browser
|
|
14
|
-
* who cannot set a header
|
|
14
|
+
* who cannot set a header. The caller is expected to strip it from the
|
|
15
15
|
* URL client-side; it *does* reach server logs, which is documented, and
|
|
16
16
|
* the header forms exist for anything automated.
|
|
17
17
|
*
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* variable unset must mean "off", not "open".
|
|
21
21
|
*
|
|
22
22
|
* The compare is constant-time. A plain `===` on a secret is a timing oracle,
|
|
23
|
-
* and `timingSafeEqual` costs one line
|
|
23
|
+
* and `timingSafeEqual` costs one line, but it throws on length mismatch, so
|
|
24
24
|
* the length is checked first (which leaks only the length, never the bytes).
|
|
25
25
|
*/
|
|
26
26
|
|
|
@@ -41,7 +41,7 @@ export function credentialMatches(
|
|
|
41
41
|
/**
|
|
42
42
|
* Pull a presented credential off a request: the `x-<name>` header, a Bearer
|
|
43
43
|
* token, or the `<name>` query parameter, in that order. Returns `null` when
|
|
44
|
-
* none is present. `name` is the header/query key
|
|
44
|
+
* none is present. `name` is the header/query key: `db-key`, `analytics-key`.
|
|
45
45
|
*/
|
|
46
46
|
export function readCredential(req: Request, name: string): string | null {
|
|
47
47
|
const header = req.headers.get(`x-${name}`)
|
package/src/utils/http/csrf.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Cross-site request forgery guards.
|
|
3
3
|
*
|
|
4
4
|
* A `SameSite=Lax` cookie is still sent on top-level cross-site *navigations*,
|
|
5
|
-
* so it is not on its own sufficient to protect a state-changing endpoint
|
|
5
|
+
* so it is not on its own sufficient to protect a state-changing endpoint:
|
|
6
6
|
* `<a href="/api/admin/delete?id=5">` or a cross-origin `<form>` POST both
|
|
7
7
|
* arrive with the victim's session attached.
|
|
8
8
|
*/
|
|
@@ -17,7 +17,7 @@ const SAME_SITE_VALUES = new Set(['same-origin', 'same-site', 'none'])
|
|
|
17
17
|
/**
|
|
18
18
|
* Returns a reason string when the request looks cross-site, or `null` when it
|
|
19
19
|
* is safe to process. Requests with neither `Origin` nor `Sec-Fetch-Site` (curl,
|
|
20
|
-
* server-to-server, older clients) are allowed
|
|
20
|
+
* server-to-server, older clients) are allowed: browsers always send at least
|
|
21
21
|
* one of them on a cross-origin request.
|
|
22
22
|
*/
|
|
23
23
|
export function checkSameOrigin(req: Request, url: URL): string | null {
|
|
@@ -46,7 +46,7 @@ export function checkCsrf(req: Request, url: URL): string | null {
|
|
|
46
46
|
/**
|
|
47
47
|
* An attacker-supplied header value, made safe to put in a reason string.
|
|
48
48
|
*
|
|
49
|
-
* Reason strings are logged, and the logger renders one record per line
|
|
49
|
+
* Reason strings are logged, and the logger renders one record per line, so
|
|
50
50
|
* an `Origin` carrying a newline could forge additional log entries. Anything
|
|
51
51
|
* outside printable ASCII goes, and the result is capped: a rejection reason
|
|
52
52
|
* is a diagnostic, not a transcript.
|
|
@@ -57,14 +57,13 @@ function safeEcho(value: string): string {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Origin guard for WebSocket upgrades
|
|
60
|
+
* Origin guard for WebSocket upgrades: deliberately stricter than
|
|
61
61
|
* `checkSameOrigin`, and applied in `upgradeWebsocket` so every socket
|
|
62
62
|
* inherits it rather than each handler having to remember.
|
|
63
63
|
*
|
|
64
64
|
* WebSockets are exempt from the same-origin policy. Any page the developer
|
|
65
65
|
* visits can open `ws://localhost:3000/_livereload`, and until this existed
|
|
66
|
-
* one `{"type":"subscribe_logger"}` bought a live feed of the server log
|
|
67
|
-
* failing SQL, table and column names, absolute source paths — plus the
|
|
66
|
+
* one `{"type":"subscribe_logger"}` bought a live feed of the server log ( * failing SQL, table and column names, absolute source paths) plus the
|
|
68
67
|
* ability to reload every open browser. The only thing that separates the
|
|
69
68
|
* app's own page from an attacker's is `Origin`, which the browser sets on
|
|
70
69
|
* every handshake and page script cannot override.
|
|
@@ -89,7 +88,7 @@ function safeEcho(value: string): string {
|
|
|
89
88
|
*
|
|
90
89
|
* An **absent** `Origin` is allowed, and that is a decision rather than an
|
|
91
90
|
* oversight. A browser never omits it on a handshake, so its absence means the
|
|
92
|
-
* peer is not a browser
|
|
91
|
+
* peer is not a browser: curl, a test, a CLI, a health check. Such a client
|
|
93
92
|
* can also set `Origin` to whatever it likes, so refusing the absent case buys
|
|
94
93
|
* no security at all while breaking every non-browser client. This check is
|
|
95
94
|
* worth exactly what a browser's inability to forge the header is worth, and
|
package/src/utils/http/dom.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { Try } from '../common/try'
|
|
|
7
7
|
import { fs } from '../fs'
|
|
8
8
|
import { escapeScriptJson } from '../isomorphic/escape'
|
|
9
9
|
|
|
10
|
-
// Keyed by hostname, which comes from the Host header
|
|
10
|
+
// Keyed by hostname, which comes from the Host header: bounded so a client
|
|
11
11
|
// cannot grow it without limit by varying that header per request.
|
|
12
12
|
const headBodyCache = new LRUCache<string, { head: string; body: string }>(64)
|
|
13
13
|
|
|
@@ -22,12 +22,12 @@ let depMap = ''
|
|
|
22
22
|
const hostDepMaps = new Map<string, string>()
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
25
|
+
* Normalize one import-map entry, for both the process-level map and the
|
|
26
26
|
* per-host maps.
|
|
27
27
|
*
|
|
28
28
|
* One helper, two callers, deliberately: they used to be separate copies that
|
|
29
|
-
* had drifted into testing different things
|
|
30
|
-
* the other on the entry value
|
|
29
|
+
* had drifted into testing different things (one switched on the entry key,
|
|
30
|
+
* the other on the entry value), so the same input normalized two ways
|
|
31
31
|
* depending on which path saw it.
|
|
32
32
|
*
|
|
33
33
|
* `@client/utils` is keyed on the *key*: it is a live alias, the default
|
|
@@ -75,12 +75,12 @@ let installedCache: Promise<string[]> | null = null
|
|
|
75
75
|
*
|
|
76
76
|
* Read from `node_modules` rather than from `dependencies`, and the difference
|
|
77
77
|
* is the whole point: a package can be installed and imported without being
|
|
78
|
-
* declared
|
|
78
|
+
* declared (a transitive one, or a dependency someone forgot to add), and the
|
|
79
79
|
* browser's failure for a specifier the map misses names its own rule rather
|
|
80
80
|
* than the missing entry: *"Failed to resolve module specifier 'pkg'. Relative
|
|
81
81
|
* references must start with either "/", "./", or "../"."*
|
|
82
82
|
*
|
|
83
|
-
* Cheap
|
|
83
|
+
* Cheap: one `readdir` per scope, no `package.json` reads, and memoized per
|
|
84
84
|
* process besides: the import map reads it at boot, `bundleModule` on every
|
|
85
85
|
* bundle (as its `external` list), and a `readdir` sweep per bundle is pure
|
|
86
86
|
* waste. A dev restart is a new process, so an install still shows up.
|
|
@@ -122,7 +122,7 @@ async function readInstalledPackages(): Promise<string[]> {
|
|
|
122
122
|
*
|
|
123
123
|
* **Resolution happens at the other end of the URL, and rewriting happens
|
|
124
124
|
* nowhere.** An entry maps a package to `/_nm/<name>`; `NMHandler` hands that to
|
|
125
|
-
* `Bun.build`, which applies real browser resolution
|
|
125
|
+
* `Bun.build`, which applies real browser resolution: `exports` maps,
|
|
126
126
|
* conditions, the `browser` field. Naming an entry file here instead would mean
|
|
127
127
|
* reimplementing all of that, badly.
|
|
128
128
|
*
|
|
@@ -168,7 +168,7 @@ export namespace DOMTools {
|
|
|
168
168
|
|
|
169
169
|
// Was a hand-rolled `JSON.stringify(...).replace(/</g, ...)`, which is the
|
|
170
170
|
// same job `escapeScriptJson` already does for every other inline-script
|
|
171
|
-
// payload
|
|
171
|
+
// payload: its docstring even said "Mirrors utils/http/dom.ts". The two
|
|
172
172
|
// had drifted: this copy never escaped U+2028/U+2029.
|
|
173
173
|
return `<script>window.__PAGE_PARAMS__ = ${escapeScriptJson(newParams)}</script>`
|
|
174
174
|
}
|
|
@@ -190,7 +190,7 @@ export namespace DOMTools {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* ResponseInit carrying `data`'s status and headers, minus Content-Length
|
|
193
|
+
* ResponseInit carrying `data`'s status and headers, minus Content-Length:
|
|
194
194
|
* injection changes the body length, so a stale value must not survive into
|
|
195
195
|
* the rebuilt Response. Shared by the buffered and streamed injection paths
|
|
196
196
|
* so the two cannot drift on which headers survive.
|
package/src/utils/http/etag.ts
CHANGED
|
@@ -17,7 +17,7 @@ export namespace ETag {
|
|
|
17
17
|
return `W/"${hash.toString(36)}"`
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** The one writer of the file-etag format
|
|
20
|
+
/** The one writer of the file-etag format: `fromFile` and the negotiation
|
|
21
21
|
* memo below must agree byte-for-byte, or a warm hit would break every
|
|
22
22
|
* `If-None-Match` a cold response handed out. */
|
|
23
23
|
function fileTag(size: number, mtime: number): string {
|
|
@@ -78,8 +78,8 @@ export namespace ETag {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Per-base-path record of which compressed siblings exist, anchored on the
|
|
81
|
-
* base file's mtime. `getOrCreateCachedFile`
|
|
82
|
-
* files
|
|
81
|
+
* base file's mtime. `getOrCreateCachedFile` (the only writer of these
|
|
82
|
+
* files) writes the raw file and every variant together in one
|
|
83
83
|
* `Promise.all`, so the base mtime changes whenever the variant set does;
|
|
84
84
|
* that makes the mtime the invalidation signal, and no write-side hook is
|
|
85
85
|
* needed (fs.ts stays unaware of this module, keeping the dependency arrow
|
|
@@ -92,7 +92,7 @@ export namespace ETag {
|
|
|
92
92
|
*
|
|
93
93
|
* Only *complete* variant sets are recorded. A partial set means either a
|
|
94
94
|
* build caught mid-write (the trio lands over a few ms) or user-authored
|
|
95
|
-
* precompressed siblings next to a served `.gz
|
|
95
|
+
* precompressed siblings next to a served `.gz`: in both cases the set can
|
|
96
96
|
* change without the base mtime moving, so those fall back to per-request
|
|
97
97
|
* probing, which is exactly the pre-memo behavior.
|
|
98
98
|
*/
|
|
@@ -102,8 +102,7 @@ export namespace ETag {
|
|
|
102
102
|
/**
|
|
103
103
|
* One stat, and the values are kept rather than discarded: a `BunFile`
|
|
104
104
|
* caches its stat on first property access (~66us fresh, ~40ns after), so
|
|
105
|
-
* routing the probe through `fs.exists`
|
|
106
|
-
* made every served variant pay a second, identical stat in `fromFile`.
|
|
105
|
+
* routing the probe through `fs.exists` (which stats a throwaway instance) * made every served variant pay a second, identical stat in `fromFile`.
|
|
107
106
|
* Same existence predicate as `fs.exists`; note a missing file reports
|
|
108
107
|
* size 0 and a far-future sentinel `lastModified`, never 0.
|
|
109
108
|
*/
|
|
@@ -119,7 +118,7 @@ export namespace ETag {
|
|
|
119
118
|
|
|
120
119
|
/**
|
|
121
120
|
* Test seam (convention 9): lets etag.test.ts count sibling probes without
|
|
122
|
-
* module-mocking `bun` or `node:fs
|
|
121
|
+
* module-mocking `bun` or `node:fs`, which is process-global and never
|
|
123
122
|
* unwinds. Same shape as `fs.__setForbiddenProbe`.
|
|
124
123
|
*/
|
|
125
124
|
export function __setVariantProbe(fn: (path: string) => VariantStat | null) {
|
|
@@ -136,7 +135,7 @@ export namespace ETag {
|
|
|
136
135
|
negotiationMemo.clear()
|
|
137
136
|
}
|
|
138
137
|
|
|
139
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: content negotiation
|
|
138
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: content negotiation, one branch per encoding outcome
|
|
140
139
|
function negotiateFile(file: Bun.BunFile, req?: Request) {
|
|
141
140
|
const fileName = file.name || 'file'
|
|
142
141
|
|
|
@@ -174,7 +173,7 @@ export namespace ETag {
|
|
|
174
173
|
)
|
|
175
174
|
|
|
176
175
|
// The memo is consulted only for a base that exists right now, and only
|
|
177
|
-
// when its recorded mtime matches the anchor exactly
|
|
176
|
+
// when its recorded mtime matches the anchor exactly: a wiped cache
|
|
178
177
|
// dir (missing base reports the far-future sentinel mtime) or any
|
|
179
178
|
// rewrite falls through to a fresh probe.
|
|
180
179
|
let entry = baseExists ? negotiationMemo.get(basePath) : undefined
|
|
@@ -231,13 +230,55 @@ export namespace ETag {
|
|
|
231
230
|
if (conditionalRes) return conditionalRes
|
|
232
231
|
}
|
|
233
232
|
|
|
233
|
+
// Range handling itself lives in Bun.serve, not here: any Response whose
|
|
234
|
+
// body is a *path-backed* BunFile is sliced by the runtime (206 with
|
|
235
|
+
// Content-Range on a satisfiable single range, 416 past EOF), including
|
|
236
|
+
// the negotiated compressed variants above (the range then addresses the
|
|
237
|
+
// encoded bytes, which is what RFC 9110 says a range means). What the
|
|
238
|
+
// runtime does not do is advertise: a plain 200 or a HEAD said nothing,
|
|
239
|
+
// so players that probe HEAD for `Accept-Ranges` before attempting seeks
|
|
240
|
+
// never tried. Advertised here because this is the one funnel every
|
|
241
|
+
// file-serving handler's BunFile passes through, and only here: an
|
|
242
|
+
// in-memory Blob or a `sendText` string body ignores Range entirely
|
|
243
|
+
// (served whole, 200), which is why `.name` gates the claim.
|
|
244
|
+
//
|
|
245
|
+
// Skipped when Bun's own range path is about to answer (a GET carrying a
|
|
246
|
+
// single-range `Range`): that path appends its own `Accept-Ranges: bytes`
|
|
247
|
+
// to the 206/416, and setting it here too emitted `bytes, bytes`.
|
|
248
|
+
//
|
|
249
|
+
// A **multipart** range (`bytes=0-1,10-11`) is carved back out of the
|
|
250
|
+
// skip: Bun does not do multipart (it serves the whole file as a 200 and
|
|
251
|
+
// appends nothing), so the skip left that response advertising nothing at
|
|
252
|
+
// all, which reads as "ranges not supported" to the one client that just
|
|
253
|
+
// demonstrated it wants them. Found by a downstream smoke test. The comma
|
|
254
|
+
// is the entire multipart grammar, so this cannot drift from Bun's own
|
|
255
|
+
// parse the way a real Range parser here could; RFC-legal multipart gets
|
|
256
|
+
// the 200-with-advertisement it deserves. What stays excluded-and-silent
|
|
257
|
+
// is a *malformed* single range (`Range: potato`), also served whole by
|
|
258
|
+
// Bun, but that requester sent garbage, and mirroring Bun's full validity
|
|
259
|
+
// judgment here is exactly the second-parser drift this comment refuses.
|
|
260
|
+
// HEAD ignores `Range` and gets the header even when one is present.
|
|
261
|
+
const rangeValue = req?.headers.get('range')
|
|
262
|
+
if (
|
|
263
|
+
resolvedFile.name &&
|
|
264
|
+
!(
|
|
265
|
+
req &&
|
|
266
|
+
req.method === 'GET' &&
|
|
267
|
+
rangeValue !== null &&
|
|
268
|
+
rangeValue !== undefined &&
|
|
269
|
+
!rangeValue.includes(',')
|
|
270
|
+
)
|
|
271
|
+
) {
|
|
272
|
+
headers['Accept-Ranges'] = 'bytes'
|
|
273
|
+
}
|
|
274
|
+
|
|
234
275
|
return new Response(resolvedFile, { headers })
|
|
235
276
|
}
|
|
236
277
|
|
|
237
278
|
/**
|
|
238
|
-
* Returns synchronously on every path that does not compress
|
|
279
|
+
* Returns synchronously on every path that does not compress: no request,
|
|
239
280
|
* small body, unsupported Accept-Encoding, and crucially the 304
|
|
240
|
-
* short-circuit
|
|
281
|
+
* short-circuit, and returns a Promise only when compression actually
|
|
241
282
|
* runs on a body large enough to offload (`ASYNC_COMPRESSION_MIN`). The
|
|
242
283
|
* one call site that can receive either (`processResponse`) awaits.
|
|
243
284
|
*/
|
|
@@ -265,7 +306,7 @@ export namespace ETag {
|
|
|
265
306
|
'Content-Type': type,
|
|
266
307
|
}
|
|
267
308
|
|
|
268
|
-
// Negotiation only
|
|
309
|
+
// Negotiation only: no compression yet. The ext suffix the etag embeds
|
|
269
310
|
// is decided by the Accept-Encoding header alone, so the 304 check can
|
|
270
311
|
// run first: a client that already holds the body must not cost a
|
|
271
312
|
// zstd pass whose output is then thrown away.
|
|
@@ -302,7 +343,7 @@ export namespace ETag {
|
|
|
302
343
|
|
|
303
344
|
// Off the request thread for large bodies: the sync pass measured
|
|
304
345
|
// ~700us of event-loop stall per 135KB response. Below the cutoff the
|
|
305
|
-
// pool round-trip costs more than the stall it removes
|
|
346
|
+
// pool round-trip costs more than the stall it removes: numbers on
|
|
306
347
|
// `ASYNC_COMPRESSION_MIN` in fs.ts.
|
|
307
348
|
if (encoderAsync && text.length >= ASYNC_COMPRESSION_MIN) {
|
|
308
349
|
return encoderAsync(text).then(
|
package/src/utils/http/html.ts
CHANGED
|
@@ -4,7 +4,16 @@ import { Try } from '../common/try'
|
|
|
4
4
|
import { DOMTools, headBodyCache } from './dom'
|
|
5
5
|
import { ETag } from './etag'
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Mark `res` as already carrying the head/body injects, so `injectIfHtml` ( * and therefore `processResponse`, which every response funnels through) * hands it back untouched.
|
|
9
|
+
*
|
|
10
|
+
* Exported for the one caller that means "never", not "already":
|
|
11
|
+
* `DefaultErrorHandler` brands its production error page so the import map is
|
|
12
|
+
* not spliced into it. That map names every installed package (an inventory
|
|
13
|
+
* of the app's module layout), and the page carries no scripts for it (or the
|
|
14
|
+
* client bundle) to serve anyway.
|
|
15
|
+
*/
|
|
16
|
+
export function injectBrand(res: Response) {
|
|
8
17
|
return Object.defineProperty(res, '__injected__', {
|
|
9
18
|
value: true,
|
|
10
19
|
enumerable: false,
|
|
@@ -19,7 +28,7 @@ function isInjected(res: Response) {
|
|
|
19
28
|
* Rebuild `res` with `status`, carrying the injection brand across.
|
|
20
29
|
*
|
|
21
30
|
* `Response.status` is read-only, so changing it means constructing a new
|
|
22
|
-
* Response
|
|
31
|
+
* Response, and a plain rebuild silently drops the `__injected__` marker,
|
|
23
32
|
* which is the only thing stopping `processResponse` running the page through
|
|
24
33
|
* `injectIfHtml` a second time and emitting two import maps and two copies of
|
|
25
34
|
* the client bundle. That is why this lives next to the brand rather than at
|
|
@@ -40,7 +49,7 @@ export function withStatus(res: Response, status: number): Response {
|
|
|
40
49
|
/**
|
|
41
50
|
* Server-controlled markup injected into the document. Deliberately a separate
|
|
42
51
|
* argument from `params`: `params` is request-derived (for GET it is the query
|
|
43
|
-
* string), so `$$head`-style keys arriving from a client must never be
|
|
52
|
+
* string), so `$$head`-style keys arriving from a client must never be honored.
|
|
44
53
|
*/
|
|
45
54
|
export interface HtmlInjects {
|
|
46
55
|
head?: string
|
|
@@ -73,7 +82,7 @@ export interface HtmlInjects {
|
|
|
73
82
|
* have sent
|
|
74
83
|
*
|
|
75
84
|
* Buffered responses carry a strong content ETag for the 304 machinery.
|
|
76
|
-
* Streamed responses carry none
|
|
85
|
+
* Streamed responses carry none. See `streamedResponse` for why that is not
|
|
77
86
|
* faked.
|
|
78
87
|
*/
|
|
79
88
|
export const STREAM_THRESHOLD_BYTES = 64 * 1024
|
|
@@ -85,7 +94,7 @@ export async function injectIfHtml(
|
|
|
85
94
|
): Promise<Response | null> {
|
|
86
95
|
if (data instanceof Response && isInjected(data)) return data
|
|
87
96
|
|
|
88
|
-
// {{param}} substitution (and only that
|
|
97
|
+
// {{param}} substitution (and only that: the __PAGE_PARAMS__ script is a
|
|
89
98
|
// head fragment either way) needs the whole document in one string, so a
|
|
90
99
|
// params-bearing call takes the buffered path regardless of size.
|
|
91
100
|
const hasParams = !!params && Object.keys(params).length > 0
|
|
@@ -123,7 +132,7 @@ export async function injectIfHtml(
|
|
|
123
132
|
return bufferedResponse(content, params, injects, responseInit)
|
|
124
133
|
}
|
|
125
134
|
|
|
126
|
-
/** The one writer of the buffered injection Response
|
|
135
|
+
/** The one writer of the buffered injection Response: main path and the
|
|
127
136
|
* streamed path's fallback both come through here. */
|
|
128
137
|
function bufferedResponse(
|
|
129
138
|
content: string,
|
|
@@ -178,7 +187,7 @@ function getConfigInjects() {
|
|
|
178
187
|
/**
|
|
179
188
|
* The final head/body fragments for one document: config injects + the
|
|
180
189
|
* __PAGE_PARAMS__ script + per-call injects, with `<!--prio-->` resolved.
|
|
181
|
-
* Computed once per response, up front
|
|
190
|
+
* Computed once per response, up front: the streamed path must not defer this
|
|
182
191
|
* into a pull callback, where the AsyncLocalStorage host context that
|
|
183
192
|
* `getConfigInjects` and `DOMTools.importMap` read may no longer be live.
|
|
184
193
|
*/
|
|
@@ -227,7 +236,7 @@ function spliceBodyEnd(html: string, body: string): string {
|
|
|
227
236
|
* each window and the final carry. */
|
|
228
237
|
function rewriteFontsUrls(html: string): string {
|
|
229
238
|
// Both alternatives in RX_GFONTS require the literal `fonts.goog`, so this
|
|
230
|
-
// substring check cannot skip a document the regex would have matched
|
|
239
|
+
// substring check cannot skip a document the regex would have matched, and
|
|
231
240
|
// most documents have no Google Fonts link at all.
|
|
232
241
|
if (!html.includes('fonts.goog')) return html
|
|
233
242
|
return html.replace(RX_GFONTS, () => GFONTS_REWRITE)
|
|
@@ -261,8 +270,8 @@ export function assembleHtml(
|
|
|
261
270
|
// The exact same three rewrites as `assembleHtml`, in the same order, without
|
|
262
271
|
// holding the document in memory. The head splice happens on a bounded probe
|
|
263
272
|
// (the tag is near the start of any real document) using the same
|
|
264
|
-
// `spliceHead`; the body-end insert and the fonts rewrite
|
|
265
|
-
// that force a scan to the end of the document
|
|
273
|
+
// `spliceHead`; the body-end insert and the fonts rewrite (the two passes
|
|
274
|
+
// that force a scan to the end of the document) run as chunk-boundary-safe
|
|
266
275
|
// text stages over the same regexes. Content inserted by the body stage flows
|
|
267
276
|
// through the fonts stage but not back through the body stage, which is
|
|
268
277
|
// exactly the buffered path's head → body → fonts ordering.
|
|
@@ -272,7 +281,7 @@ export function assembleHtml(
|
|
|
272
281
|
* that can be left dangling at a chunk boundary. */
|
|
273
282
|
const BODY_END_HOLDBACK = '</body>'.length - 1
|
|
274
283
|
|
|
275
|
-
/** Longest text RX_GFONTS can match, minus one
|
|
284
|
+
/** Longest text RX_GFONTS can match, minus one: any incomplete match prefix
|
|
276
285
|
* at a window edge is at most this long, so holding back this many characters
|
|
277
286
|
* guarantees no match is ever split across two windows. */
|
|
278
287
|
const FONTS_HOLDBACK = 'https://fonts.googleapis.com/css2'.length - 1
|
|
@@ -281,7 +290,7 @@ const FONTS_HOLDBACK = 'https://fonts.googleapis.com/css2'.length - 1
|
|
|
281
290
|
* Read the response body until either EOF or enough bytes to commit to
|
|
282
291
|
* streaming. Streaming needs two things the probe establishes: proof the body
|
|
283
292
|
* is actually large (unless Content-Length already said so), and a `<head…>`
|
|
284
|
-
* match
|
|
293
|
+
* match, the no-head fallback prepends to the very start of the document,
|
|
285
294
|
* which a stream that has begun emitting can no longer do. Anything that fails
|
|
286
295
|
* those checks is drained and handed to the buffered path, whose output is the
|
|
287
296
|
* long-standing behavior.
|
|
@@ -367,7 +376,7 @@ async function* tailText(
|
|
|
367
376
|
}
|
|
368
377
|
} finally {
|
|
369
378
|
// Reached on normal EOF (no-op) and when the consumer cancels the response
|
|
370
|
-
// mid-stream
|
|
379
|
+
// mid-stream: in which case the source must be canceled too.
|
|
371
380
|
await Try(() => reader.cancel())
|
|
372
381
|
}
|
|
373
382
|
}
|
|
@@ -382,9 +391,9 @@ async function* withPrefix(
|
|
|
382
391
|
|
|
383
392
|
/**
|
|
384
393
|
* Streaming equivalent of `spliceBodyEnd`: insert before the first literal
|
|
385
|
-
* `</body>` (in original casing
|
|
394
|
+
* `</body>` (in original casing, the insert slices around the match rather
|
|
386
395
|
* than reconstructing it), else append at the very end. Once inserted the
|
|
387
|
-
* stage is a passthrough, so content it inserted is never rescanned
|
|
396
|
+
* stage is a passthrough, so content it inserted is never rescanned: matching
|
|
388
397
|
* the buffered path's single non-global replace.
|
|
389
398
|
*/
|
|
390
399
|
async function* bodyEndStage(
|
|
@@ -424,7 +433,7 @@ async function* bodyEndStage(
|
|
|
424
433
|
/**
|
|
425
434
|
* Streaming equivalent of `rewriteFontsUrls`. Complete matches that start in
|
|
426
435
|
* the emittable region are rewritten (a complete match cannot be invalidated
|
|
427
|
-
* or extended by later input
|
|
436
|
+
* or extended by later input: the pattern has no unbounded or trailing
|
|
428
437
|
* parts); matches or match prefixes inside the holdback stay in the carry for
|
|
429
438
|
* the next window, and the final carry goes through the shared
|
|
430
439
|
* `rewriteFontsUrls` itself.
|
|
@@ -452,7 +461,7 @@ async function* fontsStage(
|
|
|
452
461
|
|
|
453
462
|
// The same fast gate the buffered path uses: no `fonts.goog` in the
|
|
454
463
|
// window means no match in it, and the emit/carry split below does not
|
|
455
|
-
// depend on match presence
|
|
464
|
+
// depend on match presence, a match *prefix* dangling at the window edge
|
|
456
465
|
// sits inside the holdback either way.
|
|
457
466
|
if (window.includes('fonts.goog')) {
|
|
458
467
|
rx.lastIndex = 0
|
|
@@ -477,8 +486,8 @@ async function* fontsStage(
|
|
|
477
486
|
/**
|
|
478
487
|
* Wrap the staged text into a streaming Response.
|
|
479
488
|
*
|
|
480
|
-
* Deliberately no ETag: a content hash would require buffering the body
|
|
481
|
-
* thing this path exists to avoid
|
|
489
|
+
* Deliberately no ETag: a content hash would require buffering the body (the
|
|
490
|
+
* thing this path exists to avoid), and deriving one from anything less (say,
|
|
482
491
|
* source file size+mtime) would keep serving 304s after the config-injected
|
|
483
492
|
* head/body fragments change. Large streamed pages are also where conditional
|
|
484
493
|
* revalidation matters least. Callers relying on the 304 machinery get it on
|
|
@@ -494,7 +503,7 @@ function streamedResponse(
|
|
|
494
503
|
// Buffered responses get `Cache-Control: no-cache` from ETag.sendResponse
|
|
495
504
|
// (guarded the same way: only when the handler set nothing itself). With no
|
|
496
505
|
// ETag that hook never fires here, and a validator-less page left without a
|
|
497
|
-
// cache policy is open to heuristic caching
|
|
506
|
+
// cache policy is open to heuristic caching: a stale page, not just a
|
|
498
507
|
// missed 304.
|
|
499
508
|
if (!headers.has('Cache-Control')) {
|
|
500
509
|
headers.set('Cache-Control', 'no-cache')
|
package/src/utils/http/ip.ts
CHANGED
|
@@ -15,7 +15,7 @@ const TRUSTED_HEADERS = [
|
|
|
15
15
|
export function getClientIp(req: Request): string {
|
|
16
16
|
// `Bakery.config`, not `getConfig()`: this runs inside the request's host
|
|
17
17
|
// store, and the process config is the wrong answer under `hosts`. It was
|
|
18
|
-
// the only reader of the two that disagreed
|
|
18
|
+
// the only reader of the two that disagreed: `getHostname` and the
|
|
19
19
|
// session's `Secure` flag both read the host's `trustProxy`, so one host
|
|
20
20
|
// could have its hostname and cookie resolved from its own config while
|
|
21
21
|
// its client IP was resolved from the base one.
|