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/cache.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { State } from './constants'
2
+
3
+ export const max = 50
4
+ export const ttl = 5 * 60 * 1000
5
+
6
+ type Meta = {
7
+ cached: number
8
+ used: number
9
+ }
10
+
11
+ const cache = new Map<string, State>()
12
+
13
+ const meta = new Map<string, Meta>()
14
+
15
+ const now = () => Date.now()
16
+
17
+ const remove = (url: string) => {
18
+ cache.delete(url)
19
+ meta.delete(url)
20
+ }
21
+
22
+ export const clear = () => {
23
+ cache.clear()
24
+ meta.clear()
25
+ }
26
+
27
+ export const get = (url: string, time = now()) => {
28
+ const state = cache.get(url)
29
+ const info = meta.get(url)
30
+
31
+ if (!state || !info) return
32
+
33
+ if (time - info.cached > ttl) {
34
+ remove(url)
35
+ return
36
+ }
37
+
38
+ info.used = time
39
+
40
+ return state
41
+ }
42
+
43
+ const prune = (active?: string, time = now()) => {
44
+ for (const [url, info] of meta) {
45
+ if (url !== active && time - info.cached > ttl) remove(url)
46
+ }
47
+
48
+ while (cache.size > max) {
49
+ let candidate: string | undefined
50
+ let oldest = Infinity
51
+
52
+ for (const [url, info] of meta) {
53
+ if (url === active) continue
54
+ if (info.used < oldest) {
55
+ oldest = info.used
56
+ candidate = url
57
+ }
58
+ }
59
+
60
+ if (!candidate) break
61
+
62
+ remove(candidate)
63
+ }
64
+ }
65
+
66
+ export const set = (url: string, state: State, options?: { active?: string; now?: number }) => {
67
+ const time = options?.now ?? now()
68
+
69
+ cache.set(url, state)
70
+ meta.set(url, { cached: time, used: time })
71
+ prune(options?.active ?? url, time)
72
+ }
73
+
74
+ export const invalidate = (topics?: string[]) => {
75
+ if (!topics?.length) {
76
+ clear()
77
+ return
78
+ }
79
+
80
+ const changed = new Set(topics)
81
+
82
+ for (const [url, state] of cache) {
83
+ if (!state.topics?.length || state.topics.some(topic => changed.has(topic))) remove(url)
84
+ }
85
+ }
package/src/client.tsx ADDED
@@ -0,0 +1,167 @@
1
+ import { render } from 'ajo'
2
+ import { current } from 'ajo/context'
3
+ import App, { init } from './app'
4
+ import type { State, Action } from './constants'
5
+ import { navigate } from './constants'
6
+ import { fields, body as make } from './form'
7
+ import { parse } from './ssr'
8
+ import { invalidate } from './cache'
9
+
10
+ // Action helper for stateful generator components
11
+
12
+ /** Creates state and submit/invoke helpers for a route action. */
13
+ export function action<T = unknown>(name?: string, init?: RequestInit): Action<T> {
14
+
15
+ const component = current()
16
+
17
+ if (!component) throw new Error('action() must be called inside a stateful component.')
18
+
19
+ let controller: AbortController | undefined
20
+
21
+ const state: Action<T> = {
22
+ loading: false,
23
+ data: undefined,
24
+ error: undefined,
25
+ submit: () => { },
26
+ invoke: (value?) => run(value),
27
+ reset: () => {
28
+ const current = controller
29
+
30
+ controller = undefined
31
+ current?.abort()
32
+ state.loading = false
33
+ state.data = undefined
34
+ state.error = undefined
35
+ component.next()
36
+ }
37
+ }
38
+
39
+ const run = async (value: unknown): Promise<T | undefined> => {
40
+
41
+ controller?.abort()
42
+ const current = controller = new AbortController()
43
+
44
+ const forwardAbort = (signal?: AbortSignal | null) => {
45
+ if (!signal) return
46
+ if (signal.aborted) current.abort(signal.reason)
47
+ else signal.addEventListener('abort', () => current.abort(signal.reason), { once: true, signal: current.signal })
48
+ }
49
+
50
+ forwardAbort(component.signal)
51
+ forwardAbort(init?.signal)
52
+
53
+ state.loading = true
54
+ state.error = undefined
55
+ component.next()
56
+
57
+ try {
58
+
59
+ const response = await fetch(name ? `?/${name}` : '', {
60
+ method: 'POST',
61
+ credentials: 'include',
62
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
63
+ body: JSON.stringify(value),
64
+ ...init,
65
+ signal: current.signal,
66
+ })
67
+
68
+ const json = await response.json().catch(() => null) as
69
+ | { redirect?: string; topics?: string[]; versions?: Record<string, number>; error?: { status?: number; message?: string; fields?: Record<string, string[] | undefined> }; message?: string; fields?: Record<string, string[] | undefined> }
70
+ | null
71
+
72
+ if (controller !== current || current.signal.aborted) return
73
+
74
+ if (!response.ok) {
75
+
76
+ state.error = {
77
+ status: json?.error?.status ?? response.status,
78
+ message: json?.error?.message ?? json?.message ?? 'Action failed',
79
+ fields: json?.error?.fields ?? json?.fields
80
+ }
81
+
82
+ return
83
+ }
84
+
85
+ invalidate(json?.topics)
86
+
87
+ if (json?.redirect) {
88
+ navigate(json.redirect)
89
+ return
90
+ }
91
+
92
+ state.data = (json ?? {}) as T
93
+ globalThis.dispatchEvent?.(new CustomEvent('ajo:action', { detail: json ?? {} }))
94
+
95
+ return state.data
96
+
97
+ } catch (error) {
98
+
99
+ if (current.signal.aborted || error instanceof Error && error.name === 'AbortError') return
100
+
101
+ if (controller !== current) return
102
+
103
+ state.error = {
104
+ status: 500,
105
+ message: error instanceof Error ? error.message : 'Action failed'
106
+ }
107
+
108
+ return
109
+
110
+ } finally {
111
+ if (controller === current) {
112
+ controller = undefined
113
+ state.loading = false
114
+ component.next()
115
+ }
116
+ }
117
+ }
118
+
119
+ state.submit = (event: SubmitEvent) => {
120
+ event.preventDefault()
121
+ const form = event.target as HTMLFormElement
122
+ const data = make(new FormData(form), fields(form))
123
+ run(data).then(() => { if (!state.error) form.reset() })
124
+ }
125
+
126
+ return state
127
+ }
128
+
129
+ if (!import.meta.env.SSR) {
130
+ const script = globalThis.document?.getElementById('__SSR__')
131
+ const data = script?.textContent ? parse<State>(script.textContent) : null
132
+ init(data)
133
+ }
134
+
135
+ if (import.meta.hot) {
136
+
137
+ const HMR = Symbol.for('ajo.hmr')
138
+ const Generator = Symbol.for('ajo.generator')
139
+ const Iterator = Symbol.for('ajo.iterator')
140
+ const Memo = Symbol.for('ajo.memo')
141
+
142
+ type HMRElement = Element & {
143
+ [Generator]?: { [HMR]?: string } | null
144
+ [Iterator]?: unknown
145
+ [Memo]?: unknown
146
+ }
147
+
148
+ const walk = (el: HMRElement, path?: string): void => {
149
+ if (el[Generator]?.[HMR] == path) el[Iterator] = el[Generator] = null
150
+ el[Memo] = null
151
+ Array.from(el.children).forEach(child => walk(child, path))
152
+ }
153
+
154
+ const root = document.getElementById('root');
155
+
156
+ (globalThis as { __HMR__?: (path?: string) => void }).__HMR__ = path => {
157
+ if (root) walk(root, path)
158
+ dispatchEvent(new CustomEvent('hmr'))
159
+ }
160
+ }
161
+
162
+ const root = globalThis?.document?.getElementById('root')
163
+
164
+ if (root) {
165
+ render(<App />, root)
166
+ document.documentElement.dataset.ajoReady = 'true'
167
+ }
@@ -0,0 +1,339 @@
1
+ import type { Children, Component } from 'ajo'
2
+ import type { Params } from 'navaid'
3
+ import type { Request, Response, Middleware } from 'polka'
4
+ /** Polka request, response, and middleware types used by route modules. */
5
+ export type { Request, Response, Middleware }
6
+ /** Document head payload returned by route head loaders. */
7
+ export type { Head } from './head'
8
+ import type { Head } from './head'
9
+ import type { Timing } from './timing'
10
+
11
+ // Route errors with HTTP status codes
12
+
13
+ /** HTTP-aware error serialized by loaders, actions, and API handlers. */
14
+ export class Failure extends Error {
15
+
16
+ constructor(public status: number, message: string) {
17
+ super(message)
18
+ }
19
+
20
+ toJSON() {
21
+ return {
22
+ message: mask(this.status, this.message),
23
+ status: this.status,
24
+ ...(!production() && import.meta.env.DEV && { stack: this.stack })
25
+ }
26
+ }
27
+ }
28
+
29
+ /** 404 error for missing routes or resources. */
30
+ export class Missing extends Failure {
31
+ constructor(message = 'Page not found') {
32
+ super(404, message)
33
+ }
34
+ }
35
+
36
+ /** 403 error for authenticated users without enough access. */
37
+ export class Forbidden extends Failure {
38
+ constructor(message = 'Access denied') {
39
+ super(403, message)
40
+ }
41
+ }
42
+
43
+ /** 401 error for requests that need authentication. */
44
+ export class Denied extends Failure {
45
+ constructor(message = 'Authentication required') {
46
+ super(401, message)
47
+ }
48
+ }
49
+
50
+ /** Field-level validation errors keyed by form field name. */
51
+ export type Fields = Record<string, string[] | undefined>
52
+
53
+ /** Client action error shape exposed by action() state. */
54
+ export type Issue = {
55
+ status: number
56
+ message: string
57
+ fields?: Fields
58
+ }
59
+
60
+ /** 400 validation error carrying field-level messages. */
61
+ export class Invalid extends Failure {
62
+ constructor(public fields: Fields, message = 'Validation failed') {
63
+ super(400, message)
64
+ }
65
+ toJSON() {
66
+ return {
67
+ message: mask(this.status, this.message),
68
+ status: this.status,
69
+ fields: this.fields,
70
+ ...(!production() && import.meta.env.DEV && { stack: this.stack })
71
+ }
72
+ }
73
+ }
74
+
75
+ const production = () => process.env.NODE_ENV === 'production'
76
+ const mask = (status: number, message: string) =>
77
+ production() && status >= 500 ? 'Internal Server Error' : message
78
+ const config = (message: string) => {
79
+ if (production()) console.error(`[security] ${message}`)
80
+ return new Failure(500, message)
81
+ }
82
+ const code = (error: unknown) => {
83
+ if (!error || typeof error !== 'object') return 500
84
+
85
+ const value = (error as { status?: unknown }).status ?? (error as { statusCode?: unknown }).statusCode
86
+
87
+ return Number.isInteger(value) && (value as number) >= 400 && (value as number) <= 599
88
+ ? value as number
89
+ : 500
90
+ }
91
+ const message = (error: unknown, status: number) => {
92
+ if (status === 413) return 'Content Too Large'
93
+ if (error instanceof Error && error.message) return error.message
94
+ return status < 500 ? 'Request failed' : 'Unknown error'
95
+ }
96
+
97
+ /** Converts any thrown value into a framework Failure. */
98
+ export function normalize(error: unknown): Failure {
99
+ if (error instanceof Failure) return error
100
+ const status = code(error)
101
+ return new Failure(status, message(error, status))
102
+ }
103
+
104
+ // Route path utilities
105
+
106
+ /** Returns route ancestor keys from outermost to innermost. */
107
+ export const ancestors = (segments: string[]) => segments.map((_, i) => segments.slice(0, i + 1).join('/'))
108
+
109
+ // Loader data types
110
+
111
+ /** Data object returned by one loader. */
112
+ export type Entry = Record<string, unknown>
113
+
114
+ /** Ordered loader data for layouts followed by the page. */
115
+ export type Data = Entry[]
116
+
117
+ /** Server payload containing head data followed by loader entries. */
118
+ export type Payload = [Head, ...Data]
119
+
120
+ /** Reads merged data from ancestor loaders. */
121
+ export type Parent = () => Promise<Entry>
122
+
123
+ // Route module types
124
+
125
+ /** Runtime shape of a route page or layout module. */
126
+ export type Module = {
127
+ default: Component
128
+ /** Receives loading=true while client navigation is pending. */
129
+ pending?: boolean
130
+ }
131
+
132
+ /** Lazy module loader generated by Vite route globs. */
133
+ export type Loader = () => Promise<Module>
134
+
135
+ /** Compiled route page metadata used by the client and server routers. */
136
+ export type Page = {
137
+ loader: Loader
138
+ segments: string[]
139
+ pattern?: string
140
+ params?: Params
141
+ }
142
+
143
+ /** Client route state produced by SSR, JSON loads, and navigation. */
144
+ export interface State {
145
+ url: string
146
+ params: Params
147
+ data: Data
148
+ loading: boolean
149
+ error?: Failure
150
+ head?: Head
151
+ hash?: string
152
+ topics?: string[]
153
+ versions?: Record<string, number>
154
+ }
155
+
156
+ /** State and commands returned by the client action() helper. */
157
+ export type Action<T> = {
158
+ loading: boolean
159
+ data?: T
160
+ error?: Issue
161
+ submit: (event: SubmitEvent) => void
162
+ invoke: (body?: unknown) => Promise<T | undefined>
163
+ /** Aborts any in-flight request and clears loading, data, and error state. */
164
+ reset: () => void
165
+ }
166
+
167
+ // Page and layout args
168
+
169
+ /** Route page component args. */
170
+ export type PageArgs<T = Entry> = {
171
+ params: Params
172
+ data?: T
173
+ loading: boolean
174
+ error?: Failure
175
+ }
176
+
177
+ /** Route layout component args, including children. */
178
+ export type LayoutArgs<T = Entry> = PageArgs<T> & {
179
+ children: Children
180
+ }
181
+
182
+ // Navigation helper
183
+
184
+ /** Pushes a browser URL and asks the client router to navigate. */
185
+ export const navigate = (to: string) => {
186
+ globalThis.history?.pushState({}, '', to)
187
+ globalThis.dispatchEvent?.(new CustomEvent('ajo:navigate'))
188
+ }
189
+
190
+ // Request helpers
191
+
192
+ /** Returns true when a request expects JSON route/action data. */
193
+ export const ajax = (req: Request) => !!req.headers.accept?.includes('application/json')
194
+ /** Returns true when a request targets the /api route namespace. */
195
+ export const api = (req: Request) => req.path.startsWith('/api/')
196
+
197
+ const enabled = (value: string | undefined) => value === '1' || value?.toLowerCase() === 'true'
198
+ const proxy = () => enabled(process.env.TRUST_PROXY)
199
+
200
+ const first = (value: string | string[] | undefined) =>
201
+ Array.isArray(value) ? value[0] : value
202
+
203
+ const address = (value: string) => {
204
+ const raw = value.trim().replace(/^\[/, '').replace(/\]$/, '').replace(/^::ffff:/, '')
205
+ return raw === '::1' || raw === '127.0.0.1' ? 'localhost' : raw
206
+ }
207
+
208
+ const ipv4 = (value: string) => {
209
+ const parts = value.split('.')
210
+ return parts.length === 4 && parts.every(part => {
211
+ if (!/^\d{1,3}$/.test(part)) return false
212
+ const number = Number(part)
213
+ return number >= 0 && number <= 255
214
+ })
215
+ }
216
+
217
+ const ipv6 = (value: string) =>
218
+ value.includes(':') && /^[0-9a-f:.]+$/i.test(value)
219
+
220
+ const local = (host: string) => {
221
+ try {
222
+ return address(new URL(`http://${host}`).hostname) === 'localhost'
223
+ } catch {
224
+ return false
225
+ }
226
+ }
227
+
228
+ const forwarded = (header: string | string[] | undefined) => {
229
+ const value = first(header)
230
+ if (!value) return
231
+
232
+ const addr = address(value.split(',')[0])
233
+ if (addr === 'localhost' || ipv4(addr) || ipv6(addr)) return addr
234
+ }
235
+
236
+ /** Resolves the client IP, honoring TRUST_PROXY for forwarded headers. */
237
+ export const ip = (req: Request) => {
238
+ const raw = proxy()
239
+ ? forwarded(req.headers['x-forwarded-for']) ?? req.socket?.remoteAddress
240
+ : req.socket?.remoteAddress
241
+
242
+ return raw ? address(raw) : 'unknown'
243
+ }
244
+
245
+ /** Resolves the trusted app origin from APP_URL or the request host. */
246
+ export const origin = (req: Request) => {
247
+ const configured = process.env.APP_URL
248
+
249
+ if (configured) {
250
+ try {
251
+ const url = new URL(configured)
252
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error()
253
+ return url.origin
254
+ } catch {
255
+ throw config('Invalid APP_URL')
256
+ }
257
+ }
258
+
259
+ const host = req.headers.host
260
+ if (!host) throw new Failure(400, 'Missing Host header')
261
+
262
+ if (production() && !local(host)) {
263
+ throw config('APP_URL is required in production')
264
+ }
265
+
266
+ const forwarded = first(req.headers['x-forwarded-proto'])?.split(',')[0]?.trim()
267
+ const protocol = proxy() && (forwarded === 'http' || forwarded === 'https') ? forwarded : 'http'
268
+
269
+ try {
270
+ return new URL(`${protocol}://${host}`).origin
271
+ } catch {
272
+ throw new Failure(400, 'Invalid Host header')
273
+ }
274
+ }
275
+
276
+ // Auth types
277
+
278
+ /** Authenticated user shape attached to requests by auth middleware. */
279
+ export interface User {
280
+ id: number
281
+ roles?: string[]
282
+ abilities?: string[]
283
+ [key: string]: unknown
284
+ }
285
+
286
+ // Request extensions for polka
287
+
288
+ declare module 'polka' {
289
+ interface Request {
290
+ user?: User
291
+ session?: { id: string }
292
+ token?: { id: string; abilities: string[] }
293
+ topics?: Set<string>
294
+ track?: (topic: string | string[]) => void
295
+ verifyLive?: () => Promise<boolean>
296
+ timing?: Timing
297
+ revalidate?: () => Promise<Payload>
298
+ head?: Head
299
+ entries?: Data
300
+ }
301
+ }
302
+
303
+ // Deferred promises for parallel loader execution
304
+
305
+ /** Internal parent/deferred link used to run loaders in parallel. */
306
+ export type Link = {
307
+ parent: Parent
308
+ deferred: { promise: Promise<Entry>; resolve: (value: Entry) => void; reject: (error: Error) => void }
309
+ }
310
+
311
+ /** Builds parent/deferred links for a loader chain. */
312
+ export function links(count: number): Link[] {
313
+
314
+ const chain: Link[] = []
315
+
316
+ for (let depth = 0; depth < count; depth++) {
317
+
318
+ let resolve!: (value: Entry) => void
319
+ let reject!: (error: Error) => void
320
+
321
+ const promise = new Promise<Entry>((res, rej) => {
322
+ resolve = res
323
+ reject = rej
324
+ })
325
+
326
+ const parent = async () =>
327
+ Object.assign({}, ...await Promise.all(chain.slice(0, depth).map(link => link.deferred.promise)))
328
+
329
+ chain.push({ parent, deferred: { promise, resolve, reject } })
330
+ }
331
+
332
+ return chain
333
+ }
334
+
335
+ // Formatting
336
+
337
+ /** Formats an ISO date for the current locale with compact defaults. */
338
+ export const date = (iso: string, options?: Intl.DateTimeFormatOptions) =>
339
+ new Date(iso).toLocaleDateString(undefined, options ?? { month: 'short', day: 'numeric', year: 'numeric' })
@@ -0,0 +1,55 @@
1
+ import { createRequire } from 'node:module'
2
+ import { Kysely, SqliteDialect } from 'kysely'
3
+ import type * as BetterSqlite3 from 'better-sqlite3'
4
+
5
+ const require = createRequire(import.meta.resolve('ajo-kit'))
6
+
7
+ /** Kysely SQL template helper. */
8
+ export { sql } from 'kysely'
9
+ /** Kysely database and row helper types. */
10
+ export type { Kysely, Generated, Selectable, Insertable } from 'kysely'
11
+ /** better-sqlite3 constructor resolved from ajo-kit's own installation. */
12
+ export const Database = require('better-sqlite3') as typeof import('better-sqlite3')
13
+ /** better-sqlite3 database handle type. */
14
+ export type Database = BetterSqlite3.Database
15
+ /** Alias for the active SQLite database handle. */
16
+ export type Sqlite = Database
17
+
18
+ let sqlite: Sqlite | null = null
19
+ let instance: Kysely<any> | null = null
20
+
21
+ /** Opens the SQLite database and configures safe defaults. */
22
+ export function connect(path = './database.sqlite'): Sqlite {
23
+ sqlite = new Database(path)
24
+ sqlite.pragma('journal_mode = WAL')
25
+ sqlite.pragma('foreign_keys = ON')
26
+ sqlite.pragma('busy_timeout = 5000')
27
+ sqlite.pragma('synchronous = NORMAL')
28
+ return sqlite
29
+ }
30
+
31
+ /** Returns the shared Kysely instance, opening SQLite on first use. */
32
+ export function db<T = any>(): Kysely<T> {
33
+ if (!sqlite) connect()
34
+ return instance ??= new Kysely<T>({
35
+ dialect: new SqliteDialect({ database: sqlite! })
36
+ })
37
+ }
38
+
39
+ /** Returns the shared raw better-sqlite3 handle. */
40
+ export function raw(): Sqlite {
41
+ if (!sqlite) connect()
42
+ return sqlite!
43
+ }
44
+
45
+ /** Destroys the shared Kysely instance and closes SQLite. */
46
+ export async function close(): Promise<void> {
47
+ if (instance) {
48
+ await instance.destroy()
49
+ instance = null
50
+ }
51
+ if (sqlite) {
52
+ sqlite.close()
53
+ sqlite = null
54
+ }
55
+ }
@@ -0,0 +1,42 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export interface Plugin {
5
+ name: string
6
+ path: string
7
+ alias?: string
8
+ serverOnly?: boolean
9
+ migrations?: string
10
+ commands?: string
11
+ }
12
+
13
+ export function discover(root = process.cwd()): Plugin[] {
14
+
15
+ const modules = join(root, 'node_modules')
16
+ const plugins: Plugin[] = []
17
+
18
+ let entries: string[]
19
+ try { entries = readdirSync(modules) } catch { return plugins }
20
+
21
+ for (const entry of entries) {
22
+
23
+ if (!entry.startsWith('ajo-') || entry === 'ajo-kit') continue
24
+
25
+ const dir = join(modules, entry)
26
+ let pkg: any
27
+
28
+ try { pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) } catch { continue }
29
+
30
+ if (!pkg.kit) continue
31
+
32
+ plugins.push({
33
+ name: pkg.name,
34
+ path: dir,
35
+ ...pkg.kit,
36
+ ...(pkg.kit.migrations && { migrations: join(dir, pkg.kit.migrations) }),
37
+ ...(pkg.kit.commands && { commands: join(dir, pkg.kit.commands) }),
38
+ })
39
+ }
40
+
41
+ return plugins
42
+ }