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/LICENSE +15 -0
- package/README.md +468 -0
- package/dist/bin/kit.js +109 -0
- package/dist/chunks/constants-5Eu0naK3.js +168 -0
- package/dist/chunks/discover-D-b8Pqm8.js +34 -0
- package/dist/chunks/headers-BDszzwLw.js +23 -0
- package/dist/chunks/migrate-C9Iytqx4.js +89 -0
- package/dist/chunks/ssr-CCxCGcm6.js +421 -0
- package/dist/client.js +134 -0
- package/dist/database.js +38 -0
- package/dist/index.js +2 -0
- package/dist/mail.js +15 -0
- package/dist/node.js +104 -0
- package/dist/server.js +502 -0
- package/dist/validate.js +15 -0
- package/dist/vite.js +110 -0
- package/package.json +106 -0
- package/src/app.tsx +484 -0
- package/src/cache.ts +85 -0
- package/src/client.tsx +167 -0
- package/src/constants.ts +339 -0
- package/src/database.ts +55 -0
- package/src/discover.ts +42 -0
- package/src/form.ts +39 -0
- package/src/freshness.ts +63 -0
- package/src/head.tsx +125 -0
- package/src/headers.ts +32 -0
- package/src/index.ts +31 -0
- package/src/mail/index.ts +25 -0
- package/src/migrate.ts +132 -0
- package/src/node.ts +134 -0
- package/src/server.tsx +584 -0
- package/src/ssr.ts +8 -0
- package/src/timing.ts +60 -0
- package/src/validate.ts +33 -0
- package/src/virtual.d.ts +10 -0
- package/src/vite.ts +166 -0
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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|
package/src/virtual.d.ts
ADDED
|
@@ -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
|
+
}
|