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