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/dist/vite.js ADDED
@@ -0,0 +1,110 @@
1
+ import { t as discover } from "./chunks/discover-D-b8Pqm8.js";
2
+ //#region packages/ajo-kit/src/vite.ts
3
+ var match = (id, pattern) => typeof pattern === "function" ? pattern(id) : typeof pattern === "string" ? id.includes(pattern) : pattern.test(id);
4
+ var any = (id, patterns) => patterns.some((p) => match(id, p));
5
+ /**
6
+ * Prevents server-only modules from being imported into client code.
7
+ * Tracks the import chain to catch transitive imports through barrel files.
8
+ */
9
+ var guard = (patterns) => {
10
+ const chain = /* @__PURE__ */ new Map();
11
+ return {
12
+ name: "ajo-server-only",
13
+ enforce: "pre",
14
+ resolveId: {
15
+ order: "pre",
16
+ async handler(source, importer) {
17
+ if (this.environment.name !== "client") return;
18
+ if (!importer) return;
19
+ const resolved = await this.resolve(source, importer, { skipSelf: true });
20
+ if (!resolved) return;
21
+ const id = resolved.id;
22
+ chain.set(id, importer);
23
+ if (/\.client\.[jt]sx?$/.test(id)) return;
24
+ if (any(id, patterns)) {
25
+ const trace = [id];
26
+ let current = importer;
27
+ while (current && trace.length < 10) {
28
+ trace.unshift(current);
29
+ current = chain.get(current);
30
+ }
31
+ const path = trace.map((p) => p.replace(/^.*\/src\//, "src/")).join("\n → ");
32
+ throw new Error(`Server-only module imported into client code:\n\n ${path}\n\nModule "${id.replace(/^.*\/src\//, "src/")}" cannot be imported by client code.`);
33
+ }
34
+ }
35
+ }
36
+ };
37
+ };
38
+ var hmr = (pattern) => ({
39
+ name: "ajo-hmr",
40
+ apply: "serve",
41
+ transform(code, id) {
42
+ if (!pattern.test(id)) return null;
43
+ const HMR = `Symbol.for('ajo.hmr')`;
44
+ const path = "/" + id.replace(/^.*?(src\/)/, "$1");
45
+ return {
46
+ code: (code.match(/export\s+default\s+(\w+)\s*[\n;]/) ? code.replace(/export\s+default\s+(\w+)/, `export default $1;$1[${HMR}]=${JSON.stringify(path)}`) : code) + `
47
+ if(import.meta.hot)import.meta.hot.accept(m=>{
48
+ if(m?.default)m.default[${HMR}]=${JSON.stringify(path)};
49
+ if(m)(globalThis.__MODULES__??=new Map).set(${JSON.stringify(path)},m),globalThis.__HMR__?.(${JSON.stringify(path)});
50
+ })`,
51
+ map: null
52
+ };
53
+ }
54
+ });
55
+ /** Default file locations used by the kit CLI. */
56
+ var defaults = {
57
+ database: "./database.sqlite",
58
+ migrations: "db/migrations",
59
+ seeds: "db/seeds"
60
+ };
61
+ var guards = (found) => [/(handler|wares)\.[jt]sx?$/, ...found.filter((p) => p.serverOnly).map((p) => new RegExp(`${p.name}/`))];
62
+ /** Returns the Vite plugins required by an ajo-kit app. */
63
+ function kit(options) {
64
+ const css = options?.css ?? [];
65
+ const found = discover();
66
+ return [
67
+ {
68
+ name: "ajo-kit",
69
+ resolveId(id) {
70
+ if (id === "virtual:ajo/routes") return "\0virtual:ajo/routes";
71
+ if (id === "virtual:ajo/handlers") return "\0virtual:ajo/handlers";
72
+ },
73
+ load(id) {
74
+ if (id === "\0virtual:ajo/routes") return "export const routes = import.meta.glob('/src/**/{layout,page}.{j,t}s{,x}')";
75
+ if (id === "\0virtual:ajo/handlers") return ["export const handlers = import.meta.glob('/src/**/handler.{j,t}s{,x}')", "export const wares = import.meta.glob('/src/**/wares.{j,t}s{,x}')"].join("\n");
76
+ },
77
+ transform(code, id) {
78
+ if (css.length && id.includes("ajo-kit") && id.endsWith("client.tsx")) return css.map((c) => `import '${c}'`).join("\n") + "\n" + code;
79
+ },
80
+ config() {
81
+ return {
82
+ ssr: { noExternal: [/^ajo-/] },
83
+ resolve: { alias: [
84
+ ...found.filter((p) => p.alias).map((p) => ({
85
+ find: new RegExp(`^@kit/${p.alias}(/|$)`),
86
+ replacement: `${p.name}$1`
87
+ })),
88
+ {
89
+ find: /^@kit(\/|$)/,
90
+ replacement: "ajo-kit$1"
91
+ },
92
+ {
93
+ find: "/src/client",
94
+ replacement: "ajo-kit/client"
95
+ }
96
+ ] }
97
+ };
98
+ }
99
+ },
100
+ guard([...guards(found), ...options?.guard ?? []]),
101
+ hmr(/(page|layout)\.[jt]sx?$/)
102
+ ];
103
+ }
104
+ /** Vite esbuild JSX settings for Ajo components. */
105
+ var jsx = {
106
+ jsx: "automatic",
107
+ jsxImportSource: "ajo"
108
+ };
109
+ //#endregion
110
+ export { defaults, jsx, kit };
package/package.json ADDED
@@ -0,0 +1,106 @@
1
+ {
2
+ "name": "ajo-kit",
3
+ "version": "0.1.0",
4
+ "description": "Full-stack metaframework for Ajo applications",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "bin": {
12
+ "kit": "./dist/bin/kit.js"
13
+ },
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "default": "./dist/index.js",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./server": {
21
+ "types": "./src/server.tsx",
22
+ "default": "./dist/server.js",
23
+ "import": "./dist/server.js"
24
+ },
25
+ "./client": {
26
+ "types": "./src/client.tsx",
27
+ "default": "./dist/client.js",
28
+ "import": "./dist/client.js"
29
+ },
30
+ "./node": {
31
+ "types": "./src/node.ts",
32
+ "default": "./dist/node.js",
33
+ "import": "./dist/node.js"
34
+ },
35
+ "./validate": {
36
+ "types": "./src/validate.ts",
37
+ "default": "./dist/validate.js",
38
+ "import": "./dist/validate.js"
39
+ },
40
+ "./vite": {
41
+ "types": "./src/vite.ts",
42
+ "default": "./dist/vite.js",
43
+ "import": "./dist/vite.js"
44
+ },
45
+ "./mail": {
46
+ "types": "./src/mail/index.ts",
47
+ "default": "./dist/mail.js",
48
+ "import": "./dist/mail.js"
49
+ },
50
+ "./database": {
51
+ "types": "./src/database.ts",
52
+ "default": "./dist/database.js",
53
+ "import": "./dist/database.js"
54
+ }
55
+ },
56
+ "dependencies": {
57
+ "@polka/parse": "1.0.0-next.28",
58
+ "@polka/send": "1.0.0-next.28",
59
+ "@types/better-sqlite3": "^7.6.13",
60
+ "better-sqlite3": "^12.11.1",
61
+ "devalue": "^5.8.1",
62
+ "dotenv": "^17.4.2",
63
+ "kysely": "^0.29.2",
64
+ "navaid": "^1.2.0",
65
+ "polka": "1.0.0-next.28",
66
+ "sade": "^1.8.1",
67
+ "sirv": "^3.0.2",
68
+ "valibot": "^1.4.1"
69
+ },
70
+ "peerDependencies": {
71
+ "ajo": "^0.1.35",
72
+ "vite": "^8.0.16"
73
+ },
74
+ "devDependencies": {
75
+ "ajo": "0.1.35",
76
+ "vite": "8.0.16"
77
+ },
78
+ "engines": {
79
+ "node": ">=22.18.0"
80
+ },
81
+ "keywords": [
82
+ "ajo",
83
+ "framework",
84
+ "vite",
85
+ "sqlite",
86
+ "ssr"
87
+ ],
88
+ "author": "Cristian Falcone",
89
+ "license": "ISC",
90
+ "repository": {
91
+ "type": "git",
92
+ "url": "git+https://github.com/cristianfalcone/ajo-kit.git",
93
+ "directory": "packages/ajo-kit"
94
+ },
95
+ "bugs": {
96
+ "url": "https://github.com/cristianfalcone/ajo-kit/issues"
97
+ },
98
+ "homepage": "https://github.com/cristianfalcone/ajo-kit/tree/main/packages/ajo-kit#readme",
99
+ "publishConfig": {
100
+ "access": "public"
101
+ },
102
+ "scripts": {
103
+ "build": "pnpm -w exec tsx scripts/package-build.ts ajo-kit",
104
+ "test": "pnpm -w exec vitest run packages/ajo-kit/tests"
105
+ }
106
+ }
package/src/app.tsx ADDED
@@ -0,0 +1,484 @@
1
+ import navaid from 'navaid'
2
+ import type { Component, Stateful } from 'ajo'
3
+ import { Failure, navigate, ancestors } from './constants'
4
+ import type {
5
+ PageArgs,
6
+ LayoutArgs,
7
+ Data,
8
+ Module,
9
+ Loader,
10
+ Page,
11
+ State,
12
+ Payload,
13
+ } from './constants'
14
+ import { apply, type Head } from './head'
15
+ import { get, set } from './cache'
16
+ import { routes } from 'virtual:ajo/routes'
17
+
18
+ // Pattern compilation
19
+
20
+ const group = /^\(.*\)$/
21
+ const dynamic = /^\[(.+?)\]$/
22
+
23
+ export const match = (segments: string[]) =>
24
+ segments
25
+ .filter(segment => segment && !group.test(segment))
26
+ .map(segment => segment.replace(dynamic, (_, name) => name === '...' ? '*' : `:${name}`))
27
+ .join('/')
28
+
29
+ export const parts = (path: string) => path.slice(4).split('/').slice(0, -1)
30
+
31
+ let initial: State | undefined
32
+
33
+ export function init(state: State | null) {
34
+ initial = state ?? undefined
35
+ }
36
+
37
+ export const error: () => Page = () => ({
38
+ segments: [''],
39
+ loader: async () => ({ default: () => null }),
40
+ })
41
+
42
+ // Build pages from file system
43
+
44
+ export const layouts = new Map<string, Loader>()
45
+ export const pages: Page[] = []
46
+
47
+ // Path helpers
48
+
49
+ export const parents = (segments: string[]) => ancestors(segments).filter(path => layouts.has(path))
50
+
51
+ for (const [path, loader] of Object.entries(routes as Record<string, Loader>)) {
52
+
53
+ const segments = parts(path)
54
+ const kind = path.split('/').pop()?.split('.')[0]
55
+
56
+ if (kind === 'layout') layouts.set(segments.join('/'), loader)
57
+ if (kind === 'page') pages.push({ pattern: match(segments), segments, loader })
58
+ }
59
+
60
+ // HMR: wrap loaders to use hot-updated modules
61
+
62
+ if (import.meta.env.DEV && !import.meta.env.SSR) {
63
+
64
+ const scope = globalThis as { __MODULES__?: Map<string, Module> }
65
+ const modules = scope.__MODULES__ ??= new Map()
66
+
67
+ const hmr = (loader: Loader, file: string): Loader => async () => {
68
+
69
+ if (modules.has(file)) return modules.get(file)
70
+
71
+ const module = await loader()
72
+
73
+ modules.set(file, module)
74
+
75
+ return module
76
+ }
77
+
78
+ for (const [path, loader] of layouts) layouts.set(path, hmr(loader, `/src${path}/layout.tsx`))
79
+
80
+ for (const page of pages) page.loader = hmr(page.loader, `/src${page.segments.join('/')}/page.tsx`)
81
+ }
82
+
83
+ // Compose component tree
84
+
85
+ function compose(
86
+ page: Module,
87
+ tree: Array<{ path: string; module: Module }>,
88
+ paths: string[],
89
+ state: State
90
+ ): Component {
91
+
92
+ const Page = page.default as Component<PageArgs>
93
+
94
+ // Find who handles pending navigation: page first, then innermost layout.
95
+
96
+ const boundary = page.pending ? 'page' : tree.findLast(entry => entry.module.pending)?.path
97
+
98
+ return tree.reduceRight<Component>(
99
+ (Child, { path, module }, depth) => {
100
+ const Layout = module.default as Component<LayoutArgs>
101
+ return () => (
102
+ <Layout
103
+ key={path}
104
+ params={state.params}
105
+ data={state.data[depth]}
106
+ loading={state.loading && boundary === path}
107
+ error={state.error}
108
+ >
109
+ <Child />
110
+ </Layout>
111
+ )
112
+ },
113
+ () => (
114
+ <Page
115
+ key={paths.join('/')}
116
+ params={state.params}
117
+ data={state.data.at(-1)}
118
+ loading={state.loading && boundary === 'page'}
119
+ error={state.error}
120
+ />
121
+ )
122
+ )
123
+ }
124
+
125
+ type Load = {
126
+ data: Data
127
+ head?: Head
128
+ hash?: string
129
+ topics?: string[]
130
+ versions?: Record<string, number>
131
+ redirect?: string
132
+ error?: Failure
133
+ }
134
+
135
+ async function load(url: string): Promise<Load> {
136
+
137
+ const cached = get(url)
138
+ const versions = cached?.versions ? JSON.stringify(cached.versions) : undefined
139
+
140
+ const response = await fetch(url, {
141
+ credentials: 'include',
142
+ cache: 'no-store',
143
+ headers: {
144
+ Accept: 'application/json',
145
+ ...(cached?.hash && { 'X-Have': cached.hash }),
146
+ ...(versions && { 'X-Ajo-Versions': versions })
147
+ }
148
+ })
149
+
150
+ if (response.status === 304 && cached) {
151
+ return {
152
+ data: cached.data,
153
+ head: cached.head,
154
+ hash: cached.hash,
155
+ topics: cached.topics,
156
+ versions: cached.versions
157
+ }
158
+ }
159
+
160
+ const json = await response.json().catch(() => null) as
161
+ | { data?: Data; head?: Head; hash?: string; topics?: string[]; versions?: Record<string, number>; redirect?: string; error?: { status?: number; message?: string } }
162
+ | null
163
+
164
+ if (!json || !response.ok) {
165
+ return {
166
+ data: [],
167
+ error: new Failure(
168
+ json?.error?.status ?? response.status,
169
+ json?.error?.message ?? 'Load failed'
170
+ )
171
+ }
172
+ }
173
+
174
+ if (json.redirect) return { data: [], redirect: json.redirect }
175
+
176
+ return {
177
+ data: json.data ?? [],
178
+ head: json.head,
179
+ hash: json.hash,
180
+ topics: json.topics,
181
+ versions: json.versions
182
+ }
183
+ }
184
+
185
+ // Resolve page: async generator yielding loading then data states
186
+
187
+ export async function* resolve(
188
+ url: string,
189
+ layouts: Map<string, Loader>,
190
+ page: Page,
191
+ data?: Data,
192
+ error?: Failure
193
+ ): AsyncGenerator<{ page: Component; state?: State }> {
194
+
195
+ const { loader, segments, params = {} } = page
196
+
197
+ const paths = parents(segments)
198
+
199
+ const [target, ...tree] = await Promise.all([
200
+ loader(),
201
+ ...paths.map(path => layouts.get(path)!().then(module => ({ path, module })))
202
+ ])
203
+
204
+ if (error) {
205
+ const state: State = { url, params, data: [], loading: false, error }
206
+ yield { page: compose(target, tree, paths, state), state }
207
+ return
208
+ }
209
+
210
+ const cached = initial?.url === url ? initial : undefined
211
+
212
+ if (cached) {
213
+
214
+ initial = undefined
215
+ if (cached.hash) set(url, cached)
216
+
217
+ yield {
218
+ page: compose(target, tree, paths, cached),
219
+ state: cached,
220
+ }
221
+
222
+ return
223
+ }
224
+
225
+ yield { page: compose(target, tree, paths, { url, params, data: [], loading: true }) }
226
+
227
+ const server: Load = data
228
+ ? { data }
229
+ : import.meta.env.SSR
230
+ ? { data: [] }
231
+ : await load(url)
232
+
233
+ if (server.redirect) {
234
+ navigate(server.redirect)
235
+ return
236
+ }
237
+
238
+ if (server.error) {
239
+ const state = { url, params, data: [], loading: false, error: server.error }
240
+ yield { page: compose(target, tree, paths, state), state }
241
+ return
242
+ }
243
+
244
+ const state: State = {
245
+ url,
246
+ params,
247
+ data: server.data,
248
+ loading: false,
249
+ head: server.head,
250
+ hash: server.hash,
251
+ topics: server.topics,
252
+ versions: server.versions,
253
+ }
254
+
255
+ if (state.hash) set(url, state)
256
+
257
+ yield {
258
+ page: compose(target, tree, paths, state),
259
+ state
260
+ }
261
+ }
262
+
263
+ type Message = {
264
+ data: Payload
265
+ hash?: string
266
+ topics?: string[]
267
+ versions?: Record<string, number>
268
+ }
269
+
270
+ type Status = 'closed' | 'connecting' | 'open'
271
+
272
+ type Detail = {
273
+ topics?: string[]
274
+ }
275
+
276
+ function stream(update: (message: Message) => void, notify?: (status: Status) => void) {
277
+
278
+ let source: EventSource | null = null
279
+
280
+ const status = (value: Status) => notify?.(value)
281
+
282
+ const connect = (path: string) => {
283
+
284
+ source?.close()
285
+
286
+ if ((globalThis as { __AJO_DISABLE_SSE__?: boolean }).__AJO_DISABLE_SSE__) {
287
+ status('closed')
288
+ return
289
+ }
290
+
291
+ status('connecting')
292
+
293
+ source = new EventSource(path)
294
+
295
+ source.onopen = () => status('open')
296
+
297
+ source.onmessage = event => {
298
+ const message = JSON.parse(event.data) as Message
299
+ if (message.data) update(message)
300
+ }
301
+
302
+ source.onerror = () => status('connecting')
303
+ }
304
+
305
+ const close = () => {
306
+ source?.close()
307
+ source = null
308
+ status('closed')
309
+ }
310
+
311
+ return { connect, close }
312
+ }
313
+
314
+ const App: Stateful<{ page?: Component }> = function* ({ page }) {
315
+
316
+ let Page: Component = page ?? (() => null)
317
+
318
+ if (page) return <Page />
319
+
320
+ let hmr = false
321
+ let active: State | null = null
322
+ let timer: ReturnType<typeof setTimeout> | null = null
323
+ let generation = 0
324
+ let live = 0
325
+ let phase: Status = 'closed'
326
+
327
+ const sse = stream(message => {
328
+
329
+ if (!active || !message.data) return
330
+
331
+ live++
332
+
333
+ const [head, ...entries] = message.data
334
+
335
+ active.data = entries
336
+ active.hash = message.hash ?? active.hash
337
+ active.topics = message.topics ?? active.topics
338
+ active.versions = message.versions ?? active.versions
339
+
340
+ if (head) apply(active.head = head)
341
+
342
+ if (active.hash) set(active.url, active)
343
+
344
+ this.next()
345
+ }, status => phase = status)
346
+
347
+ const go = async (target: Page, options: { scroll?: boolean } = {}) => {
348
+
349
+ const gen = ++generation
350
+ const url = location.pathname + location.search
351
+ const scroll = options.scroll ?? true
352
+
353
+ sse.close()
354
+
355
+ try {
356
+
357
+ for await (const { page, state } of resolve(url, layouts, target)) {
358
+
359
+ if (gen !== generation) return
360
+ if (hmr && !state) continue
361
+
362
+ this.next(() => Page = page)
363
+
364
+ if (state?.head) apply(state.head)
365
+
366
+ if (state && !state.loading) {
367
+ active = state
368
+ }
369
+ }
370
+
371
+ } catch (err) {
372
+
373
+ if (gen !== generation) return
374
+
375
+ err = err instanceof Failure ? err : new Failure(500, err instanceof Error ? err.message : 'Navigation failed')
376
+
377
+ for await (const { page } of resolve(url, layouts, error(), undefined, err as Failure)) {
378
+
379
+ if (gen !== generation) return
380
+
381
+ this.next(() => Page = page)
382
+ }
383
+
384
+ return
385
+ }
386
+
387
+ if (gen !== generation) return
388
+
389
+ if (!hmr) {
390
+ sse.connect(url)
391
+ if (scroll) requestAnimationFrame(() => scrollTo({ top: 0, behavior: 'smooth' }))
392
+ }
393
+
394
+ hmr = false
395
+ }
396
+
397
+ const refresh = async () => {
398
+ if (!active) return
399
+
400
+ const gen = generation
401
+ const state = active
402
+ const server = await load(state.url)
403
+
404
+ if (gen !== generation || active !== state) return
405
+
406
+ if (server.redirect) {
407
+ navigate(server.redirect)
408
+ return
409
+ }
410
+
411
+ if (server.error) {
412
+ state.data = []
413
+ state.error = server.error
414
+ state.loading = false
415
+ } else {
416
+ state.data = server.data
417
+ state.error = undefined
418
+ state.head = server.head
419
+ state.hash = server.hash
420
+ state.topics = server.topics
421
+ state.versions = server.versions
422
+
423
+ if (server.head) apply(server.head)
424
+ if (state.hash) set(state.url, state)
425
+ }
426
+
427
+ this.next()
428
+ }
429
+
430
+ const reconcile = (topics?: string[]) => {
431
+
432
+ if (!topics?.length || !active?.topics?.length) return
433
+
434
+ const changed = new Set(topics)
435
+
436
+ if (!active.topics.some(topic => changed.has(topic))) return
437
+
438
+ const seen = live
439
+ const delay = phase === 'open' ? 250 : 0
440
+
441
+ if (timer) clearTimeout(timer)
442
+
443
+ timer = setTimeout(() => {
444
+ timer = null
445
+ if (live !== seen) return
446
+ void refresh()
447
+ }, delay)
448
+ }
449
+
450
+ const router = navaid('/', () => go(error()))
451
+
452
+ for (const config of pages) router.on(config.pattern!, params => go({ ...config, params }))
453
+
454
+ router.listen()
455
+
456
+ if (import.meta.env.DEV) addEventListener(
457
+ 'hmr',
458
+ () => {
459
+ hmr = true
460
+ router.run()
461
+ },
462
+ { signal: this.signal }
463
+ )
464
+
465
+ addEventListener('ajo:navigate', () => router.run(), { signal: this.signal })
466
+
467
+ addEventListener(
468
+ 'ajo:action',
469
+ event => reconcile((event as CustomEvent<Detail>).detail?.topics),
470
+ { signal: this.signal }
471
+ )
472
+
473
+ this.signal.addEventListener('abort', () => {
474
+ if (timer) clearTimeout(timer)
475
+ sse.close()
476
+ router.unlisten?.()
477
+ })
478
+
479
+ while (true) yield <Page />
480
+ }
481
+
482
+ App.attrs = { class: 'h-full' }
483
+
484
+ export default App