@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,374 @@
1
+ import type { Handler } from './handlers/core/$base'
2
+ import type * as HandlerError from './handlers/core/$error'
3
+ import type * as HandlerRegistry from './handlers/core/$registry'
4
+ import type * as HandlerWs from './handlers/core/$websocket'
5
+ import type * as _logger from './logger/logger'
6
+ import type * as _plugins from './plugins/types'
7
+ import type { Session } from './session'
8
+ import type { MapOf, MixedPromise } from './types'
9
+
10
+ /**
11
+ * File-local, deliberately not global and not exported: its only two uses are
12
+ * the two definitions below it. A global `type Override` is the kind of name a
13
+ * consuming app is likely to want for itself.
14
+ */
15
+ type Override<T, U> = Omit<T, keyof U> & U
16
+
17
+ declare global {
18
+ // Bound on globalThis by core/init.ts, which is why they are declared and
19
+ // not imported. `createElement` and `Fragment` additionally *have* to be
20
+ // global: tsconfig sets jsxFactory/jsxFragmentFactory, and the classic JSX
21
+ // runtime resolves those names in global scope.
22
+ //
23
+ // `var req: Request` and `var body: any` used to live here too. Nothing ever
24
+ // assigned them — not init.ts, not client/utils.ts, nothing — so
25
+ // `req.headers.get('host')` typechecked anywhere in the codebase and threw
26
+ // `ReferenceError: req is not defined` when it ran. A global that only
27
+ // exists in the type system is worse than no global at all.
28
+ var createElement: typeof import('./core/jsx').createElement
29
+ var Fragment: typeof import('./core/jsx').Fragment
30
+ var html: typeof import('./core/jsx').html
31
+
32
+ // JsonResponse and ISFunction are declared once, in shared.d.ts — the
33
+ // browser runtime needs them too. MapOf, Wrapped and MixedPromise are not
34
+ // global at all any more; they are imported from ./types above.
35
+ //
36
+ // `Mutable<T>` used to sit here. It had one declaration and zero consumers
37
+ // anywhere in the repo, so it is deleted rather than moved.
38
+
39
+ type InjectScript = {
40
+ src: string
41
+ module?: boolean
42
+ async?: boolean
43
+ defer?: boolean
44
+ inBody?: boolean
45
+ }
46
+
47
+ type HostEntry = {
48
+ root?: string
49
+ importMap?: Record<string, string>
50
+ middleware?: ((
51
+ req: Request,
52
+ server: Bun.Server<any>,
53
+ ) => MixedPromise<Response | void>)[]
54
+ onRequest?(req: Request): MixedPromise<any>
55
+ onError?(error: Handler.Error.Data): MixedPromise<any>
56
+ head?: string
57
+ body?: string
58
+ proxy?: Record<string, string>
59
+ blocked?: string[]
60
+ rateLimit?:
61
+ | {
62
+ max: number
63
+ refill: number
64
+ keyBy?: (req: Request) => string
65
+ }
66
+ | false
67
+ }
68
+
69
+ type AppConfig = {
70
+ root?: string
71
+
72
+ port?: number
73
+
74
+ host?: string
75
+
76
+ importMap?: Record<string, string>
77
+
78
+ backups?: number
79
+
80
+ proxy?: Record<string, string>
81
+
82
+ head?: string
83
+
84
+ body?: string
85
+
86
+ onStart?(): MixedPromise<void>
87
+
88
+ onRequest?(req: Request): MixedPromise<any>
89
+
90
+ onError?(error: Handler.Error.Data): MixedPromise<any>
91
+
92
+ onShutdown?(): MixedPromise<void>
93
+
94
+ middleware?: ((
95
+ req: Request,
96
+ server: Bun.Server<any>,
97
+ ) => MixedPromise<Response | void>)[]
98
+
99
+ plugins?: ServerPlugin[]
100
+
101
+ websocket?: Bun.WebSocketHandler<any>
102
+
103
+ maxBodySize?: number
104
+
105
+ // `maxCacheSize?: number` was declared and defaulted to 500 here, and read
106
+ // by nothing: the route LRUs size themselves and the tiered cache takes its
107
+ // own options. Deleted rather than wired up — there is no single cache it
108
+ // plausibly governs, and a knob that silently does nothing is worse than an
109
+ // absent one. Removing it before the first publish is a docs note; after,
110
+ // it is a breaking change.
111
+
112
+ blocked?: string[]
113
+
114
+ rateLimit?:
115
+ | {
116
+ max: number
117
+ refill: number
118
+ keyBy?: (req: Request) => string
119
+ }
120
+ | false
121
+
122
+ trustProxy?: boolean
123
+
124
+ /**
125
+ * Where the ORM finds the app's schema — a file, or a folder containing
126
+ * `index.ts`. Relative paths resolve against the app's cwd. Omit it (or
127
+ * leave it empty) to auto-detect `orm/` then `schema.ts`.
128
+ *
129
+ * A plain string, deliberately: core must never depend on `@bakery-framework/orm`,
130
+ * which depends on core, and a path carries no type from it. The ORM reads
131
+ * this through `schemaFromConfig`. A configured path that does not exist is
132
+ * a hard error rather than a fall back to auto-detect — otherwise a typo
133
+ * makes the sync engine generate a fresh schema at the wrong path while the
134
+ * real one sits untouched.
135
+ */
136
+ schema?: string
137
+
138
+ hosts?: Record<string, HostEntry>
139
+ }
140
+
141
+ type ServerPlugin = _plugins.ServerPlugin
142
+
143
+ interface Bakery {
144
+ server?: Bun.Server<any>
145
+ readonly sharedPool: import('./utils/shared-pool').SharedMemoryPool
146
+ readonly cacheDir: string
147
+ readonly dataDir: string
148
+ readonly version: string
149
+ readonly root: string
150
+ readonly serveRoot: string
151
+ readonly apiRoot: string
152
+ readonly publicRoot: string
153
+ readonly config: ProcessedAppConfig
154
+ readonly handlers: {
155
+ readonly fetch: HandlerRegistry.HandlerMap
156
+ readonly error: HandlerRegistry.HandlerMap<
157
+ | typeof HandlerError.ErrorHandler
158
+ | typeof HandlerError.DynamicErrorHandler
159
+ >
160
+ readonly websocket: HandlerRegistry.HandlerMap<
161
+ typeof HandlerWs.WebSocketHandler
162
+ >
163
+ }
164
+ onShutdown(hook: () => Promise<void> | void): void
165
+ readonly shutdownHooks: (() => Promise<void> | void)[]
166
+ readonly startNs: number
167
+ }
168
+
169
+ // DBSchema / DBOptionals are declared by the orm package (its own
170
+ // globals.d.ts), not here: core must not reference orm, which depends on core.
171
+
172
+ type LoggerEntry = _logger.LoggerEntry
173
+ type LogLevels = _logger.LogLevels
174
+
175
+ type ResponseFn = {
176
+ (body?: Bun.BodyInit | null, init?: ResponseInit): Response
177
+ json: ((status: number, message: string, data?: any) => Response) & {
178
+ success: <T>(message: string, data?: T, status?: number) => Response
179
+ error: <T>(status?: number, message?: string, data?: T) => Response
180
+ }
181
+ html: (html: string, status?: number, init?: ResponseInit) => Response
182
+ text: (text: string, status?: number, init?: ResponseInit) => Response
183
+ href: (url: string, status?: 301 | 302 | 307 | 308) => Response
184
+ type: (body: any, contentType: string, init?: ResponseInit) => Response
185
+ error: (
186
+ error: string | Error,
187
+ code?: number,
188
+ init?: ResponseInit,
189
+ ) => Response
190
+ }
191
+
192
+ type ApiCallback<T = any> = (
193
+ req: Request,
194
+ body: MapOf<any>,
195
+ server: Bun.Server<any>,
196
+ ) => MixedPromise<T>
197
+
198
+ namespace JSX {
199
+ type Element = string
200
+
201
+ interface ElementChildrenAttribute {
202
+ children: MapOf<any>
203
+ }
204
+
205
+ interface HTMLAttributes {
206
+ class?: string
207
+ className?: string
208
+ id?: string
209
+ style?: string | Record<string, string | number>
210
+ children?: any
211
+ tabindex?: number | string
212
+ title?: string
213
+
214
+ [key: `data-${string}`]: string | undefined
215
+ [key: `aria-${string}`]: string | undefined
216
+
217
+ [key: string]: any
218
+ }
219
+
220
+ interface AnchorAttributes extends HTMLAttributes {
221
+ href?: string
222
+ target?: string
223
+ rel?: string
224
+ }
225
+ interface ImgAttributes extends HTMLAttributes {
226
+ src?: string
227
+ alt?: string
228
+ width?: string | number
229
+ height?: string | number
230
+ loading?: 'lazy' | 'eager'
231
+ }
232
+ interface InputAttributes extends HTMLAttributes {
233
+ type?: string
234
+ value?: any
235
+ name?: string
236
+ placeholder?: string
237
+ disabled?: boolean
238
+ required?: boolean
239
+ checked?: boolean
240
+ autocomplete?: string
241
+ }
242
+ interface FormAttributes extends HTMLAttributes {
243
+ action?: string
244
+ method?: 'GET' | 'POST' | 'get' | 'post'
245
+ enctype?: string
246
+ }
247
+ interface ScriptAttributes extends HTMLAttributes {
248
+ src?: string
249
+ type?: string
250
+ defer?: boolean
251
+ async?: boolean
252
+ }
253
+ interface LinkAttributes extends HTMLAttributes {
254
+ rel?: string
255
+ href?: string
256
+ as?: string
257
+ type?: string
258
+ }
259
+ interface MetaAttributes extends HTMLAttributes {
260
+ name?: string
261
+ content?: string
262
+ charset?: string
263
+ property?: string
264
+ }
265
+
266
+ interface IntrinsicElements {
267
+ html: HTMLAttributes & { lang?: string }
268
+ head: HTMLAttributes
269
+ body: HTMLAttributes
270
+ title: HTMLAttributes
271
+ meta: MetaAttributes
272
+ link: LinkAttributes
273
+ script: ScriptAttributes
274
+
275
+ div: HTMLAttributes
276
+ span: HTMLAttributes
277
+ p: HTMLAttributes
278
+ h1: HTMLAttributes
279
+ h2: HTMLAttributes
280
+ h3: HTMLAttributes
281
+ h4: HTMLAttributes
282
+ h5: HTMLAttributes
283
+ h6: HTMLAttributes
284
+ ul: HTMLAttributes
285
+ ol: HTMLAttributes
286
+ li: HTMLAttributes
287
+
288
+ a: AnchorAttributes
289
+ img: ImgAttributes
290
+ button: HTMLAttributes & {
291
+ type?: 'button' | 'submit' | 'reset'
292
+ disabled?: boolean
293
+ }
294
+ input: InputAttributes
295
+ textarea: InputAttributes & {
296
+ rows?: number | string
297
+ cols?: number | string
298
+ }
299
+ form: FormAttributes
300
+ select: HTMLAttributes & {
301
+ name?: string
302
+ disabled?: boolean
303
+ required?: boolean
304
+ multiple?: boolean
305
+ }
306
+ option: HTMLAttributes & {
307
+ value?: any
308
+ selected?: boolean
309
+ disabled?: boolean
310
+ }
311
+
312
+ br: HTMLAttributes
313
+ hr: HTMLAttributes
314
+
315
+ [elemName: string]: HTMLAttributes
316
+ }
317
+ }
318
+
319
+ // Defined as getters on process.env by core/init.ts, and substituted into
320
+ // browser bundles by the compiler's `defines`. The client used to redeclare
321
+ // `ImportMeta.env` with its own incompatible shape, which is TS2687/TS2717 —
322
+ // suppressed, like everything else in a .d.ts, by skipLibCheck.
323
+ interface ImportMetaEnv {
324
+ readonly DEV: boolean
325
+ readonly PROD: boolean
326
+ readonly WORKER: boolean
327
+ readonly DEV_WORKER: boolean
328
+ readonly THREAD_WORKER: boolean
329
+ readonly THREAD_ID: string
330
+ readonly TEST: boolean
331
+ readonly MODE: 'production' | 'development' | 'dev-worker' | 'thread-worker'
332
+ // `readonly SERVE_ROOT: string` was declared here. Nothing defines it —
333
+ // not init.ts, not the compiler's `defines` — and nothing reads it, so any
334
+ // code that trusted the declaration would have got `undefined` typed as
335
+ // `string`. The resolved root is `Bakery.serveRoot`; use that.
336
+ /** Compiler define; the only one the browser bundle reads. */
337
+ readonly BAKERY_VERSION: string
338
+ }
339
+
340
+ interface SessionData extends MapOf<any> {}
341
+
342
+ interface Request {
343
+ startNs: number
344
+ session: Session<SessionData>
345
+ __hostname?: string
346
+ }
347
+
348
+ type HandlerName = string & {}
349
+ type HrefPath = string & {}
350
+ type UpgradeData = MapOf<any> | undefined | void
351
+ type WebSocketData<T> = {
352
+ this: typeof HandlerWs.WebSocketHandler
353
+ type: 'websocket'
354
+ orig: HandlerName
355
+ path: HrefPath
356
+ data: T
357
+ }
358
+
359
+ type ProcessedAppConfig = Override<
360
+ Required<AppConfig>,
361
+ {
362
+ blocked: Bun.Glob
363
+ }
364
+ >
365
+
366
+ type ServerWebSocket<T = MapOf<any>> = Override<
367
+ Bun.ServerWebSocket,
368
+ { data: WebSocketData<T> }
369
+ >
370
+
371
+ interface Blob {
372
+ slice(start: number, end?: number, contentType?: string): Blob
373
+ }
374
+ }
@@ -0,0 +1,225 @@
1
+ import { LRUCache } from '../../cache/lru'
2
+ import { Bakery } from '../../core/bakery'
3
+ import { toHash } from '../../utils/common/case'
4
+ import { COMPRESSION_MAP, FileSystem as fs } from '../../utils/fs'
5
+ import { response } from '../../utils/http'
6
+ import { Handler } from '../core/$base'
7
+
8
+ /**
9
+ * The Google Fonts CSS endpoints this proxy is willing to forward to.
10
+ *
11
+ * `css` is the v1 API, `css2` the current one, `icon` the Material icon
12
+ * stylesheet. Everything else on `fonts.googleapis.com` — and anything at all
13
+ * once the path is attacker-supplied — is refused, because this handler is an
14
+ * unauthenticated outbound fetch from the server's own IP and the path used to
15
+ * be forwarded verbatim.
16
+ */
17
+ const GF_CSS_PATHS = new Set(['css', 'css2', 'icon'])
18
+
19
+ /**
20
+ * The documented Google Fonts query parameters.
21
+ *
22
+ * Google answers `200` for a request carrying parameters it does not know, so
23
+ * before this list every `?family=Roboto&junk=<n>` was a fresh cache key *and*
24
+ * a fresh outbound request: six junk requests were measured producing six keys
25
+ * and eighteen files. Names are compared raw, without percent-decoding — a
26
+ * name spelled `%66amily` simply fails the list, which is the safe direction.
27
+ */
28
+ const GF_CSS_PARAMS = new Set([
29
+ 'display',
30
+ 'effect',
31
+ 'family',
32
+ 'icon_names',
33
+ 'subset',
34
+ 'text',
35
+ ])
36
+
37
+ const GF_MAX_PARAMS = 12
38
+ const GF_MAX_QUERY = 512
39
+ const GF_MAX_GSTATIC_PATH = 256
40
+
41
+ /**
42
+ * A path under `fonts.gstatic.com`, as the rewritten CSS references it —
43
+ * `s/roboto/v47/KFOmCnqEu92Fr1Mu4mxK.woff2`.
44
+ *
45
+ * Every segment must start with an alphanumeric, which is what rules out `..`
46
+ * and an empty segment (`//`) without a second pass over the string, and the
47
+ * extension list keeps this to font payloads.
48
+ */
49
+ const GF_GSTATIC_PATH =
50
+ /^[a-zA-Z0-9][a-zA-Z0-9._-]*(?:\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*\.(?:woff2?|ttf|otf|eot|svg)$/
51
+
52
+ /**
53
+ * How many distinct upstream assets this handler will keep on disk.
54
+ *
55
+ * `getOrCreateCachedFile` writes three files per entry (raw, `.zst`, `.gz`)
56
+ * and nothing ever removed them, so the cache directory grew with the number
57
+ * of distinct request URLs — which is to say, without bound. The allow-lists
58
+ * above shrink the key space but do not close it: a family *name* is still
59
+ * free text, and a wrong one costs a 400 from Google rather than a rejection
60
+ * here. This is the bound, in the shape convention 6 asks for.
61
+ *
62
+ * The index is per-process and the files are not, so a restart forgets which
63
+ * files it was tracking. That leaves at most one generation of stale files
64
+ * behind rather than an unbounded pile, and re-requesting an entry re-adopts
65
+ * it; a cache directory is disposable by design.
66
+ */
67
+ const GF_MAX_ENTRIES = 256
68
+
69
+ const cacheIndex = new LRUCache<string, string>(
70
+ GF_MAX_ENTRIES,
71
+ (_key, rawPath) => {
72
+ void fs.rm(rawPath, { force: true })
73
+ for (const { ext } of COMPRESSION_MAP) {
74
+ void fs.rm(`${rawPath}${ext}`, { force: true })
75
+ }
76
+ },
77
+ )
78
+
79
+ /** Record a cache entry against the bound, evicting the oldest if it is full. */
80
+ function trackCacheEntry(cacheDir: string, cacheName: string): void {
81
+ const rawPath = fs.resolve(cacheDir, cacheName)
82
+ cacheIndex.set(rawPath, rawPath)
83
+ }
84
+
85
+ /**
86
+ * Test seams for the bound, in the `__`-prefixed style of `__setTestConfig`.
87
+ *
88
+ * Every other branch of this handler is reachable from a test through
89
+ * `handle()` — the allow-lists refuse before anything is fetched. The eviction
90
+ * path is not: reaching it legitimately means 257 successful round trips to
91
+ * Google, which is not a unit test. These let one assert the bound holds and
92
+ * that an evicted entry takes its `.zst`/`.gz` companions with it.
93
+ */
94
+ export const __gfTrackCacheEntry = trackCacheEntry
95
+ export const __gfCacheIndex: ReadonlyMap<string, string> = cacheIndex
96
+ export const __gfMaxEntries = GF_MAX_ENTRIES
97
+
98
+ /**
99
+ * The query, validated and put in a canonical order, or `null` if it carries
100
+ * anything undocumented.
101
+ *
102
+ * Pairs are sorted as raw text rather than re-encoded through
103
+ * `URLSearchParams`: sorting collapses `?family=X&display=swap` and
104
+ * `?display=swap&family=X` onto one cache key and one upstream request, while
105
+ * leaving each pair's bytes exactly as the client sent them. Re-encoding would
106
+ * turn the `:`, `@` and `;` of a `family=Roboto:wght@400;700` axis spec into
107
+ * percent escapes and change the request going upstream, which is not a thing
108
+ * to do on a live third-party API for a cosmetic win.
109
+ */
110
+ function canonicalQuery(search: string): string | null {
111
+ const raw = search.startsWith('?') ? search.slice(1) : search
112
+ if (!raw) return ''
113
+ if (raw.length > GF_MAX_QUERY) return null
114
+
115
+ const pairs = raw.split('&')
116
+ if (pairs.length > GF_MAX_PARAMS) return null
117
+
118
+ for (const pair of pairs) {
119
+ const name = pair.split('=')[0]
120
+ if (!GF_CSS_PARAMS.has(name)) return null
121
+ }
122
+
123
+ return `?${pairs.sort().join('&')}`
124
+ }
125
+
126
+ export class GoogleFontHandler extends Handler {
127
+ static get cacheDir() {
128
+ return fs.resolve(Bakery.cacheDir, 'gf_cache')
129
+ }
130
+
131
+ static canHandle(path: string): boolean {
132
+ return path === '/_gf' || path.startsWith('/_gf/')
133
+ }
134
+
135
+ static async handle(path: string, req: Request) {
136
+ if (path.startsWith('/_gf/gstatic/')) {
137
+ return this.handleGstatic(path)
138
+ }
139
+
140
+ const rawPath = path.slice('/_gf'.length)
141
+ const strippedPath = rawPath.startsWith('/') ? rawPath.slice(1) : rawPath
142
+ const gfPath = strippedPath || 'css2'
143
+
144
+ if (!GF_CSS_PATHS.has(gfPath)) {
145
+ return response.error('Unknown Google Fonts endpoint', 404)
146
+ }
147
+
148
+ const url = new URL(req.url)
149
+ const searchQuery = canonicalQuery(url.search)
150
+ if (searchQuery === null) {
151
+ return response.error('Unsupported Google Fonts query', 400)
152
+ }
153
+
154
+ const cacheKey = `${gfPath}${searchQuery}`
155
+ const cacheName = `${toHash(cacheKey)}.css`
156
+ const cacheDir = this.cacheDir
157
+
158
+ const cached = await fs.getOrCreateCachedFile(
159
+ cacheDir,
160
+ cacheName,
161
+ null,
162
+ async () => {
163
+ const gfUrl = `https://fonts.googleapis.com/${gfPath}${searchQuery}`
164
+
165
+ const res = await fetch(gfUrl, {
166
+ headers: {
167
+ 'User-Agent':
168
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
169
+ ' (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
170
+ },
171
+ })
172
+
173
+ if (!res.ok) return null
174
+
175
+ let css = await res.text()
176
+ css = css.replace(/https?:\/\/fonts\.gstatic\.com/g, '/_gf/gstatic')
177
+
178
+ return css
179
+ },
180
+ )
181
+
182
+ if (!cached) return response.error('Failed to fetch Google Fonts CSS', 502)
183
+
184
+ trackCacheEntry(cacheDir, cacheName)
185
+ return cached
186
+ }
187
+
188
+ private static async handleGstatic(path: string) {
189
+ const gstaticPath = path.slice('/_gf/gstatic/'.length)
190
+
191
+ // Refused before `this.cacheDir` is read, so a rejection costs neither a
192
+ // config lookup nor a path resolution.
193
+ if (
194
+ gstaticPath.length > GF_MAX_GSTATIC_PATH ||
195
+ !GF_GSTATIC_PATH.test(gstaticPath)
196
+ ) {
197
+ return response.error('Unknown Google Fonts asset', 404)
198
+ }
199
+
200
+ const cacheDir = fs.resolve(this.cacheDir, 'gstatic')
201
+ const cacheExt = `${fs.parse(gstaticPath).ext || '.bin'}`
202
+ const cacheName = `${toHash(gstaticPath)}${cacheExt}`
203
+
204
+ const cached = await fs.getOrCreateCachedFile(
205
+ cacheDir,
206
+ cacheName,
207
+ null,
208
+ async () => {
209
+ const gfUrl = `https://fonts.gstatic.com/${gstaticPath}`
210
+ const res = await fetch(gfUrl)
211
+
212
+ if (!res.ok) return null
213
+
214
+ return res.arrayBuffer()
215
+ },
216
+ )
217
+
218
+ if (!cached) {
219
+ return response.error('Failed to fetch Google Fonts asset', 502)
220
+ }
221
+
222
+ trackCacheEntry(cacheDir, cacheName)
223
+ return cached
224
+ }
225
+ }