ajo-kit 0.1.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/src/server.tsx ADDED
@@ -0,0 +1,584 @@
1
+ import * as html from 'ajo/html'
2
+ import type { Component } from 'ajo'
3
+ import { AsyncLocalStorage } from 'node:async_hooks'
4
+ import polka from 'polka'
5
+ import type { Request, Response, Middleware } from 'polka'
6
+ import { json } from '@polka/parse'
7
+ /** Sends an HTTP response using Polka's send helper. */
8
+ export { default as send } from '@polka/send'
9
+ import send from '@polka/send'
10
+ import App, { resolve, layouts, pages, error, match, parts, parents } from './app'
11
+ import { Failure, links, ancestors, normalize, ajax, api } from './constants'
12
+ import type { State, Data, Entry, Page, Parent, Payload } from './constants'
13
+ import { merge, render as view, type Head } from './head'
14
+ import * as headers from './headers'
15
+ import { bump, fresh, topics as sorted, parse, hash, snapshot, type Versions } from './freshness'
16
+ import { elapsed, finish, log, header, start } from './timing'
17
+ import { script } from './ssr'
18
+ import { handlers as files, wares as stacks } from 'virtual:ajo/handlers'
19
+
20
+ const emitted = new AsyncLocalStorage<Set<string>>()
21
+
22
+ const payload = (head: Head, entries: Data) => ({ data: entries, head })
23
+
24
+ const digest = (head: Head, entries: Data) => hash(JSON.stringify(payload(head, entries)))
25
+
26
+ const metadata = (topics: Set<string>) => {
27
+ const list = [...topics].sort()
28
+ return { topics: list, versions: snapshot(list) }
29
+ }
30
+
31
+ const size = (body: string) => Buffer.byteLength(body)
32
+
33
+ const vary = 'Accept, Cookie'
34
+
35
+ const base = (type?: string) => ({
36
+ 'Cache-Control': 'no-store',
37
+ Vary: vary,
38
+ ...(type && { 'Content-Type': type }),
39
+ })
40
+
41
+ const done = (req: Request, res: Response, status: number, bytes: number, cache?: string) => {
42
+ const result = finish(req.timing, { status, bytes, cache })
43
+
44
+ if (!result) return
45
+
46
+ res.setHeader('Server-Timing', header(result))
47
+ res.setHeader('X-Ajo-Bytes', String(bytes))
48
+ log(`${req.method} ${req.originalUrl}`, result)
49
+ }
50
+
51
+ const write = (req: Request, res: Response, hash?: string, early = false) => {
52
+ const cache = early ? 'fresh' : 'revalidated'
53
+
54
+ res.statusCode = 304
55
+ headers.set(res, base())
56
+ res.setHeader('X-Ajo-Cache', cache)
57
+ if (hash) res.setHeader('ETag', `"${hash}"`)
58
+ done(req, res, 304, 0, cache)
59
+ res.end()
60
+ }
61
+
62
+ type Connection = {
63
+ req: Request
64
+ auth: 'anonymous' | 'bearer' | 'session' | 'user'
65
+ topics: Set<string>
66
+ hash: string
67
+ verify?: () => Promise<boolean>
68
+ revalidate: () => Promise<Payload>
69
+ send: (message: { data: Payload; hash: string; topics: string[]; versions: Versions }) => void
70
+ close: () => void
71
+ }
72
+
73
+ const connections = new Set<Connection>()
74
+
75
+ const pending = new Set<string>()
76
+
77
+ let debounce: NodeJS.Timeout | null = null
78
+
79
+ const limit = 4
80
+
81
+ const mode = (req: Request): Connection['auth'] => {
82
+ if (req.token) return 'bearer'
83
+ if (req.session) return 'session'
84
+ if (req.user) return 'user'
85
+ return 'anonymous'
86
+ }
87
+
88
+ const matches = (conn: Connection, topics: Set<string>) => {
89
+ return [...topics].some(topic => conn.topics.has(topic))
90
+ }
91
+
92
+ const probe = () => {
93
+ const headers = new Map<string, string | number | readonly string[]>()
94
+ const res = {
95
+ statusCode: 200,
96
+ writableEnded: false,
97
+ setHeader(key: string, value: string | number | readonly string[]) {
98
+ headers.set(key.toLowerCase(), value)
99
+ return res
100
+ },
101
+ getHeader(key: string) {
102
+ return headers.get(key.toLowerCase())
103
+ },
104
+ hasHeader(key: string) {
105
+ return headers.has(key.toLowerCase())
106
+ },
107
+ removeHeader(key: string) {
108
+ headers.delete(key.toLowerCase())
109
+ },
110
+ writeHead(status: number, values?: Record<string, string | number | readonly string[]>) {
111
+ res.statusCode = status
112
+ if (values) {
113
+ for (const [key, value] of Object.entries(values)) {
114
+ if (value !== undefined) res.setHeader(key, value)
115
+ }
116
+ }
117
+ return res
118
+ },
119
+ write() {
120
+ res.writableEnded = true
121
+ return true
122
+ },
123
+ end() {
124
+ res.writableEnded = true
125
+ return res
126
+ }
127
+ }
128
+
129
+ return res as unknown as Response
130
+ }
131
+
132
+ const run = (ware: Middleware, req: Request) => new Promise<boolean>((resolve, reject) => {
133
+ const res = probe()
134
+ let settled = false
135
+ const settle = (value: boolean) => {
136
+ if (settled) return
137
+ settled = true
138
+ resolve(value)
139
+ }
140
+ const fail = (err: unknown) => {
141
+ if (settled) return
142
+ settled = true
143
+ reject(err)
144
+ }
145
+
146
+ try {
147
+ const result = ware(req, res, err => err ? fail(err) : settle(true))
148
+ Promise.resolve(result).then(() => {
149
+ if (!settled) settle(false)
150
+ }, fail)
151
+ } catch (err) {
152
+ fail(err)
153
+ }
154
+ })
155
+
156
+ const verify = async (req: Request, wares: Middleware[]) => {
157
+ for (const ware of wares) {
158
+ if (!await run(ware, req)) return false
159
+ }
160
+
161
+ return true
162
+ }
163
+
164
+ const each = async <T,>(items: T[], limit: number, run: (item: T) => Promise<void>) => {
165
+ let index = 0
166
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
167
+ while (index < items.length) await run(items[index++])
168
+ })
169
+
170
+ await Promise.all(workers)
171
+ }
172
+
173
+ const close = (conn: Connection, reason?: string) => {
174
+ if (reason) {
175
+ console.warn('[SSE] Closing live connection:', {
176
+ reason,
177
+ path: conn.req.path,
178
+ auth: conn.auth,
179
+ })
180
+ }
181
+
182
+ connections.delete(conn)
183
+ conn.close()
184
+ }
185
+
186
+ const revalidate = async (conn: Connection) => {
187
+ try {
188
+ if (!connections.has(conn)) return
189
+
190
+ if (conn.verify && !await conn.verify()) {
191
+ close(conn, 'credential revalidation failed')
192
+ return
193
+ }
194
+
195
+ conn.req.topics = new Set<string>()
196
+ const data = await conn.revalidate()
197
+ conn.topics = conn.req.topics ?? new Set<string>()
198
+ const [head, ...entries] = data
199
+ const hash = digest(head, entries)
200
+
201
+ if (hash === conn.hash) return
202
+
203
+ conn.hash = hash
204
+ conn.send({
205
+ data: data,
206
+ hash,
207
+ ...metadata(conn.topics)
208
+ })
209
+
210
+ } catch (err) {
211
+ console.error('[SSE] Live update failed:', err)
212
+ close(conn)
213
+ }
214
+ }
215
+
216
+ /** Marks live data topics as changed and notifies matching SSE clients. */
217
+ export function emit(topic: string | string[]) {
218
+
219
+ const topics = bump(topic)
220
+ const store = emitted.getStore()
221
+
222
+ topics.forEach(t => {
223
+ pending.add(t)
224
+ store?.add(t)
225
+ })
226
+
227
+ if (debounce) return
228
+
229
+ debounce = setTimeout(async () => {
230
+ const current = new Set(pending)
231
+ pending.clear()
232
+ debounce = null
233
+
234
+ const affected = [...connections].filter(conn => matches(conn, current))
235
+ await each(affected, limit, revalidate)
236
+ }, 10)
237
+ }
238
+
239
+ type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'head'
240
+
241
+ const methods: Method[] = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']
242
+
243
+ type Api = Partial<Record<Method, Middleware>>
244
+
245
+ type Handler = {
246
+ page?: (req: Request, parent: Parent) => Promise<Entry>
247
+ layout?: (req: Request, parent: Parent) => Promise<Entry>
248
+ head?: (req: Request, parent: Parent) => Promise<Head>
249
+ actions?: Record<string, (req: Request, res: Response) => Promise<unknown>>
250
+ }
251
+
252
+ type Template = (slots: Record<string, string>) => string
253
+
254
+ const parser = json()
255
+
256
+ const body: Middleware = (req, res, next) => {
257
+ let called = false
258
+ const done = (err?: Parameters<typeof next>[0]) => {
259
+ if (called) return
260
+ called = true
261
+ next(err)
262
+ }
263
+
264
+ try {
265
+ parser(req, res, done)
266
+ } catch (err) {
267
+ done(err as Parameters<typeof next>[0])
268
+ }
269
+ }
270
+
271
+ /** Creates the SSR Polka app from an HTML slot template. */
272
+ export async function create(template: Template) {
273
+
274
+ const secure: Middleware = (_, res, next) => {
275
+ headers.set(res, headers.security(), true)
276
+ next()
277
+ }
278
+
279
+ const timing: Middleware = (req, _, next) => {
280
+ req.timing = start()
281
+ next()
282
+ }
283
+
284
+ const data = (page: Page, stack: Middleware[]): Middleware => async (req, res, next) => {
285
+
286
+ req.topics = new Set<string>()
287
+ req.verifyLive = () => verify(req, stack)
288
+
289
+ req.track = (topic: string | string[]) => {
290
+ if (Array.isArray(topic)) topic.forEach(t => req.topics!.add(t))
291
+ else req.topics!.add(topic)
292
+ }
293
+
294
+ const paths = parents(page.segments)
295
+ const key = page.segments.join('/')
296
+
297
+ if (ajax(req) && fresh(parse(req.headers['x-ajo-versions']))) {
298
+ if (req.timing) req.timing.loader = 0
299
+ write(req, res, req.headers['x-have']?.toString(), true)
300
+ return
301
+ }
302
+
303
+ const execute = async () => {
304
+
305
+ req.topics!.clear()
306
+
307
+ const chain = links(paths.length + 1)
308
+
309
+ const run = async (
310
+ loader: ((req: Request, parent: Parent) => Promise<Entry>) | undefined,
311
+ depth: number,
312
+ ): Promise<Entry> => {
313
+ const { parent, deferred } = chain[depth]
314
+ try {
315
+
316
+ const result = await (loader?.(req, parent) ?? Promise.resolve({}))
317
+ deferred.resolve(result)
318
+
319
+ return result
320
+
321
+ } catch (err) {
322
+ deferred.reject(normalize(err))
323
+ throw err
324
+ }
325
+ }
326
+
327
+ const layout = await Promise.all(paths.map((path, depth) => run(handlers.get(path)?.layout, depth)))
328
+ const entry = await run(handlers.get(key)?.page, paths.length)
329
+ const data = [...layout, entry]
330
+
331
+ const heads = await Promise.all([
332
+ ...paths.map((path, index) => handlers.get(path)?.head?.(req, async () => data[index]) ?? Promise.resolve({})),
333
+ handlers.get(key)?.head?.(req, async () => entry) ?? Promise.resolve({})
334
+ ])
335
+
336
+ return [merge(...heads), ...data] as Payload
337
+ }
338
+
339
+ const begun = performance.now()
340
+
341
+ try {
342
+
343
+ const result = await execute();
344
+ if (req.timing) req.timing.loader = elapsed(begun)
345
+
346
+ req.revalidate = execute
347
+ req.head = result[0]
348
+ req.entries = result.slice(1)
349
+
350
+ next()
351
+
352
+ } catch (err) {
353
+ if (req.timing) req.timing.loader = elapsed(begun)
354
+ next(normalize(err))
355
+ }
356
+ }
357
+
358
+ const sse: Middleware = (req, res, next) => {
359
+
360
+ if (req.headers.accept !== 'text/event-stream') return next()
361
+
362
+ res.writeHead(200, {
363
+ 'Content-Type': 'text/event-stream',
364
+ 'Cache-Control': 'no-cache',
365
+ 'Connection': 'keep-alive'
366
+ })
367
+
368
+ res.flushHeaders()
369
+
370
+ const head = (req.head ?? {}) as Head
371
+ const entries = (req.entries ?? []) as Data
372
+ const hash = digest(head, entries)
373
+
374
+ const conn: Connection = {
375
+ req,
376
+ auth: mode(req),
377
+ topics: req.topics ?? new Set<string>(),
378
+ hash,
379
+ verify: req.verifyLive,
380
+ revalidate: req.revalidate!,
381
+ send: (message) => res.write(`data: ${JSON.stringify(message)}\n\n`),
382
+ close: () => {}
383
+ }
384
+
385
+ connections.add(conn)
386
+
387
+ const heartbeat = setInterval(() => res.write(':hb\n\n'), 30000)
388
+ let closed = false
389
+
390
+ const cleanup = () => {
391
+ if (closed) return
392
+ closed = true
393
+ clearInterval(heartbeat)
394
+ connections.delete(conn)
395
+ }
396
+
397
+ conn.close = () => {
398
+ cleanup()
399
+ if (!res.writableEnded) res.end()
400
+ }
401
+
402
+ req.socket?.on('close', cleanup)
403
+ }
404
+
405
+ const render = async (req: Request, res: Response, page: Page, error?: Failure) => {
406
+
407
+ const begun = performance.now()
408
+ const head = (req.head ?? {}) as Head
409
+ const entries = (req.entries ?? []) as Data
410
+
411
+ if (ajax(req)) {
412
+
413
+ headers.set(res, base('application/json; charset=utf-8'))
414
+
415
+ if (error) {
416
+ const body = JSON.stringify({ error: error.toJSON() })
417
+
418
+ if (req.timing) req.timing.render = elapsed(begun)
419
+ done(req, res, error.status, size(body))
420
+
421
+ return send(res, error.status, body)
422
+ }
423
+
424
+ const body = payload(head, entries)
425
+ const hash = digest(head, entries)
426
+ const match = req.headers['x-have'] === hash || req.headers['if-none-match'] === `"${hash}"`
427
+ const meta = metadata(req.topics ?? new Set<string>())
428
+
429
+ res.setHeader('ETag', `"${hash}"`)
430
+
431
+ if (match) {
432
+ if (req.timing) req.timing.render = elapsed(begun)
433
+ write(req, res, hash)
434
+ return
435
+ }
436
+
437
+ res.setHeader('X-Ajo-Cache', 'miss')
438
+
439
+ const response = JSON.stringify({ ...body, hash, ...meta })
440
+
441
+ if (req.timing) req.timing.render = elapsed(begun)
442
+ done(req, res, 200, size(response), 'miss')
443
+
444
+ return send(res, 200, response)
445
+ }
446
+
447
+ let resolved: { page: Component; state?: State } | undefined
448
+
449
+ for await (const r of resolve(req.originalUrl, layouts, page, entries, error)) resolved = r
450
+
451
+ const hash = error ? undefined : digest(head, entries)
452
+ const meta = metadata(req.topics ?? new Set<string>())
453
+ const status = resolved?.state?.error?.status ?? error?.status ?? 200
454
+ const state = {
455
+ ...resolved!.state,
456
+ error: resolved!.state?.error?.toJSON?.() ?? resolved!.state?.error,
457
+ head,
458
+ hash,
459
+ ...meta,
460
+ }
461
+ const body = template({
462
+ head: view(head as Head),
463
+ data: script(state),
464
+ root: html.render(<App page={resolved!.page} />),
465
+ })
466
+
467
+ if (req.timing) req.timing.render = elapsed(begun)
468
+ done(req, res, status, size(body))
469
+
470
+ send(
471
+ res,
472
+ status,
473
+ body,
474
+ base('text/html; charset=utf-8')
475
+ )
476
+ }
477
+
478
+ const action = (segments: string[]): Middleware => async (req, res) => {
479
+ const url = new URL(req.originalUrl, `http://${req.headers.host}`)
480
+ const name = [...url.searchParams.keys()].find(key => key.startsWith('/'))?.slice(1) || 'default'
481
+ let handler: ((req: Request, res: Response) => Promise<unknown>) | undefined
482
+
483
+ for (const path of ancestors(segments).filter(path => handlers.has(path)).reverse()) {
484
+ handler = handlers.get(path)?.actions?.[name]
485
+ if (handler) break
486
+ }
487
+
488
+ if (!handler) throw new Failure(400, `Action '${name}' not found`)
489
+
490
+ const topics = new Set<string>()
491
+ const result = await emitted.run(topics, () => handler(req, res)) as { redirect?: string } | void
492
+
493
+ if (ajax(req)) {
494
+ const body = result?.redirect ? { redirect: result.redirect } : (result ?? { ok: true })
495
+ const sent = sorted([...topics])
496
+ const payload = {
497
+ ...body,
498
+ ...(sent.length > 0 && {
499
+ topics: sent,
500
+ versions: snapshot(sent),
501
+ })
502
+ }
503
+
504
+ headers.set(res, base('application/json; charset=utf-8'))
505
+
506
+ send(res, 200, JSON.stringify(payload))
507
+
508
+ return
509
+ }
510
+
511
+ res.statusCode = 302
512
+ res.setHeader('Location', result?.redirect ?? req.originalUrl.split('?')[0])
513
+ res.end()
514
+ }
515
+
516
+ const app = polka({
517
+ onError: (err, req, res) => {
518
+ const normalized = normalize(err)
519
+ if (!(err instanceof Failure) && normalized.status >= 500) console.error(err)
520
+ if (api(req)) send(res, normalized.status, normalized.toJSON())
521
+ else render(req, res, error(), normalized)
522
+ },
523
+ onNoMatch: (req, res) => {
524
+ const missing = new Failure(404, 'Not found')
525
+ if (api(req)) send(res, 404, missing.toJSON())
526
+ else render(req, res, error(), missing)
527
+ }
528
+ })
529
+
530
+ app.use(secure)
531
+
532
+ const collect = (segments: string[]): Middleware[] => ancestors(segments).flatMap(path => wares.get(path) ?? [])
533
+
534
+ const wares = new Map<string, Middleware[]>()
535
+
536
+ for (const [file, loader] of Object.entries(stacks as Record<string, () => Promise<Record<string, unknown>>>)) {
537
+
538
+ const exports = await loader()
539
+ const key = parts(file).join('/')
540
+ const items = Array.isArray(exports.default) ? exports.default : [exports.default]
541
+
542
+ wares.set(key, (wares.get(key) ?? []).concat(items as Middleware[]))
543
+ }
544
+
545
+ const handlers = new Map<string, Handler>()
546
+
547
+ for (const [file, loader] of Object.entries(files as Record<string, () => Promise<Record<string, unknown>>>)) {
548
+
549
+ const exports = await loader()
550
+ const segments = parts(file)
551
+ const key = segments.join('/')
552
+ const pattern = match(segments)
553
+
554
+ const { default: api, page, layout, head, actions } = exports as {
555
+ default?: Api
556
+ page?: Handler['page']
557
+ layout?: Handler['layout']
558
+ head?: Handler['head']
559
+ actions?: Handler['actions']
560
+ }
561
+
562
+ handlers.set(key, { page, layout, head, actions })
563
+
564
+ if (api) {
565
+ for (const method of methods) {
566
+ const route = api[method]
567
+ if (!route) continue
568
+ app[method](`api/${pattern}`, body, ...collect(segments), route)
569
+ }
570
+ }
571
+ }
572
+
573
+ for (const page of pages) {
574
+
575
+ const { pattern, segments } = page
576
+ const path = `/${pattern || ''}`
577
+ const stack = collect(segments)
578
+
579
+ app.get(path, timing, ...stack, data(page, stack), sse, (req, res) => render(req, res, page))
580
+ app.post(path, body, ...stack, action(segments))
581
+ }
582
+
583
+ return app
584
+ }
package/src/ssr.ts ADDED
@@ -0,0 +1,8 @@
1
+ import * as devalue from 'devalue'
2
+
3
+ export const serialize = (value: unknown) => devalue.stringify(value)
4
+
5
+ export const parse = <T = unknown>(value: string) => devalue.parse(value) as T
6
+
7
+ export const script = (value: unknown) =>
8
+ `<script type="application/json" id="__SSR__">${serialize(value)}</script>`
package/src/timing.ts ADDED
@@ -0,0 +1,60 @@
1
+ const disabled = new Set(['', '0', 'false', 'off'])
2
+
3
+ const active = () => {
4
+ const value = typeof process === 'undefined' ? undefined : process.env.AJO_TIMING
5
+ return !!value && !disabled.has(value.toLowerCase())
6
+ }
7
+
8
+ const round = (value: number) => Math.round(value * 10) / 10
9
+
10
+ export type Timing = {
11
+ start: number
12
+ loader?: number
13
+ render?: number
14
+ }
15
+
16
+ export type Result = Timing & {
17
+ total: number
18
+ status: number
19
+ bytes: number
20
+ cache?: string
21
+ }
22
+
23
+ export const start = (): Timing | undefined =>
24
+ active() ? { start: performance.now() } : undefined
25
+
26
+ export const elapsed = (start: number) => round(performance.now() - start)
27
+
28
+ export const finish = (
29
+ timing: Timing | undefined,
30
+ result: Omit<Result, keyof Timing | 'total'>,
31
+ ): Result | undefined => timing && {
32
+ ...timing,
33
+ ...result,
34
+ total: elapsed(timing.start),
35
+ }
36
+
37
+ export const header = (result: Result) => [
38
+ `total;dur=${result.total}`,
39
+ result.loader !== undefined && `loader;dur=${result.loader}`,
40
+ result.render !== undefined && `render;dur=${result.render}`,
41
+ ].filter(Boolean).join(', ')
42
+
43
+ export const log = (label: string, result: Result) => {
44
+ const cache = result.cache ? ` ${result.cache}` : ''
45
+ const loader = result.loader === undefined ? '-' : `${result.loader}ms`
46
+ const render = result.render === undefined ? '-' : `${result.render}ms`
47
+ console.log(`[ajo] ${label} ${result.status}${cache} total=${result.total}ms loader=${loader} render=${render} bytes=${result.bytes}`)
48
+ }
49
+
50
+ export async function measure<T>(label: string, run: () => T | Promise<T>): Promise<T> {
51
+ if (!active()) return run()
52
+
53
+ const start = performance.now()
54
+
55
+ try {
56
+ return await run()
57
+ } finally {
58
+ console.log(`[ajo:timing] ${label} ${elapsed(start)}ms`)
59
+ }
60
+ }
@@ -0,0 +1,33 @@
1
+ import {
2
+ safeParse,
3
+ flatten,
4
+ type GenericSchema,
5
+ type InferOutput,
6
+ } from 'valibot'
7
+ import { Invalid, type Fields } from './constants'
8
+
9
+ /** Valibot schema builders and type helpers re-exported for app validation. */
10
+ export {
11
+ object, string, number, boolean, array, optional, literal,
12
+ pipe, trim, toLowerCase, transform, forward, partialCheck,
13
+ email, minLength, maxLength, unknown,
14
+ type GenericSchema, type InferOutput,
15
+ } from 'valibot'
16
+
17
+ /** Parses data with Valibot and throws Invalid with field errors on failure. */
18
+ export function parse<T extends GenericSchema>(schema: T, data: unknown): InferOutput<T> {
19
+
20
+ const result = safeParse(schema, data)
21
+
22
+ if (result.success) return result.output
23
+
24
+ const flat = flatten<T>(result.issues)
25
+ const fields: Fields = { ...flat.nested }
26
+
27
+ if (flat.root?.length) fields._form = flat.root
28
+
29
+ const nested = flat.nested as Record<string, string[]> | undefined
30
+ const message = flat.root?.[0] ?? Object.values(nested ?? {})[0]?.[0] ?? 'Validation failed'
31
+
32
+ throw new Invalid(fields, message)
33
+ }
@@ -0,0 +1,10 @@
1
+ declare module 'virtual:ajo/routes' {
2
+ type Loader = () => Promise<Record<string, unknown>>
3
+ export const routes: Record<string, Loader>
4
+ }
5
+
6
+ declare module 'virtual:ajo/handlers' {
7
+ type Loader = () => Promise<Record<string, unknown>>
8
+ export const handlers: Record<string, Loader>
9
+ export const wares: Record<string, Loader>
10
+ }