@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
package/src/global.d.ts
ADDED
|
@@ -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
|
+
}
|