@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,525 @@
|
|
|
1
|
+
import { Bakery, hostStore } from '../../core/bakery'
|
|
2
|
+
import type { MapOf } from '../../types'
|
|
3
|
+
import { Try } from '../common/try'
|
|
4
|
+
import { DOMTools, headBodyCache } from './dom'
|
|
5
|
+
import { ETag } from './etag'
|
|
6
|
+
|
|
7
|
+
function injectBrand(res: Response) {
|
|
8
|
+
return Object.defineProperty(res, '__injected__', {
|
|
9
|
+
value: true,
|
|
10
|
+
enumerable: false,
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isInjected(res: Response) {
|
|
15
|
+
return (res as any).__injected__
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Rebuild `res` with `status`, carrying the injection brand across.
|
|
20
|
+
*
|
|
21
|
+
* `Response.status` is read-only, so changing it means constructing a new
|
|
22
|
+
* Response — and a plain rebuild silently drops the `__injected__` marker,
|
|
23
|
+
* which is the only thing stopping `processResponse` running the page through
|
|
24
|
+
* `injectIfHtml` a second time and emitting two import maps and two copies of
|
|
25
|
+
* the client bundle. That is why this lives next to the brand rather than at
|
|
26
|
+
* the call site.
|
|
27
|
+
*/
|
|
28
|
+
export function withStatus(res: Response, status: number): Response {
|
|
29
|
+
if (res.status === status) return res
|
|
30
|
+
|
|
31
|
+
const next = new Response(res.body, {
|
|
32
|
+
status,
|
|
33
|
+
statusText: res.statusText,
|
|
34
|
+
headers: res.headers,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return isInjected(res) ? injectBrand(next) : next
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Server-controlled markup injected into the document. Deliberately a separate
|
|
42
|
+
* 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 honoured.
|
|
44
|
+
*/
|
|
45
|
+
export interface HtmlInjects {
|
|
46
|
+
head?: string
|
|
47
|
+
body?: string
|
|
48
|
+
prio?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bodies at or below this many bytes take the buffered path; above it (or when
|
|
53
|
+
* the size cannot be known without buffering) they stream. It doubles as the
|
|
54
|
+
* probe window: if no `<head…>` tag has appeared within this many bytes, the
|
|
55
|
+
* streamed path gives up and buffers (see `probeAndInject`).
|
|
56
|
+
*
|
|
57
|
+
* Decision matrix (`injectIfHtml`):
|
|
58
|
+
* - string input → buffered (already fully in memory)
|
|
59
|
+
* - {{…}} params substitution needed → buffered, whatever the size (the
|
|
60
|
+
* substitution is a whole-document pass)
|
|
61
|
+
* - Blob, size ≤ threshold → buffered
|
|
62
|
+
* - Blob, size > threshold → streamed
|
|
63
|
+
* - Response, Content-Length ≤ thr. → buffered
|
|
64
|
+
* - Response, Content-Length > thr. → streamed
|
|
65
|
+
* - Response, size unknown → probe: read up to the threshold; EOF
|
|
66
|
+
* first means a small body → buffered
|
|
67
|
+
* (the status quo), otherwise → streamed
|
|
68
|
+
* (the case where buffering hurts most)
|
|
69
|
+
* - streaming chosen, but no `<head…>` match within the probe window
|
|
70
|
+
* → buffered fallback: the no-head case
|
|
71
|
+
* prepends the head fragment *before*
|
|
72
|
+
* content that streaming would already
|
|
73
|
+
* have sent
|
|
74
|
+
*
|
|
75
|
+
* Buffered responses carry a strong content ETag for the 304 machinery.
|
|
76
|
+
* Streamed responses carry none — see `streamedResponse` for why that is not
|
|
77
|
+
* faked.
|
|
78
|
+
*/
|
|
79
|
+
export const STREAM_THRESHOLD_BYTES = 64 * 1024
|
|
80
|
+
|
|
81
|
+
export async function injectIfHtml(
|
|
82
|
+
data: string | Response | Blob,
|
|
83
|
+
params?: MapOf<string>,
|
|
84
|
+
injects?: HtmlInjects,
|
|
85
|
+
): Promise<Response | null> {
|
|
86
|
+
if (data instanceof Response && isInjected(data)) return data
|
|
87
|
+
|
|
88
|
+
// {{param}} substitution (and only that — the __PAGE_PARAMS__ script is a
|
|
89
|
+
// head fragment either way) needs the whole document in one string, so a
|
|
90
|
+
// params-bearing call takes the buffered path regardless of size.
|
|
91
|
+
const hasParams = !!params && Object.keys(params).length > 0
|
|
92
|
+
|
|
93
|
+
if (!hasParams && data instanceof Response && data.body) {
|
|
94
|
+
const contentType = data.headers.get('content-type') || ''
|
|
95
|
+
if (!DOMTools.isHTMLContentType(contentType)) return null
|
|
96
|
+
|
|
97
|
+
const declaredRaw = data.headers.get('content-length')
|
|
98
|
+
const declared = declaredRaw ? Number(declaredRaw) : NaN
|
|
99
|
+
const size =
|
|
100
|
+
Number.isFinite(declared) && declared >= 0 ? declared : undefined
|
|
101
|
+
|
|
102
|
+
if (size === undefined || size > STREAM_THRESHOLD_BYTES) {
|
|
103
|
+
return probeAndInject(
|
|
104
|
+
data.body,
|
|
105
|
+
size !== undefined,
|
|
106
|
+
DOMTools.htmlResponseInit(data),
|
|
107
|
+
injects,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!hasParams && data instanceof Blob) {
|
|
113
|
+
if (!DOMTools.isHTMLContentType(data.type || '')) return null
|
|
114
|
+
|
|
115
|
+
if (data.size > STREAM_THRESHOLD_BYTES) {
|
|
116
|
+
return probeAndInject(data.stream(), true, {}, injects)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { content, responseInit } = await DOMTools.isHTML(data)
|
|
121
|
+
if (!content) return null
|
|
122
|
+
|
|
123
|
+
return bufferedResponse(content, params, injects, responseInit)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The one writer of the buffered injection Response — main path and the
|
|
127
|
+
* streamed path's fallback both come through here. */
|
|
128
|
+
function bufferedResponse(
|
|
129
|
+
content: string,
|
|
130
|
+
params: MapOf<string> | undefined,
|
|
131
|
+
injects: HtmlInjects | undefined,
|
|
132
|
+
responseInit: ResponseInit & { headers?: any },
|
|
133
|
+
): Response {
|
|
134
|
+
const headers = new Headers(responseInit?.headers)
|
|
135
|
+
const html = assembleHtml(content, params, injects)
|
|
136
|
+
|
|
137
|
+
headers.set('Content-Type', 'text/html; charset=utf-8')
|
|
138
|
+
headers.set('ETag', ETag.fromText(html))
|
|
139
|
+
|
|
140
|
+
const response = new Response(html, {
|
|
141
|
+
...responseInit,
|
|
142
|
+
headers,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return injectBrand(response)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const RX_CURLY_PARAMS = /{{\s*([^,\s}]+)(?:\s*,\s*([^}]+))?\s*}}/g
|
|
149
|
+
const RX_GFONTS = /https?:\/\/fonts\.(?:googleapis|google)\.com\/css2/g
|
|
150
|
+
const RX_HEAD_TAG = /<head[^>]*>/i
|
|
151
|
+
const RX_BODY_END = /<\/body>/i
|
|
152
|
+
|
|
153
|
+
const GFONTS_REWRITE = '/_gf/'
|
|
154
|
+
|
|
155
|
+
function getConfigInjects() {
|
|
156
|
+
const host = hostStore.getStore()?.hostname || '__default__'
|
|
157
|
+
const cached = headBodyCache.get(host)
|
|
158
|
+
if (cached) return cached
|
|
159
|
+
|
|
160
|
+
const scripts: string[] = [
|
|
161
|
+
DOMTools.importMap(),
|
|
162
|
+
'<script src="/_client/utils.js" type="module"></script>',
|
|
163
|
+
'<!--prio-->',
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
if (import.meta.env.DEV) {
|
|
167
|
+
scripts.push('<script src="/_client/livereload.js" type="module"></script>')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const head = scripts.join('') + (Bakery.config.head || '')
|
|
171
|
+
const body = Bakery.config.body || ''
|
|
172
|
+
|
|
173
|
+
const result = { head, body }
|
|
174
|
+
headBodyCache.set(host, result)
|
|
175
|
+
return result
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The final head/body fragments for one document: config injects + the
|
|
180
|
+
* __PAGE_PARAMS__ script + per-call injects, with `<!--prio-->` resolved.
|
|
181
|
+
* Computed once per response, up front — the streamed path must not defer this
|
|
182
|
+
* into a pull callback, where the AsyncLocalStorage host context that
|
|
183
|
+
* `getConfigInjects` and `DOMTools.importMap` read may no longer be live.
|
|
184
|
+
*/
|
|
185
|
+
function computeInjects(params: MapOf<string>, injects: HtmlInjects) {
|
|
186
|
+
const configInjects = getConfigInjects()
|
|
187
|
+
const paramsStr = DOMTools.params(params)
|
|
188
|
+
|
|
189
|
+
const headInjects = configInjects.head + paramsStr + (injects.head || '')
|
|
190
|
+
const bodyInjects = configInjects.body + (injects.body || '')
|
|
191
|
+
|
|
192
|
+
// Function replacers throughout this file: the injected strings can contain
|
|
193
|
+
// data derived from a request, and `$&` / `$'` / `` $` `` in a replacement
|
|
194
|
+
// *string* would splice surrounding document content into the output.
|
|
195
|
+
const head = headInjects.replace('<!--prio-->', () => injects.prio || '')
|
|
196
|
+
|
|
197
|
+
return { head, body: bodyInjects }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Insert `head` after the first `<head…>` tag, or prepend it when the
|
|
201
|
+
* document has none. Single writer for both the buffered path and the
|
|
202
|
+
* streamed path's probe. */
|
|
203
|
+
function spliceHead(html: string, head: string): string {
|
|
204
|
+
return RX_HEAD_TAG.test(html)
|
|
205
|
+
? html.replace(RX_HEAD_TAG, match => `${match}${head}`)
|
|
206
|
+
: head + html
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Insert `body` before the first `</body>`, or append it at the very end
|
|
210
|
+
* when the document has none. */
|
|
211
|
+
function spliceBodyEnd(html: string, body: string): string {
|
|
212
|
+
// `test()` then `replace()` scans the document twice, and `</body>` is at the
|
|
213
|
+
// very end, so the discarded `test()` pass is a full scan of the page. One
|
|
214
|
+
// `replace()` with a flag set from the replacer does the same job in one.
|
|
215
|
+
// (The `<head>` case above is left as-is: the tag is near the start, so its
|
|
216
|
+
// `test()` exits early and the same rewrite measured no faster.)
|
|
217
|
+
let matched = false
|
|
218
|
+
const out = html.replace(RX_BODY_END, match => {
|
|
219
|
+
matched = true
|
|
220
|
+
return `${body}${match}`
|
|
221
|
+
})
|
|
222
|
+
return matched ? out : out + body
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Rewrite Google Fonts css2 URLs to the local `/_gf/` proxy. Single writer:
|
|
226
|
+
* the buffered path runs it over the whole document, the streamed path over
|
|
227
|
+
* each window and the final carry. */
|
|
228
|
+
function rewriteFontsUrls(html: string): string {
|
|
229
|
+
// Both alternatives in RX_GFONTS require the literal `fonts.goog`, so this
|
|
230
|
+
// substring check cannot skip a document the regex would have matched — and
|
|
231
|
+
// most documents have no Google Fonts link at all.
|
|
232
|
+
if (!html.includes('fonts.goog')) return html
|
|
233
|
+
return html.replace(RX_GFONTS, () => GFONTS_REWRITE)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function assembleHtml(
|
|
237
|
+
content: string,
|
|
238
|
+
params: MapOf<string> = {},
|
|
239
|
+
injects: HtmlInjects = {},
|
|
240
|
+
) {
|
|
241
|
+
const frags = computeInjects(params, injects)
|
|
242
|
+
|
|
243
|
+
let html = spliceHead(content, frags.head)
|
|
244
|
+
html = spliceBodyEnd(html, frags.body)
|
|
245
|
+
html = rewriteFontsUrls(html)
|
|
246
|
+
|
|
247
|
+
const hasParams = Object.keys(params).length > 0
|
|
248
|
+
if (hasParams) {
|
|
249
|
+
html = html.replace(RX_CURLY_PARAMS, (_, key, fallback) => {
|
|
250
|
+
const val = params?.[key] ?? fallback?.trim()
|
|
251
|
+
return val !== undefined ? Bun.escapeHTML(String(val)) : `{{${key}}}`
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return html
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Streamed injection
|
|
260
|
+
//
|
|
261
|
+
// The exact same three rewrites as `assembleHtml`, in the same order, without
|
|
262
|
+
// holding the document in memory. The head splice happens on a bounded probe
|
|
263
|
+
// (the tag is near the start of any real document) using the same
|
|
264
|
+
// `spliceHead`; the body-end insert and the fonts rewrite — the two passes
|
|
265
|
+
// that force a scan to the end of the document — run as chunk-boundary-safe
|
|
266
|
+
// text stages over the same regexes. Content inserted by the body stage flows
|
|
267
|
+
// through the fonts stage but not back through the body stage, which is
|
|
268
|
+
// exactly the buffered path's head → body → fonts ordering.
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/** `'</body>'.length - 1`: the longest proper prefix of the body-end pattern
|
|
272
|
+
* that can be left dangling at a chunk boundary. */
|
|
273
|
+
const BODY_END_HOLDBACK = '</body>'.length - 1
|
|
274
|
+
|
|
275
|
+
/** Longest text RX_GFONTS can match, minus one — any incomplete match prefix
|
|
276
|
+
* at a window edge is at most this long, so holding back this many characters
|
|
277
|
+
* guarantees no match is ever split across two windows. */
|
|
278
|
+
const FONTS_HOLDBACK = 'https://fonts.googleapis.com/css2'.length - 1
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Read the response body until either EOF or enough bytes to commit to
|
|
282
|
+
* streaming. Streaming needs two things the probe establishes: proof the body
|
|
283
|
+
* is actually large (unless Content-Length already said so), and a `<head…>`
|
|
284
|
+
* match — the no-head fallback prepends to the very start of the document,
|
|
285
|
+
* which a stream that has begun emitting can no longer do. Anything that fails
|
|
286
|
+
* those checks is drained and handed to the buffered path, whose output is the
|
|
287
|
+
* long-standing behavior.
|
|
288
|
+
*/
|
|
289
|
+
async function probeAndInject(
|
|
290
|
+
source: ReadableStream<Uint8Array>,
|
|
291
|
+
knownLarge: boolean,
|
|
292
|
+
responseInit: ResponseInit & { headers?: any },
|
|
293
|
+
injects?: HtmlInjects,
|
|
294
|
+
): Promise<Response | null> {
|
|
295
|
+
const reader = source.getReader()
|
|
296
|
+
const decoder = new TextDecoder()
|
|
297
|
+
|
|
298
|
+
let text = ''
|
|
299
|
+
let bytes = 0
|
|
300
|
+
let head: RegExpExecArray | null = null
|
|
301
|
+
let eof = false
|
|
302
|
+
let commit = false
|
|
303
|
+
|
|
304
|
+
while (true) {
|
|
305
|
+
const { done, value } = await reader.read()
|
|
306
|
+
if (done) {
|
|
307
|
+
eof = true
|
|
308
|
+
text += decoder.decode()
|
|
309
|
+
break
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
bytes += value.length
|
|
313
|
+
text += decoder.decode(value, { stream: true })
|
|
314
|
+
|
|
315
|
+
if (!head) head = RX_HEAD_TAG.exec(text)
|
|
316
|
+
|
|
317
|
+
if (head && (knownLarge || bytes > STREAM_THRESHOLD_BYTES)) {
|
|
318
|
+
commit = true
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Head tag not within the probe window: stop growing the probe and fall
|
|
323
|
+
// back to buffering the rest.
|
|
324
|
+
if (!head && bytes > STREAM_THRESHOLD_BYTES) break
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!commit) {
|
|
328
|
+
if (!eof) {
|
|
329
|
+
while (true) {
|
|
330
|
+
const { done, value } = await reader.read()
|
|
331
|
+
if (done) {
|
|
332
|
+
text += decoder.decode()
|
|
333
|
+
break
|
|
334
|
+
}
|
|
335
|
+
text += decoder.decode(value, { stream: true })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!text) return null
|
|
340
|
+
return bufferedResponse(text, undefined, injects, responseInit)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const frags = computeInjects({}, injects || {})
|
|
344
|
+
const probeOut = spliceHead(text, frags.head)
|
|
345
|
+
const staged = fontsStage(
|
|
346
|
+
bodyEndStage(withPrefix(probeOut, tailText(reader, decoder)), frags.body),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return streamedResponse(staged, responseInit)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Decode the unread remainder of the probe's reader as text pieces. */
|
|
353
|
+
async function* tailText(
|
|
354
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
355
|
+
decoder: TextDecoder,
|
|
356
|
+
): AsyncGenerator<string> {
|
|
357
|
+
try {
|
|
358
|
+
while (true) {
|
|
359
|
+
const { done, value } = await reader.read()
|
|
360
|
+
if (done) {
|
|
361
|
+
const rest = decoder.decode()
|
|
362
|
+
if (rest) yield rest
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
const piece = decoder.decode(value, { stream: true })
|
|
366
|
+
if (piece) yield piece
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
// Reached on normal EOF (no-op) and when the consumer cancels the response
|
|
370
|
+
// mid-stream — in which case the source must be cancelled too.
|
|
371
|
+
await Try(() => reader.cancel())
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function* withPrefix(
|
|
376
|
+
prefix: string,
|
|
377
|
+
rest: AsyncGenerator<string>,
|
|
378
|
+
): AsyncGenerator<string> {
|
|
379
|
+
yield prefix
|
|
380
|
+
yield* rest
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Streaming equivalent of `spliceBodyEnd`: insert before the first literal
|
|
385
|
+
* `</body>` (in original casing — the insert slices around the match rather
|
|
386
|
+
* than reconstructing it), else append at the very end. Once inserted the
|
|
387
|
+
* stage is a passthrough, so content it inserted is never rescanned — matching
|
|
388
|
+
* the buffered path's single non-global replace.
|
|
389
|
+
*/
|
|
390
|
+
async function* bodyEndStage(
|
|
391
|
+
src: AsyncGenerator<string>,
|
|
392
|
+
body: string,
|
|
393
|
+
): AsyncGenerator<string> {
|
|
394
|
+
let carry = ''
|
|
395
|
+
let injected = false
|
|
396
|
+
|
|
397
|
+
for await (const chunk of src) {
|
|
398
|
+
if (injected) {
|
|
399
|
+
yield chunk
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const window = carry + chunk
|
|
404
|
+
// Non-global regex: exec ignores lastIndex, safe on the shared instance.
|
|
405
|
+
const match = RX_BODY_END.exec(window)
|
|
406
|
+
|
|
407
|
+
if (match) {
|
|
408
|
+
injected = true
|
|
409
|
+
carry = ''
|
|
410
|
+
yield window.slice(0, match.index) + body + window.slice(match.index)
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const hold = Math.min(window.length, BODY_END_HOLDBACK)
|
|
415
|
+
const emit = window.slice(0, window.length - hold)
|
|
416
|
+
carry = window.slice(window.length - hold)
|
|
417
|
+
if (emit) yield emit
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (carry) yield carry
|
|
421
|
+
if (!injected && body) yield body
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Streaming equivalent of `rewriteFontsUrls`. Complete matches that start in
|
|
426
|
+
* the emittable region are rewritten (a complete match cannot be invalidated
|
|
427
|
+
* or extended by later input — the pattern has no unbounded or trailing
|
|
428
|
+
* parts); matches or match prefixes inside the holdback stay in the carry for
|
|
429
|
+
* the next window, and the final carry goes through the shared
|
|
430
|
+
* `rewriteFontsUrls` itself.
|
|
431
|
+
*/
|
|
432
|
+
async function* fontsStage(
|
|
433
|
+
src: AsyncGenerator<string>,
|
|
434
|
+
): AsyncGenerator<string> {
|
|
435
|
+
let carry = ''
|
|
436
|
+
// Fresh instance: the module-level RX_GFONTS is `/g` and this loop drives it
|
|
437
|
+
// via exec/lastIndex, which must not be shared across interleaved responses.
|
|
438
|
+
const rx = new RegExp(RX_GFONTS.source, RX_GFONTS.flags)
|
|
439
|
+
|
|
440
|
+
for await (const chunk of src) {
|
|
441
|
+
const window = carry + chunk
|
|
442
|
+
|
|
443
|
+
if (window.length <= FONTS_HOLDBACK) {
|
|
444
|
+
carry = window
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const emitEnd = window.length - FONTS_HOLDBACK
|
|
449
|
+
let out = ''
|
|
450
|
+
let last = 0
|
|
451
|
+
let match: RegExpExecArray | null
|
|
452
|
+
|
|
453
|
+
// The same fast gate the buffered path uses: no `fonts.goog` in the
|
|
454
|
+
// window means no match in it, and the emit/carry split below does not
|
|
455
|
+
// depend on match presence — a match *prefix* dangling at the window edge
|
|
456
|
+
// sits inside the holdback either way.
|
|
457
|
+
if (window.includes('fonts.goog')) {
|
|
458
|
+
rx.lastIndex = 0
|
|
459
|
+
while ((match = rx.exec(window))) {
|
|
460
|
+
// Starts inside the holdback: defer to the next window (matches are
|
|
461
|
+
// non-overlapping, so nothing already emitted can reach it).
|
|
462
|
+
if (match.index >= emitEnd) break
|
|
463
|
+
out += window.slice(last, match.index) + GFONTS_REWRITE
|
|
464
|
+
last = match.index + match[0].length
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const cut = Math.max(emitEnd, last)
|
|
469
|
+
out += window.slice(last, cut)
|
|
470
|
+
carry = window.slice(cut)
|
|
471
|
+
if (out) yield out
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (carry) yield rewriteFontsUrls(carry)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Wrap the staged text into a streaming Response.
|
|
479
|
+
*
|
|
480
|
+
* Deliberately no ETag: a content hash would require buffering the body — the
|
|
481
|
+
* thing this path exists to avoid — and deriving one from anything less (say,
|
|
482
|
+
* source file size+mtime) would keep serving 304s after the config-injected
|
|
483
|
+
* head/body fragments change. Large streamed pages are also where conditional
|
|
484
|
+
* revalidation matters least. Callers relying on the 304 machinery get it on
|
|
485
|
+
* the buffered path, which every params-bearing and small response takes.
|
|
486
|
+
*/
|
|
487
|
+
function streamedResponse(
|
|
488
|
+
parts: AsyncGenerator<string>,
|
|
489
|
+
responseInit: ResponseInit & { headers?: any },
|
|
490
|
+
): Response {
|
|
491
|
+
const headers = new Headers(responseInit?.headers)
|
|
492
|
+
headers.set('Content-Type', 'text/html; charset=utf-8')
|
|
493
|
+
|
|
494
|
+
// Buffered responses get `Cache-Control: no-cache` from ETag.sendResponse
|
|
495
|
+
// (guarded the same way: only when the handler set nothing itself). With no
|
|
496
|
+
// ETag that hook never fires here, and a validator-less page left without a
|
|
497
|
+
// cache policy is open to heuristic caching — a stale page, not just a
|
|
498
|
+
// missed 304.
|
|
499
|
+
if (!headers.has('Cache-Control')) {
|
|
500
|
+
headers.set('Cache-Control', 'no-cache')
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const encoder = new TextEncoder()
|
|
504
|
+
const body = new ReadableStream<Uint8Array>({
|
|
505
|
+
async pull(controller) {
|
|
506
|
+
const { done, value } = await parts.next()
|
|
507
|
+
if (done) {
|
|
508
|
+
controller.close()
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
if (value) controller.enqueue(encoder.encode(value))
|
|
512
|
+
},
|
|
513
|
+
async cancel() {
|
|
514
|
+
// Runs the generators' finally blocks, which cancel the source reader.
|
|
515
|
+
await Try(() => parts.return(undefined))
|
|
516
|
+
},
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
const response = new Response(body, {
|
|
520
|
+
...responseInit,
|
|
521
|
+
headers,
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
return injectBrand(response)
|
|
525
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Bakery } from '../../core/bakery'
|
|
2
|
+
|
|
3
|
+
const TRUSTED_HEADERS = [
|
|
4
|
+
'cf-connecting-ip',
|
|
5
|
+
'x-forwarded-for',
|
|
6
|
+
'x-real-ip',
|
|
7
|
+
'x-client-ip',
|
|
8
|
+
'fastly-client-ip',
|
|
9
|
+
'true-client-ip',
|
|
10
|
+
'x-forwarded',
|
|
11
|
+
'forwarded-for',
|
|
12
|
+
'forwarded',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export function getClientIp(req: Request): string {
|
|
16
|
+
// `Bakery.config`, not `getConfig()`: this runs inside the request's host
|
|
17
|
+
// store, and the process config is the wrong answer under `hosts`. It was
|
|
18
|
+
// the only reader of the two that disagreed — `getHostname` and the
|
|
19
|
+
// session's `Secure` flag both read the host's `trustProxy`, so one host
|
|
20
|
+
// could have its hostname and cookie resolved from its own config while
|
|
21
|
+
// its client IP was resolved from the base one.
|
|
22
|
+
if (!Bakery.config.trustProxy) {
|
|
23
|
+
return Bakery.server?.requestIP(req)?.address || ''
|
|
24
|
+
}
|
|
25
|
+
for (const header of TRUSTED_HEADERS) {
|
|
26
|
+
const value = req.headers.get(header)
|
|
27
|
+
if (!value) continue
|
|
28
|
+
const first = value.split(',')[0].trim()
|
|
29
|
+
if (first) return first
|
|
30
|
+
}
|
|
31
|
+
return Bakery.server?.requestIP(req)?.address || ''
|
|
32
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type JsonResponseData, jsonResponse } from '../common/json'
|
|
2
|
+
import { is } from '../common/misc'
|
|
3
|
+
import { ETag } from './etag'
|
|
4
|
+
|
|
5
|
+
export function response(body: Bun.BodyInit | null, init?: ResponseInit) {
|
|
6
|
+
return new Response(body as any, init)
|
|
7
|
+
}
|
|
8
|
+
export type ResponseJsonFactory = typeof jsonResponse & {
|
|
9
|
+
success: <T>(
|
|
10
|
+
message: string,
|
|
11
|
+
data?: T,
|
|
12
|
+
status?: number,
|
|
13
|
+
) => JsonResponseData<T>
|
|
14
|
+
error: <T>(status?: number, message?: string, data?: T) => JsonResponseData<T>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const responseJson = jsonResponse as ResponseJsonFactory
|
|
18
|
+
responseJson.success = function responseJsonSuccess<T>(
|
|
19
|
+
message: string,
|
|
20
|
+
data?: T,
|
|
21
|
+
status = 200,
|
|
22
|
+
) {
|
|
23
|
+
return responseJson(status, message, data)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
responseJson.error = function responseJsonError<T>(
|
|
27
|
+
status: any = 404,
|
|
28
|
+
message = 'Error',
|
|
29
|
+
data?: T,
|
|
30
|
+
) {
|
|
31
|
+
if (typeof status === 'string') {
|
|
32
|
+
data = message as any
|
|
33
|
+
message = status
|
|
34
|
+
status = 400
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
typeof status !== 'number' ||
|
|
38
|
+
Number.isNaN(status) ||
|
|
39
|
+
status < 100 ||
|
|
40
|
+
status > 599
|
|
41
|
+
) {
|
|
42
|
+
status = 400
|
|
43
|
+
}
|
|
44
|
+
return responseJson(status, message, data)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function attachData(content: any, type: string, init?: ResponseInit): Response {
|
|
48
|
+
const etag = ETag.fromText(String(content))
|
|
49
|
+
const headers = new Headers(init?.headers as any)
|
|
50
|
+
|
|
51
|
+
headers.set('Content-Type', type)
|
|
52
|
+
headers.set('ETag', etag)
|
|
53
|
+
|
|
54
|
+
return new Response(content, {
|
|
55
|
+
...init,
|
|
56
|
+
headers,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
response.json = responseJson
|
|
61
|
+
|
|
62
|
+
response.html = function responseHTML(
|
|
63
|
+
html: string,
|
|
64
|
+
status = 200,
|
|
65
|
+
init?: ResponseInit,
|
|
66
|
+
) {
|
|
67
|
+
return attachData(html, 'text/html; charset=utf-8', {
|
|
68
|
+
...init,
|
|
69
|
+
status,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
response.text = function responseText(
|
|
74
|
+
text: string,
|
|
75
|
+
status = 200,
|
|
76
|
+
init?: ResponseInit,
|
|
77
|
+
) {
|
|
78
|
+
return attachData(text, 'text/plain; charset=utf-8', {
|
|
79
|
+
...init,
|
|
80
|
+
status,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function redirect(url: string, status = 302) {
|
|
85
|
+
return Response.redirect(url, status)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
response.href = redirect
|
|
89
|
+
|
|
90
|
+
// Everything a reason-phrase may not contain: CR/LF would split headers, and
|
|
91
|
+
// bytes outside printable ASCII can make a Response constructor throw.
|
|
92
|
+
const RX_UNSAFE_STATUS_TEXT = /[^\t\x20-\x7e]+/g
|
|
93
|
+
|
|
94
|
+
response.error = function responseError(
|
|
95
|
+
error: string | Error,
|
|
96
|
+
code = 404,
|
|
97
|
+
init?: ResponseInit,
|
|
98
|
+
) {
|
|
99
|
+
error = typeof error === 'string' ? error : error.message
|
|
100
|
+
// The message rides in the body as well as statusText: HTTP/2 drops the
|
|
101
|
+
// reason-phrase entirely and browsers never render it, so a null-bodied
|
|
102
|
+
// error was a blank page everywhere it mattered.
|
|
103
|
+
return attachData(error, 'text/plain; charset=utf-8', {
|
|
104
|
+
...init,
|
|
105
|
+
status: code,
|
|
106
|
+
statusText: error.replace(RX_UNSAFE_STATUS_TEXT, ' ').trim(),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
response.type = function responseType(
|
|
111
|
+
body: Bun.BodyInit | null,
|
|
112
|
+
contentType: string,
|
|
113
|
+
init?: ResponseInit,
|
|
114
|
+
) {
|
|
115
|
+
let etag = ''
|
|
116
|
+
|
|
117
|
+
if (is.string(body)) {
|
|
118
|
+
etag = ETag.fromText(body)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const headers = new Headers(init?.headers as any)
|
|
122
|
+
headers.set('Content-Type', contentType)
|
|
123
|
+
etag && headers.set('ETag', etag)
|
|
124
|
+
|
|
125
|
+
return new Response(body as any, {
|
|
126
|
+
...init,
|
|
127
|
+
headers,
|
|
128
|
+
})
|
|
129
|
+
}
|