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 ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2022-2026, Cristian Falcone
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
14
+ OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,468 @@
1
+ # ajo-kit
2
+
3
+ Full-stack metaframework for [Ajo](https://github.com/cristianfalcone/ajo) with file-based routing, server handlers, form actions, middleware, migrations, and SSE route payload updates.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add ajo ajo-kit
9
+ pnpm add -D vite typescript @types/node
10
+ ```
11
+
12
+ `ajo-kit` requires `ajo ^0.1.35`, `vite ^8.0.16`, and Node 22.18 or newer.
13
+ TypeScript migrations run through Node's built-in type stripping and use
14
+ erasable TypeScript syntax.
15
+
16
+ ## Minimal Setup
17
+
18
+ ### `package.json`
19
+
20
+ ```json
21
+ {
22
+ "type": "module",
23
+ "scripts": {
24
+ "dev": "kit dev",
25
+ "build": "kit build",
26
+ "start": "kit start"
27
+ }
28
+ }
29
+ ```
30
+
31
+ ### `vite.config.ts`
32
+
33
+ ```ts
34
+ import { defineConfig } from 'vite'
35
+ import { kit, jsx } from 'ajo-kit/vite'
36
+
37
+ export default defineConfig({
38
+ plugins: [...kit()],
39
+ esbuild: jsx,
40
+ })
41
+ ```
42
+
43
+ ### `tsconfig.json`
44
+
45
+ ```json
46
+ {
47
+ "compilerOptions": {
48
+ "target": "ESNext",
49
+ "module": "ESNext",
50
+ "moduleResolution": "bundler",
51
+ "allowImportingTsExtensions": true,
52
+ "isolatedModules": true,
53
+ "noEmit": true,
54
+ "jsx": "react-jsx",
55
+ "jsxImportSource": "ajo",
56
+ "strict": true,
57
+ "paths": {
58
+ "/src/*": ["src/*"],
59
+ "@kit": ["node_modules/ajo-kit/src/index.ts"],
60
+ "@kit/*": ["node_modules/ajo-kit/src/*"]
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### `index.html`
67
+
68
+ ```html
69
+ <!DOCTYPE html>
70
+ <html lang="en">
71
+ <head>
72
+ <meta charset="UTF-8">
73
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
74
+ <!-- ssr:head -->
75
+ </head>
76
+ <body>
77
+ <!-- ssr:data -->
78
+ <div id="root"><!-- ssr:root --></div>
79
+ <script src="/src/client" type="module"></script>
80
+ </body>
81
+ </html>
82
+ ```
83
+
84
+ ### `src/page.tsx`
85
+
86
+ ```tsx
87
+ export default () => (
88
+ <main>
89
+ <h1>Welcome to ajo-kit</h1>
90
+ <p>Edit <code>src/page.tsx</code> to get started.</p>
91
+ </main>
92
+ )
93
+ ```
94
+
95
+ ## CLI
96
+
97
+ ```bash
98
+ kit dev [-p 5173]
99
+ kit build
100
+ kit start [-p 5173]
101
+
102
+ kit migrate up [-d ./database.sqlite]
103
+ kit migrate down [-d ./database.sqlite]
104
+ kit migrate status [-d ./database.sqlite]
105
+ kit migrate create <name>
106
+
107
+ kit seed [-d ./database.sqlite]
108
+ ```
109
+
110
+ Defaults:
111
+
112
+ - database: `./database.sqlite`
113
+ - migrations folder: `db/migrations`
114
+ - seeds folder: `db/seeds`
115
+
116
+ ## Routing
117
+
118
+ File-based routes:
119
+
120
+ ```mermaid
121
+ flowchart LR
122
+ root["src/page.tsx"] --> rootPath["/"]
123
+ about["src/about/page.tsx"] --> aboutPath["/about"]
124
+ blog["src/blog/[id]/page.tsx"] --> blogPath["/blog/:id"]
125
+ docs["src/docs/[...]/page.tsx"] --> docsPath["/docs/*"]
126
+ group["src/(app)/dashboard/page.tsx"] --> dashboard["/dashboard"]
127
+ ```
128
+
129
+ Per-route files:
130
+
131
+ - `page.tsx`: page component
132
+ - `layout.tsx`: shared wrapper for a route branch
133
+ - `handler.ts`: server loaders/actions/api handlers
134
+ - `wares.ts`: middleware for that branch and descendants
135
+
136
+ `page.tsx` and `layout.tsx` modules can export `pending = true` to receive
137
+ `loading=true` while client navigation waits for route data. The page wins
138
+ first; otherwise the innermost pending layout handles it.
139
+
140
+ ## Server Handlers
141
+
142
+ `handler.ts` supports:
143
+
144
+ ```ts
145
+ import type { Request, Response } from '@kit'
146
+ import { send } from '@kit/server'
147
+ import type { Head } from '@kit'
148
+
149
+ export async function layout(req: Request, parent: () => Promise<Record<string, unknown>>) {
150
+ return {}
151
+ }
152
+
153
+ export async function page(req: Request, parent: () => Promise<Record<string, unknown>>) {
154
+ return {}
155
+ }
156
+
157
+ export async function head(req: Request, parent: () => Promise<Record<string, unknown>>): Promise<Head> {
158
+ return { title: 'My page' }
159
+ }
160
+
161
+ export const actions = {
162
+ async save(req: Request, res: Response) {
163
+ return { ok: true }
164
+ }
165
+ }
166
+
167
+ export default {
168
+ async get(req: Request, res: Response) {
169
+ send(res, 200, { ok: true })
170
+ }
171
+ }
172
+ ```
173
+
174
+ Notes:
175
+
176
+ - `default` maps HTTP methods to `/api/<route>`.
177
+ - API handlers in `default` must write/send the HTTP response.
178
+ - `actions` are invoked by `POST /current-route?/actionName`.
179
+ - action `"default"` is used when no `?/name` is provided.
180
+ - `parent()` resolves merged ancestor loader data.
181
+
182
+ ## Actions from Client
183
+
184
+ ```tsx
185
+ import { action } from '@kit/client'
186
+
187
+ const Page = function* () {
188
+ const form = action<{ ok: boolean }>('save')
189
+
190
+ while (true) {
191
+ yield (
192
+ <form onsubmit={form.submit}>
193
+ <input name="title" />
194
+ <button disabled={form.loading}>Save</button>
195
+ {form.error && <p>{form.error.message}</p>}
196
+ </form>
197
+ )
198
+ }
199
+ }
200
+ ```
201
+
202
+ You can also trigger programmatically:
203
+
204
+ ```ts
205
+ await form.invoke({ title: 'Hello' })
206
+ ```
207
+
208
+ If an action returns `{ redirect: '/path' }`, client navigation is triggered automatically.
209
+ Successful non-redirect actions dispatch `ajo:action` with returned JSON detail.
210
+
211
+ ## Middleware
212
+
213
+ `wares.ts` exports one middleware or an array:
214
+
215
+ ```ts
216
+ import type { Middleware } from '@kit'
217
+
218
+ const log: Middleware = (req, _res, next) => {
219
+ console.log(req.method, req.url)
220
+ next()
221
+ }
222
+
223
+ export default log
224
+ ```
225
+
226
+ Middlewares are collected from route ancestors and applied to both page and API handlers.
227
+
228
+ ## SSE Topics (Live Updates)
229
+
230
+ Track topics in loaders, then emit from server code after mutations:
231
+
232
+ ```ts
233
+ // src/chat/handler.ts
234
+ import { emit } from '@kit/server'
235
+
236
+ export async function page(req) {
237
+ req.track?.('messages')
238
+ return { messages: [] }
239
+ }
240
+
241
+ export const actions = {
242
+ async create(req) {
243
+ // write to DB...
244
+ emit('messages')
245
+ return { ok: true }
246
+ }
247
+ }
248
+ ```
249
+
250
+ The runtime maintains an SSE stream, revalidates affected routes, and replaces the active route payload when tracked topics change.
251
+
252
+ ## Database and Migrations
253
+
254
+ `ajo-kit/database` exports:
255
+
256
+ - `connect(path?)`
257
+ - `db<T>()`
258
+ - `raw()`
259
+ - `close()`
260
+ - `Database` (better-sqlite3)
261
+ - `sql` and Kysely types
262
+
263
+ SSE topic versions, active connections, and update fanout are stored in process
264
+ memory. Multi-process deployments require shared topic coordination and
265
+ fanout. Store SQLite database files on persistent local disk.
266
+
267
+ For non-local production, configure `APP_URL` to the public `http` or `https`
268
+ origin. Applications choose how to configure their database path:
269
+
270
+ ```ts
271
+ connect(process.env.DATABASE_PATH ?? './database.sqlite')
272
+ ```
273
+
274
+ `kit migrate` composes:
275
+
276
+ - app migrations in `db/migrations`
277
+ - plugin migrations discovered from installed `ajo-*` packages that expose `package.json#kit.migrations`
278
+
279
+ Each migration provider uses a contiguous sequence beginning at `0001`, so a
280
+ plugin and the app may both define `0001_initial`. Stored identities use
281
+ `plugin/<package>/<name>` and `project/<name>` in one SQLite history and lock.
282
+
283
+ Every migration exports `up()` and `down()`. `migrate down` rolls back the
284
+ latest executed migration across all providers. `migrate status` rejects
285
+ history entries whose migration is unavailable.
286
+
287
+ `kit seed` runs sorted `db/seeds/*.ts` files that export:
288
+
289
+ ```ts
290
+ export async function seed(db) {
291
+ // ...
292
+ }
293
+ ```
294
+
295
+ ## Validation
296
+
297
+ `@kit/validate` re-exports common Valibot helpers and provides `parse(schema, data)`, which throws `Invalid` with field-level details.
298
+
299
+ ## Plugin Discovery
300
+
301
+ Installed packages named `ajo-*` (except `ajo-kit`) with a `kit` block in `package.json` are auto-discovered:
302
+
303
+ ```json
304
+ {
305
+ "kit": {
306
+ "alias": "auth",
307
+ "serverOnly": true,
308
+ "migrations": "./migrations/",
309
+ "commands": "./src/commands.ts"
310
+ }
311
+ }
312
+ ```
313
+
314
+ This enables:
315
+
316
+ - `@kit/<alias>` import aliases
317
+ - server-only import protection in Vite
318
+ - automatic migration loading
319
+ - CLI command extension via `register(cli)`
320
+
321
+ ## Public Entry Points
322
+
323
+ | Import | API |
324
+ |---|---|
325
+ | `ajo-kit` or `@kit` | Route types, HTTP errors, request helpers, navigation, and formatting |
326
+ | `ajo-kit/server` or `@kit/server` | Server runtime, `send()`, and `emit()` |
327
+ | `ajo-kit/client` or `@kit/client` | Client boot and `action()` |
328
+ | `ajo-kit/validate` or `@kit/validate` | Valibot helpers and `parse()` |
329
+ | `ajo-kit/database` or `@kit/database` | SQLite, Kysely, and database lifecycle |
330
+ | `ajo-kit/mail` or `@kit/mail` | Configurable mail transport |
331
+ | `ajo-kit/vite` | Vite plugin, JSX config, and defaults |
332
+ | `ajo-kit/node` | Programmatic development, build, start, and listen runtime |
333
+
334
+ ## Core API
335
+
336
+ ```ts
337
+ import {
338
+ Denied,
339
+ Failure,
340
+ Forbidden,
341
+ Invalid,
342
+ Missing,
343
+ ajax,
344
+ api,
345
+ date,
346
+ ip,
347
+ navigate,
348
+ normalize,
349
+ origin,
350
+ } from 'ajo-kit'
351
+ import type {
352
+ Action,
353
+ Entry,
354
+ Fields,
355
+ Head,
356
+ Issue,
357
+ LayoutArgs,
358
+ Middleware,
359
+ PageArgs,
360
+ Parent,
361
+ Request,
362
+ Response,
363
+ User,
364
+ } from 'ajo-kit'
365
+ ```
366
+
367
+ `Failure` carries an HTTP status. `Missing`, `Forbidden`, `Denied`, and
368
+ `Invalid` represent 404, 403, 401, and 400 responses. `normalize()` converts an
369
+ unknown thrown value into a `Failure`.
370
+
371
+ `ajax()` and `api()` classify requests. `ip()` resolves the client address, and
372
+ `origin()` resolves the trusted application origin. `navigate()` performs
373
+ client navigation, and `date()` formats ISO timestamps.
374
+
375
+ ## Server API
376
+
377
+ ```ts
378
+ import { emit, send } from 'ajo-kit/server'
379
+
380
+ send(res, 200, { ok: true })
381
+ emit('posts:list')
382
+ ```
383
+
384
+ `send()` writes an API response. `emit()` accepts one topic or an array of
385
+ topics and revalidates active routes that track them. Emit after durable writes
386
+ commit.
387
+
388
+ ## Head
389
+
390
+ Route `head()` loaders return `Head`. Ancestor and page values are merged for
391
+ SSR and client navigation.
392
+
393
+ ```ts
394
+ type Head = {
395
+ title?: string
396
+ meta?: (
397
+ | { name: string; content: string }
398
+ | { property: string; content: string }
399
+ | { httpEquiv: string; content: string }
400
+ )[]
401
+ link?: { rel: string; href: string; [key: string]: string | undefined }[]
402
+ }
403
+ ```
404
+
405
+ ## Mail
406
+
407
+ ```ts
408
+ import { configure, send } from 'ajo-kit/mail'
409
+ import type { Mail, Transport } from 'ajo-kit/mail'
410
+
411
+ const deliver: Transport = async mail => {
412
+ // Send mail with the application's provider.
413
+ }
414
+
415
+ configure(deliver)
416
+
417
+ await send({
418
+ to: 'person@example.com',
419
+ subject: 'Welcome',
420
+ text: 'Welcome to the app.',
421
+ })
422
+ ```
423
+
424
+ `configure()` registers a `Transport` function. The default transport writes
425
+ mail to stdout.
426
+
427
+ ## Vite API
428
+
429
+ ```ts
430
+ import { jsx, kit } from 'ajo-kit/vite'
431
+ import type { Options } from 'ajo-kit/vite'
432
+ import { defineConfig } from 'vite'
433
+
434
+ const options: Options = {
435
+ guard: [/\/src\/data\//],
436
+ css: ['virtual:uno.css'],
437
+ }
438
+
439
+ export default defineConfig({
440
+ plugins: [...kit(options)],
441
+ esbuild: jsx,
442
+ })
443
+ ```
444
+
445
+ `kit()` configures routes, handlers, aliases, server-only guards, HMR, CSS
446
+ entries, and production SSR. Custom `guard` patterns extend the default client
447
+ graph protection.
448
+
449
+ `css` entries load before application hydration. `jsx` configures Ajo's
450
+ automatic JSX runtime. The exported `defaults` object contains the database,
451
+ migrations, and seeds paths used by the CLI.
452
+
453
+ ## Node API
454
+
455
+ ```ts
456
+ import { build, compile, dev, listen, start } from 'ajo-kit/node'
457
+ import type { Options } from 'ajo-kit/node'
458
+
459
+ const options: Options = {
460
+ hmr: { overlay: false },
461
+ }
462
+
463
+ await dev(options)
464
+ ```
465
+
466
+ `dev()`, `build()`, and `start()` expose the CLI runtimes programmatically.
467
+ `compile()` fills `<!-- ssr:name -->` HTML slots. `listen()` starts an
468
+ application server and can require a strict port.
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ import { t as discover } from "../chunks/discover-D-b8Pqm8.js";
3
+ import * as url from "node:url";
4
+ import "dotenv/config";
5
+ import sade from "sade";
6
+ import { build, dev, listen, start } from "ajo-kit/node";
7
+ import { defaults } from "ajo-kit/vite";
8
+ //#region packages/ajo-kit/bin/kit.ts
9
+ var ok = "\x1B[32m✓\x1B[0m";
10
+ var fail = "\x1B[31m✗\x1B[0m";
11
+ var pending = "\x1B[33m○\x1B[0m";
12
+ async function database(path, fn) {
13
+ const { connect, db, close } = await import("ajo-kit/database");
14
+ connect(path);
15
+ try {
16
+ await fn(db);
17
+ } finally {
18
+ await close();
19
+ }
20
+ }
21
+ function report(results, error, empty, suffix = "") {
22
+ for (const r of results ?? []) console.log(`${r.status === "Success" ? ok : fail} ${r.migrationName}${suffix}`);
23
+ if (error) throw error;
24
+ if (!results?.length) console.log(empty);
25
+ }
26
+ var cli = sade("kit");
27
+ cli.command("dev").describe("Start development server").option("-p, --port", "Port number", 5173).action(async (opts) => {
28
+ await listen(await dev(), opts.port);
29
+ });
30
+ cli.command("build").describe("Build for production").action(async () => {
31
+ await build();
32
+ });
33
+ cli.command("start").describe("Start production server").option("-p, --port", "Port number", 5173).action(async (opts) => {
34
+ process.env.NODE_ENV ??= "production";
35
+ await listen(await start(), opts.port);
36
+ });
37
+ cli.command("migrate up").describe("Run pending migrations").option("-d, --database", "Database path", defaults.database).action(async (opts) => {
38
+ await database(opts.database, async (db) => {
39
+ const { migrator } = await import("../chunks/migrate-C9Iytqx4.js");
40
+ const { results, error } = await migrator(db()).migrateToLatest();
41
+ report(results, error, "No pending migrations");
42
+ });
43
+ });
44
+ cli.command("migrate down").describe("Rollback last migration").option("-d, --database", "Database path", defaults.database).action(async (opts) => {
45
+ await database(opts.database, async (db) => {
46
+ const { migrator } = await import("../chunks/migrate-C9Iytqx4.js");
47
+ const { results, error } = await migrator(db()).migrateDown();
48
+ report(results, error, "No migrations to rollback", " (rolled back)");
49
+ });
50
+ });
51
+ cli.command("migrate status").describe("Show migration status").option("-d, --database", "Database path", defaults.database).action(async (opts) => {
52
+ await database(opts.database, async (db) => {
53
+ const { migrationStatus } = await import("../chunks/migrate-C9Iytqx4.js");
54
+ const migrations = await migrationStatus(db());
55
+ for (const m of migrations) console.log(`${m.executedAt ? ok : pending} ${m.name}`);
56
+ });
57
+ });
58
+ cli.command("migrate create <name>").describe("Create a new migration file").action(async (name) => {
59
+ const { mkdirSync, writeFileSync, readdirSync } = await import("node:fs");
60
+ const { join } = await import("node:path");
61
+ const { migrationFile } = await import("../chunks/migrate-C9Iytqx4.js");
62
+ const dir = join(process.cwd(), defaults.migrations);
63
+ mkdirSync(dir, { recursive: true });
64
+ const file = migrationFile(readdirSync(dir), name);
65
+ writeFileSync(join(dir, file), `import type { Kysely } from 'ajo-kit/database'
66
+
67
+ export async function up(db: Kysely<any>): Promise<void> {
68
+ }
69
+
70
+ export async function down(db: Kysely<any>): Promise<void> {
71
+ }
72
+ `);
73
+ console.log(`Created ${join(defaults.migrations, file)}`);
74
+ });
75
+ cli.command("seed").describe("Run database seeds").option("-d, --database", "Database path", defaults.database).action(async (opts) => {
76
+ await database(opts.database, async (db) => {
77
+ const { join } = await import("node:path");
78
+ const { readdirSync } = await import("node:fs");
79
+ const dir = join(process.cwd(), defaults.seeds);
80
+ let files;
81
+ try {
82
+ files = readdirSync(dir).filter((f) => f.endsWith(".ts")).sort();
83
+ } catch {
84
+ files = [];
85
+ }
86
+ if (!files.length) {
87
+ console.log("No seed files found");
88
+ return;
89
+ }
90
+ for (const file of files) {
91
+ const mod = await import(url.pathToFileURL(join(dir, file)).href);
92
+ if (typeof mod.seed === "function") {
93
+ await mod.seed(db());
94
+ console.log(`${ok} ${file}`);
95
+ }
96
+ }
97
+ });
98
+ });
99
+ for (const plugin of discover()) {
100
+ if (!plugin.commands) continue;
101
+ try {
102
+ const mod = await import(url.pathToFileURL(plugin.commands).href);
103
+ if (typeof mod.register === "function") mod.register(cli);
104
+ } catch (error) {
105
+ console.error(`Failed to load commands from ${plugin.name}:`, error);
106
+ }
107
+ }
108
+ cli.parse(process.argv);
109
+ //#endregion