@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.
Files changed (91) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +89 -0
  3. package/package.json +69 -0
  4. package/src/cache/index.ts +8 -0
  5. package/src/cache/lru.ts +41 -0
  6. package/src/cache/shared-db.ts +51 -0
  7. package/src/cache/string.ts +150 -0
  8. package/src/cache/tiered.ts +493 -0
  9. package/src/client/globals.d.ts +74 -0
  10. package/src/client/livereload.ts +437 -0
  11. package/src/client/utils.ts +315 -0
  12. package/src/compiler/compiler.ts +263 -0
  13. package/src/compiler/dev-service.ts +660 -0
  14. package/src/compiler/index.ts +2 -0
  15. package/src/compiler/prompt-tracker.ts +36 -0
  16. package/src/compiler/tsconfig-sync.ts +71 -0
  17. package/src/core/bakery.ts +96 -0
  18. package/src/core/cache-version.ts +119 -0
  19. package/src/core/config.ts +296 -0
  20. package/src/core/context.ts +121 -0
  21. package/src/core/index.ts +61 -0
  22. package/src/core/init.ts +90 -0
  23. package/src/core/jsx.ts +152 -0
  24. package/src/core/paths.ts +24 -0
  25. package/src/core/plugins.ts +120 -0
  26. package/src/core/port.ts +73 -0
  27. package/src/global.d.ts +374 -0
  28. package/src/handlers/assets/google-font.ts +225 -0
  29. package/src/handlers/assets/image.ts +136 -0
  30. package/src/handlers/assets/nm.ts +73 -0
  31. package/src/handlers/assets/public.ts +17 -0
  32. package/src/handlers/assets/static.ts +86 -0
  33. package/src/handlers/assets/ts.ts +61 -0
  34. package/src/handlers/assets/tsx.ts +106 -0
  35. package/src/handlers/assets/virtual-asset.ts +104 -0
  36. package/src/handlers/core/$base.ts +256 -0
  37. package/src/handlers/core/$dynamic.ts +285 -0
  38. package/src/handlers/core/$error.ts +301 -0
  39. package/src/handlers/core/$middleware.ts +71 -0
  40. package/src/handlers/core/$mounts.ts +84 -0
  41. package/src/handlers/core/$registry.ts +153 -0
  42. package/src/handlers/core/$routing.ts +205 -0
  43. package/src/handlers/core/$static.ts +100 -0
  44. package/src/handlers/core/$websocket.ts +52 -0
  45. package/src/handlers/index.ts +21 -0
  46. package/src/handlers/routes/api.ts +95 -0
  47. package/src/handlers/routes/html.ts +95 -0
  48. package/src/handlers/routes/livereload.ts +54 -0
  49. package/src/handlers/routes/proxy.ts +74 -0
  50. package/src/logger/clients.ts +12 -0
  51. package/src/logger/index.ts +3 -0
  52. package/src/logger/logger.ts +375 -0
  53. package/src/logger/serve-log.ts +206 -0
  54. package/src/plugins/index.ts +15 -0
  55. package/src/plugins/routes.ts +110 -0
  56. package/src/plugins/types.ts +19 -0
  57. package/src/router.ts +351 -0
  58. package/src/session.ts +556 -0
  59. package/src/shared.d.ts +63 -0
  60. package/src/startup.ts +154 -0
  61. package/src/types.d.ts +111 -0
  62. package/src/utils/common/case.ts +11 -0
  63. package/src/utils/common/index.ts +5 -0
  64. package/src/utils/common/json.ts +35 -0
  65. package/src/utils/common/match.ts +6 -0
  66. package/src/utils/common/misc.ts +53 -0
  67. package/src/utils/common/try.ts +6 -0
  68. package/src/utils/constants.ts +153 -0
  69. package/src/utils/fs.ts +621 -0
  70. package/src/utils/http/body.ts +65 -0
  71. package/src/utils/http/csrf.ts +111 -0
  72. package/src/utils/http/dom.ts +238 -0
  73. package/src/utils/http/escape.ts +8 -0
  74. package/src/utils/http/etag.ts +318 -0
  75. package/src/utils/http/html.ts +525 -0
  76. package/src/utils/http/index.ts +8 -0
  77. package/src/utils/http/ip.ts +32 -0
  78. package/src/utils/http/response.ts +129 -0
  79. package/src/utils/index.ts +4 -0
  80. package/src/utils/isomorphic/case.ts +52 -0
  81. package/src/utils/isomorphic/escape.ts +43 -0
  82. package/src/utils/isomorphic/index.ts +15 -0
  83. package/src/utils/isomorphic/is.ts +36 -0
  84. package/src/utils/isomorphic/match.ts +50 -0
  85. package/src/utils/isomorphic/math.ts +11 -0
  86. package/src/utils/isomorphic/misc.ts +22 -0
  87. package/src/utils/isomorphic/stringify.ts +42 -0
  88. package/src/utils/isomorphic/try.ts +94 -0
  89. package/src/utils/jsonc.ts +10 -0
  90. package/src/utils/shared-pool.ts +193 -0
  91. 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,8 @@
1
+ export * from './body'
2
+ export * from './csrf'
3
+ export * from './dom'
4
+ export * from './escape'
5
+ export * from './etag'
6
+ export * from './html'
7
+ export * from './ip'
8
+ export * from './response'
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export * from './common'
2
+ export * from './fs'
3
+ export * from './http'
4
+ export * from './jsonc'