ajo-kit
v0.3.2
Published
Full-stack metaframework for Ajo applications
Maintainers
Readme
ajo-kit
Full-stack metaframework for Ajo with file-based routing, server handlers, form actions, middleware, migrations, and SSE route payload updates.
Install
pnpm add ajo ajo-kit
pnpm add -D vite typescript @types/nodeajo-kit requires ajo ^0.1.35, vite ^8.0.16, and Node 22.18 or newer as
its development, build, test, and CLI host. Production applications execute
only on the ajo engine. TypeScript migrations run through Node's built-in type
stripping for CLI operations and use erasable TypeScript syntax.
Minimal Setup
package.json
{
"type": "module",
"scripts": {
"dev": "kit dev",
"build": "kit build",
"artifact": "kit build --compiler ajo-engine-compiler"
}
}vite.config.ts
import { defineConfig } from 'vite'
import { kit, jsx } from 'ajo-kit/vite'
export default defineConfig({
plugins: [...kit()],
esbuild: jsx,
})tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "ajo",
"strict": true,
"paths": {
"/src/*": ["./src/*"],
"@kit": ["./node_modules/ajo-kit/dist/index.d.ts"],
"@kit/*": ["./node_modules/ajo-kit/dist/*"]
}
}
}index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- ssr:head -->
</head>
<body>
<!-- ssr:data -->
<div id="root"><!-- ssr:root --></div>
<script src="/src/client" type="module"></script>
</body>
</html>src/page.tsx
export default () => (
<main>
<h1>Welcome to ajo-kit</h1>
<p>Edit <code>src/page.tsx</code> to get started.</p>
</main>
)CLI
kit dev [-p 5173]
kit build [--compiler /path/to/ajo-engine-compiler]
kit migrate up [-d ./database.sqlite]
kit migrate down [-d ./database.sqlite]
kit migrate status [-d ./database.sqlite]
kit migrate create <name>
kit seed [-d ./database.sqlite]Defaults:
- database:
./database.sqlite - migrations folder:
db/migrations - seeds folder:
db/seeds
kit build has one target: the ajo engine. It writes the closed server graph,
compiled migration registry, transformed client, and compiler.json descriptor to
.ajo/. Without --compiler it prints the exact compiler command. With --compiler,
it seals that staging tree into dist/ajo. Every build rejects Node builtins and
other imports that violate the engine's closed module graph.
Apps that store SQLite databases on disk must declare their runtime data directory in package.json:
{
"kit": {
"engine": {
"fs": { "roots": ["/ajo/data"] }
}
}
}Create that writable directory and run the engine with AJO_DATA=/ajo/data.
The data path must be inside a declared filesystem root; setting AJO_DATA
alone does not grant access. Use a relative DATABASE_PATH within that directory.
Routing
File-based routes:
flowchart LR
root["src/page.tsx"] --> rootPath["/"]
about["src/about/page.tsx"] --> aboutPath["/about"]
blog["src/blog/[id]/page.tsx"] --> blogPath["/blog/:id"]
docs["src/docs/[...]/page.tsx"] --> docsPath["/docs/*"]
group["src/(app)/dashboard/page.tsx"] --> dashboard["/dashboard"]Per-route files:
page.tsx: page componentlayout.tsx: shared wrapper for a route branchhandler.ts: server loaders/actions/api handlerswares.ts: middleware for that branch and descendants
page.tsx and layout.tsx modules can export pending = true to receive
loading=true while client navigation waits for route data. The page wins
first; otherwise the innermost pending layout handles it.
Server Handlers
handler.ts supports:
import type { ActionContext, Request, Response } from '@kit'
import { send } from '@kit/server'
import type { Head } from '@kit'
export async function layout(req: Request, parent: () => Promise<Record<string, unknown>>) {
return {}
}
export async function page(req: Request, parent: () => Promise<Record<string, unknown>>) {
return {}
}
export async function head(req: Request, parent: () => Promise<Record<string, unknown>>): Promise<Head> {
return { title: 'My page' }
}
export const actions = {
async save(req: Request, res: Response, action: ActionContext) {
action.emit('records')
return { ok: true }
}
}
export default {
async get(req: Request, res: Response) {
send(res, 200, { ok: true })
}
}Notes:
defaultmaps HTTP methods to/api/<route>.- API handlers in
defaultmust write/send the HTTP response. actionsare invoked byPOST /current-route?/actionName.- action
"default"is used when no?/nameis provided. - actions receive an explicit third-argument context;
action.emit()both broadcasts changed topics and includes them in that action's JSON response. parent()resolves merged ancestor loader data.
Actions from Client
import { action } from '@kit/client'
const Page = function* () {
const form = action<{ ok: boolean }>('save')
while (true) {
yield (
<form set:onsubmit={form.submit}>
<input name="title" />
<button disabled={form.loading}>Save</button>
{form.error && <p>{form.error.message}</p>}
</form>
)
}
}You can also trigger programmatically:
await form.invoke({ title: 'Hello' })If an action returns { redirect: '/path' }, client navigation is triggered automatically.
Successful non-redirect actions dispatch ajo:action with returned JSON detail.
Middleware
wares.ts exports one middleware or an array:
import type { Middleware } from '@kit'
const log: Middleware = (req, _res, next) => {
console.log(req.method, req.url)
next()
}
export default logMiddlewares are collected from route ancestors and applied to both page and API handlers.
The root src/wares.ts module may also export one production bootstrap hook:
import type { Bootstrap, Middleware } from '@kit'
import type { DB } from '/src/data/types'
export const bootstrap: Bootstrap<DB> = async ({ db, config }) => {
// Migrations are complete. Perform idempotent application setup here.
}
export default [] satisfies Middleware[]The exact type is:
type Bootstrap<Database = any> = (context: {
db: Kysely<Database>
config: Readonly<{
database: string
host: string
port: number
}>
}) => Promise<void>The Ajo engine loads this named export through the existing root wares registry,
awaits it after its compiled migrations, then calls create() and opens the
listener. A rejection is fatal and closes the engine database.
This is an engine-only production hook. kit migrate remains available for
development and operations, while a sealed engine artifact runs its compiled
migrations before invoking the hook.
SSE Topics (Live Updates)
Track topics in loaders, then emit from server code after mutations:
// src/chat/handler.ts
export async function page(req) {
req.track?.('messages')
return { messages: [] }
}
export const actions = {
async create(req, res, action) {
// write to DB...
action.emit('messages')
return { ok: true }
}
}The runtime opens SSE only when the resolved route tracked at least one topic,
revalidates affected routes, and replaces the active route payload when tracked
topics change. One process accepts at most 128 live streams and at most 8 per
session, bearer token, attached user, or anonymous client address; excess
connections receive 503 or 429 without an SSE upgrade.
Route cache and its scope
The client keeps a small in-memory cache of route payloads (50 entries, 5
minute TTL) and revalidates with X-Have, so an unchanged route costs a 304
instead of a payload. Login and logout are SPA navigations — no reload clears
that cache — so every entry is partitioned by a scope: an opaque label the
server derives per request from whichever credential your auth middleware
attached (req.token, req.session, req.user, else anon), hashed with its
keyspace so ids from different tables cannot collide. Set req.scope in a
middleware to decide the partition yourself.
The scope travels in the SSR document, in route JSON, and in live messages. The client caches only under the scope the payload was computed for, drops the previous partition when the identity changes, and presents the scope alongside its freshness material — the server's fast 304 confirms a hash only for the identity that cached it. Without a scope nothing is cached at all: guessing wrong would mean showing one person another person's data, so it fails closed.
Database and Migrations
ajo-kit/database exports:
connect(path?)db<T>()close()sqland Kysely types
SSE topic versions, active connections, and update fanout are stored in process memory. Multi-process deployments require shared topic coordination and fanout. Store SQLite database files on persistent local disk.
For non-local production, configure APP_URL to the public http or https
origin. When the host supplies the managed origins manifest through
AJO_ORIGINS_FILE, APP_URL must be an exact HTTPS origin with no path,
credentials, query, or fragment, and must appear in that manifest. Applications
choose how to configure their database path:
connect(process.env.DATABASE_PATH ?? './database.sqlite')The Ajo engine accepts :memory: or a relative file path. File paths resolve
beneath the configured runtime application data root; absolute paths and ..
segments are rejected, and file-backed databases require a data root.
kit migrate composes:
- app migrations in
db/migrations - plugin migrations discovered from installed
ajo-*packages that exposepackage.json#kit.migrations
Each migration provider uses a contiguous sequence beginning at 0001, so a
plugin and the app may both define 0001_initial. Stored identities use
plugin/<package>/<name> and project/<name> in one SQLite history and lock.
Every migration exports up() and down(). migrate down rolls back the
latest executed migration across all providers. migrate status rejects
history entries whose migration is unavailable.
kit seed runs sorted db/seeds/*.ts files that export:
export async function seed(db) {
// ...
}Validation
@kit/validate re-exports common Valibot helpers and provides parse(schema, data), which throws Invalid with field-level details.
Plugin Discovery
Installed packages named ajo-* (except ajo-kit) with a kit block in package.json are auto-discovered:
{
"kit": {
"alias": "auth",
"serverOnly": true,
"migrations": "./migrations/",
"commands": "./src/commands.ts"
}
}This enables:
@kit/<alias>import aliases- server-only import protection in Vite
- automatic migration loading
- CLI command extension via
register(cli) - engine descriptor configuration through
kit.engine
A plugin's kit.engine block uses the same env, fs and ipc shape as the
App's block. Builds include installed plugins from dependencies and
devDependencies, validate each declaration, and combine their requirements
with the App's. Shared entries appear once; a variable required by any
contributor is required in the final descriptor. Entries remain sorted, and
malformed or duplicate entries within one declaration fail the build with the
plugin's name.
For example, ajo-kit-server declares the optional AJO_ORIGINS_FILE variable
and the /ajo/origin filesystem root. The host provides that directory as a
read-only mount, including when the App has no custom domains. Builds with
both declarations load the host-origin reader; Apps without this integration
keep their existing descriptor and do not load that reader. To run a platform
artifact directly on the engine, provide the same directory mount.
Public Entry Points
| Import | API |
|---|---|
| ajo-kit or @kit | Route types, HTTP errors, request helpers, navigation, and formatting |
| ajo-kit/server or @kit/server | Server runtime, send(), and emit() |
| ajo-kit/client or @kit/client | Client boot and action() |
| ajo-kit/validate or @kit/validate | Valibot helpers and parse() |
| ajo-kit/database or @kit/database | SQLite, Kysely, and database lifecycle |
| ajo-kit/mail or @kit/mail | Configurable mail transport |
| ajo-kit/vite | Vite plugin, JSX config, and defaults |
| ajo-kit/node | Programmatic Node host utilities for development, engine builds, and tests |
Core API
import {
Denied,
Failure,
Forbidden,
Invalid,
Missing,
ajax,
api,
date,
ip,
navigate,
normalize,
origin,
} from 'ajo-kit'
import type {
Action,
ActionContext,
Entry,
Fields,
Head,
Issue,
LayoutArgs,
Middleware,
PageArgs,
Parent,
Request,
Response,
User,
} from 'ajo-kit'Failure carries an HTTP status. Missing, Forbidden, Denied, and
Invalid represent 404, 403, 401, and 400 responses. normalize() converts an
unknown thrown value into a Failure.
ajax() and api() classify requests. ip() resolves the client address, and
origin(req) resolves the canonical application origin from APP_URL in
production, for links that intentionally use that address. requestOrigin(req)
resolves the current request origin; with a managed origins manifest it accepts
only the direct request host listed by the host. Use it when a request or form
must stay on the current alias. Adding aliases does not redirect requests or
share browser cookies, sessions, or passkey registrations between origins.
navigate() performs client navigation, and date() formats ISO timestamps.
Server API
import { emit, send } from 'ajo-kit/server'
send(res, 200, { ok: true })
emit('posts:list')send() writes an API response. The server-level emit() is the broadcast
path for API handlers, loaders, and other process-level work: it accepts one
topic or an array, increments topic versions, and revalidates active routes
that track them. It never adds topics to an action response. Route actions use
their explicit action.emit() context instead. Emit after durable writes
commit on either path.
Head
Route head() loaders return Head. Ancestor and page values are merged for
SSR and client navigation.
type Head = {
title?: string
meta?: (
| { name: string; content: string }
| { property: string; content: string }
| { httpEquiv: string; content: string }
)[]
link?: { rel: string; href: string; [key: string]: string | undefined }[]
}import { configure, send } from 'ajo-kit/mail'
import type { Mail, Transport } from 'ajo-kit/mail'
const deliver: Transport = async mail => {
// Send mail with the application's provider.
}
configure(deliver)
await send({
to: '[email protected]',
subject: 'Welcome',
text: 'Welcome to the app.',
})configure() registers a Transport function. Without one, send() throws an
actionable error in production. In other environments the default transport
logs only the recipient and subject, never the message body.
Vite API
import { jsx, kit } from 'ajo-kit/vite'
import type { Options } from 'ajo-kit/vite'
import { defineConfig } from 'vite'
const options: Options = {
guard: [/\/src\/data\//],
css: ['virtual:uno.css'],
}
export default defineConfig({
plugins: [...kit(options)],
esbuild: jsx,
})kit() configures routes, handlers, aliases, server-only guards, HMR, CSS
entries, and the engine SSR graph. Custom guard patterns extend the default client
graph protection.
css entries load before application hydration. jsx configures Ajo's
automatic JSX runtime. The exported defaults object contains the database,
migrations, and seeds paths used by the CLI.
Node Host API
import { build, compile, dev, listen } from 'ajo-kit/node'
import type { Options } from 'ajo-kit/node'
const options: Options = {
hmr: { overlay: false },
}
await dev(options)dev() exposes the Vite development shell, and build() stages the engine
artifact inputs while returning the descriptor and graph findings. compile()
fills <!-- ssr:name --> HTML slots. listen() starts Node-hosted development
or test applications and can require a strict port. The default condition
faces for ajo-kit/platform and ajo-kit/database are likewise dev-time Node
shims for Vite, Vitest, and CLI operations; they are not production runtimes.
