npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ajo-kit

v0.3.2

Published

Full-stack metaframework for Ajo applications

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/node

ajo-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 component
  • layout.tsx: shared wrapper for a route branch
  • handler.ts: server loaders/actions/api handlers
  • wares.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:

  • default maps HTTP methods to /api/<route>.
  • API handlers in default must write/send the HTTP response.
  • actions are invoked by POST /current-route?/actionName.
  • action "default" is used when no ?/name is 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 log

Middlewares 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()
  • sql and 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 expose package.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 }[]
}

Mail

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.