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

honertia

v0.1.32

Published

Inertia.js-style server-driven SPA adapter for Hono

Readme

Honertia

Inertia.js adapter for Hono with Effect.ts. Server-driven app with SPA behavior.

CLI Commands

Generate Action

# Basic action
honertia generate:action projects/create --method POST --path /projects

# With authentication
honertia generate:action projects/create --method POST --path /projects --auth required

# With validation schema
honertia generate:action projects/create \
  --method POST \
  --path /projects \
  --auth required \
  --schema "name:string:required, description:string:nullable"

# With route model binding
honertia generate:action projects/update \
  --method PUT \
  --path "/projects/{project}" \
  --auth required \
  --schema "name:string:required"

# Preview without writing
honertia generate:action projects/create --preview

# JSON output for programmatic use
honertia generate:action projects/create --json --preview

Schema format: fieldName:type:modifier

  • Types: string, number, boolean, date, uuid, email, url
  • Modifiers: required (default), nullable, optional

Generate CRUD

# Full CRUD
honertia generate:crud projects

# With schema for create/update
honertia generate:crud projects \
  --schema "name:string:required, description:string:nullable"

# Only specific actions
honertia generate:crud projects --only index,show

# Exclude actions
honertia generate:crud projects --except destroy

# Preview all generated files
honertia generate:crud projects --preview

Generate Feature

# Custom action on a resource
honertia generate:feature projects/archive \
  --method POST \
  --path "/projects/{project}/archive" \
  --auth required

# With fields
honertia generate:feature users/profile \
  --method PUT \
  --path "/profile" \
  --fields "name:string:required, bio:string:nullable"

List Routes

honertia routes              # Table format
honertia routes --json       # JSON for agents
honertia routes --minimal    # METHOD PATH only
honertia routes --method get # Filter by method
honertia routes --prefix /api
honertia routes --pattern '/projects/*'

Project Check

honertia check           # Run all checks
honertia check --json    # JSON output with fix suggestions
honertia check --verbose # Detailed output
honertia check --only routes,naming

OpenAPI Generation

honertia generate:openapi \
  --title "My API" \
  --version "1.0.0" \
  --server https://api.example.com \
  --output openapi.json

# Only API routes
honertia generate:openapi --include /api

# Exclude internal routes
honertia generate:openapi --exclude /internal,/admin

Database Migrations

honertia db status              # Show migration status
honertia db status --json       # JSON output
honertia db migrate             # Run pending migrations
honertia db migrate --preview   # Preview SQL without executing
honertia db rollback            # Rollback last migration
honertia db rollback --preview  # Preview rollback SQL
honertia db generate add_email  # Generate new migration

Installation

bun add honertia hono effect better-auth drizzle-orm
bun add -d @types/bun typescript vite @vitejs/plugin-react @inertiajs/react react react-dom

Required Files

These files MUST exist for the framework to function. Create them in this order.

1. src/types.ts (REQUIRED FIRST)

Type definitions and module augmentation. Without this, TypeScript errors will occur and services won't be typed.

// src/types.ts
import type { DrizzleD1Database } from 'drizzle-orm/d1'
import type { Auth } from './lib/auth'
import * as schema from './db/schema'

// Database type
export type Database = DrizzleD1Database<typeof schema>

// Cloudflare bindings
export type Bindings = {
  DATABASE_URL: string
  BETTER_AUTH_SECRET: string
  ENVIRONMENT?: string
  // Add KV, R2, Queue bindings as needed:
  // KV: KVNamespace
  // R2: R2Bucket
}

// Hono context variables
export type Variables = {
  db: Database
  auth: Auth
}

// Full environment type for Hono
export type Env = {
  Bindings: Bindings
  Variables: Variables
}

// CRITICAL: Module augmentation for type-safe services
declare module 'honertia/effect' {
  interface HonertiaDatabaseType {
    type: Database
    schema: typeof schema
  }
  interface HonertiaAuthType {
    type: Auth
  }
  interface HonertiaBindingsType {
    type: Bindings
  }
}

2. src/db/schema.ts (REQUIRED)

Drizzle schema. Required for route model binding and database queries.

// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { relations } from 'drizzle-orm'

export const users = sqliteTable('users', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
  image: text('image'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})

export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  token: text('token').notNull().unique(),
  expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
  ipAddress: text('ip_address'),
  userAgent: text('user_agent'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})

export const accounts = sqliteTable('accounts', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  accountId: text('account_id').notNull(),
  providerId: text('provider_id').notNull(),
  accessToken: text('access_token'),
  refreshToken: text('refresh_token'),
  accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }),
  refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }),
  scope: text('scope'),
  password: text('password'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})

export const verifications = sqliteTable('verifications', {
  id: text('id').primaryKey(),
  identifier: text('identifier').notNull(),
  value: text('value').notNull(),
  expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})

// Your app tables
export const projects = sqliteTable('projects', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  name: text('name').notNull(),
  description: text('description'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})

// Relations for query builder
export const usersRelations = relations(users, ({ many }) => ({
  projects: many(projects),
  sessions: many(sessions),
  accounts: many(accounts),
}))

export const projectsRelations = relations(projects, ({ one }) => ({
  user: one(users, { fields: [projects.userId], references: [users.id] }),
}))

3. src/db/db.ts (REQUIRED)

Database client factory.

// src/db/db.ts
import { drizzle } from 'drizzle-orm/d1'
import * as schema from './schema'
import type { Database } from '../types'

export function createDb(d1: D1Database): Database {
  return drizzle(d1, { schema })
}

// For local development with better-sqlite3:
// import Database from 'better-sqlite3'
// import { drizzle } from 'drizzle-orm/better-sqlite3'
// export function createDb(path: string): Database {
//   const sqlite = new Database(path)
//   return drizzle(sqlite, { schema })
// }

4. src/lib/auth.ts (REQUIRED)

Better-auth configuration.

// src/lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import type { Database } from '../types'

export function createAuth(options: {
  db: Database
  secret: string
  baseURL: string
}) {
  return betterAuth({
    database: drizzleAdapter(options.db, {
      provider: 'sqlite',
    }),
    secret: options.secret,
    baseURL: options.baseURL,
    emailAndPassword: {
      enabled: true,
    },
    session: {
      expiresIn: 60 * 60 * 24 * 7, // 7 days
      updateAge: 60 * 60 * 24,     // 1 day
    },
  })
}

export type Auth = ReturnType<typeof createAuth>

5. src/index.ts (REQUIRED)

Main app entry point.

// src/index.ts
import { Hono } from 'hono'
import { setupHonertia, createTemplate, createVersion, registerErrorHandlers } from 'honertia'
import * as schema from './db/schema'
import { createDb } from './db/db'
import { createAuth } from './lib/auth'
import { registerRoutes } from './routes'
import type { Env } from './types'

// Import manifest (generated by Vite build)
// @ts-ignore - Generated at build time
import manifest from '../dist/manifest.json'

const app = new Hono<Env>()

app.use('*', setupHonertia<Env>({
  honertia: {
    version: createVersion(manifest),
    render: createTemplate((ctx) => ({
      title: 'My App',
      scripts: [manifest['src/main.tsx']?.file].filter(Boolean),
      styles: manifest['src/main.tsx']?.css ?? [],
    })),
    database: (c) => createDb(c.env.DB),
    auth: (c) => createAuth({
      db: c.var.db,
      secret: c.env.BETTER_AUTH_SECRET,
      baseURL: new URL(c.req.url).origin,
    }),
    schema,
  },
}))

registerRoutes(app)
registerErrorHandlers(app)

export default app

6. src/routes.ts (REQUIRED)

Route definitions.

// src/routes.ts
import type { Hono } from 'hono'
import type { Env } from './types'
import { effectRoutes } from 'honertia/effect'
import { effectAuthRoutes, RequireAuthLayer } from 'honertia/auth'

// Import your actions
import { loginUser, registerUser, logoutUser } from './actions/auth'
// import { listProjects, showProject, createProject } from './actions/projects'

export function registerRoutes(app: Hono<Env>) {
  // Auth routes (handles /login, /register, /logout, /api/auth/*)
  effectAuthRoutes(app, {
    loginComponent: 'Auth/Login',
    registerComponent: 'Auth/Register',
    loginAction: loginUser,
    registerAction: registerUser,
    logoutAction: logoutUser,
  })

  // Protected routes (require authentication)
  effectRoutes(app)
    .provide(RequireAuthLayer)
    .group((route) => {
      // Add your protected routes here
      // route.get('/', showDashboard)
      // route.prefix('/projects').group((route) => {
      //   route.get('/', listProjects)
      //   route.get('/{project}', showProject)
      //   route.post('/', createProject)
      // })
    })

  // Public routes (no auth required)
  effectRoutes(app).group((route) => {
    // route.get('/about', showAbout)
  })
}

7. src/main.tsx (REQUIRED)

Client-side entry point.

// src/main.tsx
import './styles.css'
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'

const pages = import.meta.glob('./pages/**/*.tsx')

createInertiaApp({
  resolve: (name) => {
    const page = pages[`./pages/${name}.tsx`]
    if (!page) {
      throw new Error(`Page not found: ${name}. Create src/pages/${name}.tsx`)
    }
    return page()
  },
  setup({ el, App, props }) {
    createRoot(el!).render(<App {...props} />)
  },
})

8. wrangler.toml (REQUIRED for Cloudflare)

name = "my-app"
compatibility_date = "2024-01-01"
main = "src/index.ts"

[vars]
ENVIRONMENT = "development"

[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "your-database-id"

[site]
bucket = "./dist"

9. vite.config.ts (REQUIRED)

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: 'dist',
    manifest: 'manifest.json',
    rollupOptions: {
      input: 'src/main.tsx',
    },
  },
  resolve: {
    alias: {
      '~': path.resolve(__dirname, 'src'),
    },
  },
})

10. tsconfig.json (REQUIRED)

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["bun-types", "@cloudflare/workers-types"],
    "paths": {
      "~/*": ["./src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Project Structure

src/
  index.ts          # App entry, setupHonertia() - REQUIRED
  routes.ts         # Route definitions - REQUIRED
  types.ts          # Type definitions - REQUIRED
  main.tsx          # Client entry - REQUIRED
  styles.css        # Global styles
  db/
    db.ts           # Database factory - REQUIRED
    schema.ts       # Drizzle schema - REQUIRED
  lib/
    auth.ts         # Auth config - REQUIRED
  actions/
    auth/
      login.ts
      register.ts
      logout.ts
    projects/
      index.ts
      show.ts
      create.ts
  pages/
    Auth/
      Login.tsx
      Register.tsx
    Projects/
      Index.tsx
      Show.tsx
      Create.tsx
    Error.tsx         # Error page component
wrangler.toml       # Cloudflare config - REQUIRED
vite.config.ts      # Vite config - REQUIRED
tsconfig.json       # TypeScript config - REQUIRED

Auth Actions (REQUIRED)

These three actions are required for effectAuthRoutes to work.

src/actions/auth/login.ts

import { betterAuthFormAction } from 'honertia/auth'
import { Schema as S } from 'effect'
import { email, requiredString } from 'honertia/effect'

const LoginSchema = S.Struct({
  email: email,
  password: requiredString,
})

export const loginUser = betterAuthFormAction({
  schema: LoginSchema,
  errorComponent: 'Auth/Login',
  redirectTo: '/',
  errorMapper: (error) => {
    switch (error.code) {
      case 'INVALID_EMAIL_OR_PASSWORD':
        return { email: 'Invalid email or password' }
      case 'USER_NOT_FOUND':
        return { email: 'No account found with this email' }
      default:
        return { email: 'Login failed' }
    }
  },
  call: (auth, input, request) =>
    auth.api.signInEmail({
      body: { email: input.email, password: input.password },
      request,
      returnHeaders: true,
    }),
})

src/actions/auth/register.ts

import { betterAuthFormAction } from 'honertia/auth'
import { Schema as S } from 'effect'
import { email, requiredString, password } from 'honertia/effect'

const RegisterSchema = S.Struct({
  name: requiredString,
  email: email,
  password: password({ min: 8, letters: true, numbers: true }),
})

export const registerUser = betterAuthFormAction({
  schema: RegisterSchema,
  errorComponent: 'Auth/Register',
  redirectTo: '/',
  errorMapper: (error) => {
    switch (error.code) {
      case 'USER_ALREADY_EXISTS':
        return { email: 'An account with this email already exists' }
      default:
        return { email: 'Registration failed' }
    }
  },
  call: (auth, input, request) =>
    auth.api.signUpEmail({
      body: { name: input.name, email: input.email, password: input.password },
      request,
      returnHeaders: true,
    }),
})

src/actions/auth/logout.ts

import { betterAuthLogoutAction } from 'honertia/auth'

export const logoutUser = betterAuthLogoutAction({
  redirectTo: '/login',
})

src/actions/auth/index.ts

export { loginUser } from './login'
export { registerUser } from './register'
export { logoutUser } from './logout'

Minimum Page Components (REQUIRED)

src/pages/Auth/Login.tsx

import { useForm } from '@inertiajs/react'

export default function Login() {
  const { data, setData, post, processing, errors } = useForm({
    email: '',
    password: '',
  })

  const submit = (e: React.FormEvent) => {
    e.preventDefault()
    post('/login')
  }

  return (
    <form onSubmit={submit}>
      <div>
        <input
          type="email"
          value={data.email}
          onChange={(e) => setData('email', e.target.value)}
          placeholder="Email"
        />
        {errors.email && <span>{errors.email}</span>}
      </div>
      <div>
        <input
          type="password"
          value={data.password}
          onChange={(e) => setData('password', e.target.value)}
          placeholder="Password"
        />
        {errors.password && <span>{errors.password}</span>}
      </div>
      <button type="submit" disabled={processing}>
        Login
      </button>
    </form>
  )
}

src/pages/Auth/Register.tsx

import { useForm } from '@inertiajs/react'

export default function Register() {
  const { data, setData, post, processing, errors } = useForm({
    name: '',
    email: '',
    password: '',
  })

  const submit = (e: React.FormEvent) => {
    e.preventDefault()
    post('/register')
  }

  return (
    <form onSubmit={submit}>
      <div>
        <input
          type="text"
          value={data.name}
          onChange={(e) => setData('name', e.target.value)}
          placeholder="Name"
        />
        {errors.name && <span>{errors.name}</span>}
      </div>
      <div>
        <input
          type="email"
          value={data.email}
          onChange={(e) => setData('email', e.target.value)}
          placeholder="Email"
        />
        {errors.email && <span>{errors.email}</span>}
      </div>
      <div>
        <input
          type="password"
          value={data.password}
          onChange={(e) => setData('password', e.target.value)}
          placeholder="Password"
        />
        {errors.password && <span>{errors.password}</span>}
      </div>
      <button type="submit" disabled={processing}>
        Register
      </button>
    </form>
  )
}

src/pages/Error.tsx

interface ErrorProps {
  status: number
  title: string
  message: string
}

export default function Error({ status, title, message }: ErrorProps) {
  return (
    <div>
      <h1>{status}</h1>
      <h2>{title}</h2>
      <p>{message}</p>
      <a href="/">Go home</a>
    </div>
  )
}

Setup Checklist

  1. Run bun add honertia hono effect better-auth drizzle-orm
  2. Create src/types.ts with module augmentation
  3. Create src/db/schema.ts with your tables
  4. Create src/db/db.ts with database factory
  5. Create src/lib/auth.ts with auth config
  6. Create src/index.ts with app setup
  7. Create src/routes.ts with route definitions
  8. Create src/actions/auth/*.ts with auth actions
  9. Create src/main.tsx with client entry
  10. Create src/pages/Auth/Login.tsx and Register.tsx
  11. Create src/pages/Error.tsx
  12. Create wrangler.toml, vite.config.ts, tsconfig.json
  13. Run bun run build then wrangler dev

Action Examples

Simple GET (Public Page)

import { Effect } from 'effect'
import { action, render } from 'honertia/effect'

export const showAbout = action(
  Effect.gen(function* () {
    return yield* render('About', {})
  })
)

GET with Authentication and Caching

import { Effect, Schema as S, Duration } from 'effect'
import { action, authorize, render, DatabaseService, cache } from 'honertia/effect'
import { eq } from 'drizzle-orm'
import { projects } from '~/db/schema'

const ProjectSchema = S.Struct({
  id: S.String,
  userId: S.String,
  name: S.String,
  description: S.NullOr(S.String),
  createdAt: S.Date,
  updatedAt: S.Date,
})

export const listProjects = action(
  Effect.gen(function* () {
    const auth = yield* authorize()
    const db = yield* DatabaseService

    // Cache expensive database query for 5 minutes
    const userProjects = yield* cache(
      `projects:user:${auth.user.id}`,
      Effect.tryPromise(() =>
        db.query.projects.findMany({
          where: eq(projects.userId, auth.user.id),
          orderBy: (p, { desc }) => [desc(p.createdAt)],
        })
      ),
      S.Array(ProjectSchema),
      Duration.minutes(5)
    )

    return yield* render('Projects/Index', { projects: userProjects })
  })
)

GET with Route Model Binding

import { Effect } from 'effect'
import { action, authorize, bound, render } from 'honertia/effect'

export const showProject = action(
  Effect.gen(function* () {
    const auth = yield* authorize()
    const project = yield* bound('project')  // Auto-fetched from {project} param

    return yield* render('Projects/Show', { project })
  })
)

POST with Validation

import { Effect, Schema as S } from 'effect'
import {
  action,
  authorize,
  validateRequest,
  DatabaseService,
  redirect,
  asTrusted,
  dbMutation,
  requiredString,
} from 'honertia/effect'
import { projects } from '~/db/schema'

const CreateProjectSchema = S.Struct({
  name: requiredString,
  description: S.optional(S.String),
})

export const createProject = action(
  Effect.gen(function* () {
    const auth = yield* authorize()
    const input = yield* validateRequest(CreateProjectSchema, {
      errorComponent: 'Projects/Create',
    })
    const db = yield* DatabaseService

    yield* dbMutation(db, async (db) => {
      await db.insert(projects).values(asTrusted({
        name: input.name,
        description: input.description ?? null,
        userId: auth.user.id,
      }))
    })

    return yield* redirect('/projects')
  })
)

PUT with Route Binding and Validation

import { Effect, Schema as S } from 'effect'
import {
  action,
  authorize,
  bound,
  validateRequest,
  DatabaseService,
  redirect,
  asTrusted,
  dbMutation,
  requiredString,
  forbidden,
} from 'honertia/effect'
import { eq } from 'drizzle-orm'
import { projects } from '~/db/schema'

const UpdateProjectSchema = S.Struct({
  name: requiredString,
  description: S.optional(S.String),
})

export const updateProject = action(
  Effect.gen(function* () {
    const auth = yield* authorize()
    const project = yield* bound('project')

    // Authorization check
    if (project.userId !== auth.user.id) {
      return yield* forbidden('You cannot edit this project')
    }

    const input = yield* validateRequest(UpdateProjectSchema, {
      errorComponent: 'Projects/Edit',
    })
    const db = yield* DatabaseService

    yield* dbMutation(db, async (db) => {
      await db.update(projects)
        .set(asTrusted({
          name: input.name,
          description: input.description ?? null,
        }))
        .where(eq(projects.id, project.id))
    })

    return yield* redirect(`/projects/${project.id}`)
  })
)

DELETE with Route Binding

import { Effect } from 'effect'
import {
  action,
  authorize,
  bound,
  DatabaseService,
  redirect,
  forbidden,
  dbMutation,
} from 'honertia/effect'
import { eq } from 'drizzle-orm'
import { projects } from '~/db/schema'

export const destroyProject = action(
  Effect.gen(function* () {
    const auth = yield* authorize()
    const project = yield* bound('project')

    if (project.userId !== auth.user.id) {
      return yield* forbidden('You cannot delete this project')
    }

    const db = yield* DatabaseService

    yield* dbMutation(db, async (db) => {
      await db.delete(projects).where(eq(projects.id, project.id))
    })

    return yield* redirect('/projects')
  })
)

API Endpoint (JSON Response)

import { Effect, Schema as S } from 'effect'
import { action, validateRequest, DatabaseService, json } from 'honertia/effect'
import { like } from 'drizzle-orm'
import { projects } from '~/db/schema'

const SearchSchema = S.Struct({
  q: S.String,
  limit: S.optional(S.NumberFromString).pipe(S.withDefault(() => 10)),
})

export const searchProjects = action(
  Effect.gen(function* () {
    const { q, limit } = yield* validateRequest(SearchSchema)
    const db = yield* DatabaseService

    const results = yield* Effect.tryPromise(() =>
      db.query.projects.findMany({
        where: like(projects.name, `%${q}%`),
        limit,
      })
    )

    return yield* json({ results, count: results.length })
  })
)

Action with Role Check

import { Effect } from 'effect'
import { action, authorize, DatabaseService, render } from 'honertia/effect'

export const adminDashboard = action(
  Effect.gen(function* () {
    // authorize() with callback checks role
    const auth = yield* authorize((a) => a.user.role === 'admin')
    const db = yield* DatabaseService

    const stats = yield* Effect.tryPromise(() =>
      db.query.users.findMany({ limit: 100 })
    )

    return yield* render('Admin/Dashboard', { stats })
  })
)

Action with Custom Error Handling

import { Effect } from 'effect'
import {
  action,
  authorize,
  DatabaseService,
  render,
  notFound,
  httpError,
} from 'honertia/effect'
import { eq } from 'drizzle-orm'
import { projects } from '~/db/schema'

export const showProject = action(
  Effect.gen(function* () {
    const auth = yield* authorize()
    const db = yield* DatabaseService
    const request = yield* RequestService
    const projectId = request.param('id')

    const project = yield* Effect.tryPromise(() =>
      db.query.projects.findFirst({
        where: eq(projects.id, projectId),
      })
    )

    if (!project) {
      return yield* notFound('Project', projectId)
    }

    if (project.userId !== auth.user.id) {
      return yield* httpError(403, 'Access denied')
    }

    return yield* render('Projects/Show', { project })
  })
)

Validation Examples

String Validators

import { Schema as S } from 'effect'
import {
  requiredString,    // Trimmed, non-empty
  nullableString,    // Empty string -> null
  email,             // Email format
  url,               // URL format
  uuid,              // UUID format
  alpha,             // Letters only
  alphaDash,         // Letters, numbers, dashes, underscores
  alphaNum,          // Letters and numbers
  min,               // min(5) - at least 5 chars
  max,               // max(100) - at most 100 chars
  size,              // size(10) - exactly 10 chars
} from 'honertia/effect'

const UserSchema = S.Struct({
  name: requiredString,
  bio: nullableString,
  email: email,
  website: S.optional(url),
  username: alphaDash.pipe(min(3), max(20)),
})

Number Validators

import { Schema as S } from 'effect'
import {
  coercedNumber,     // String -> number
  positiveInt,       // > 0
  nonNegativeInt,    // >= 0
  between,           // between(1, 100)
  gt,                // gt(0) - greater than
  gte,               // gte(0) - greater than or equal
  lt,                // lt(100)
  lte,               // lte(100)
} from 'honertia/effect'

const ProductSchema = S.Struct({
  price: coercedNumber.pipe(gte(0)),
  quantity: positiveInt,
  discount: S.optional(coercedNumber.pipe(between(0, 100))),
})

Boolean and Date Validators

import { Schema as S } from 'effect'
import {
  coercedBoolean,    // "true", "1", "on" -> true
  checkbox,          // HTML checkbox (defaults to false)
  accepted,          // Must be truthy (for terms acceptance)
  coercedDate,       // String -> Date
  nullableDate,      // Empty string -> null
  after,             // after(new Date()) - must be in future
  before,            // before('2025-12-31')
} from 'honertia/effect'

const EventSchema = S.Struct({
  isPublic: checkbox,
  termsAccepted: accepted,
  startDate: coercedDate.pipe(after(new Date())),
  endDate: S.optional(nullableDate),
})

Password Validator

import { password } from 'honertia/effect'

const RegisterSchema = S.Struct({
  email: email,
  password: password({
    min: 8,
    letters: true,
    mixedCase: true,
    numbers: true,
    symbols: true,
  }),
})

Full Form Example

import { Schema as S } from 'effect'
import {
  requiredString,
  nullableString,
  email,
  coercedNumber,
  checkbox,
  coercedDate,
  between,
} from 'honertia/effect'

const CreateEventSchema = S.Struct({
  title: requiredString.pipe(S.maxLength(200)),
  description: nullableString,
  organizerEmail: email,
  maxAttendees: coercedNumber.pipe(between(1, 10000)),
  isPublic: checkbox,
  startDate: coercedDate,
  endDate: S.optional(coercedDate),
})

// In action
const input = yield* validateRequest(CreateEventSchema, {
  errorComponent: 'Events/Create',
  messages: {
    title: 'Please enter an event title',
    organizerEmail: 'Please enter a valid email address',
  },
  attributes: {
    maxAttendees: 'maximum attendees',
  },
})

Route Model Binding Examples

Basic Binding (by ID)

// Route: /projects/{project}
// Queries: SELECT * FROM projects WHERE id = :project

effectRoutes(app).get('/projects/{project}', showProject)

const showProject = action(
  Effect.gen(function* () {
    const project = yield* bound('project')
    return yield* render('Projects/Show', { project })
  })
)

Binding by Slug

// Route: /projects/{project:slug}
// Queries: SELECT * FROM projects WHERE slug = :project

effectRoutes(app).get('/projects/{project:slug}', showProject)

Nested Binding (Scoped)

// Route: /users/{user}/posts/{post}
// Queries:
//   1. SELECT * FROM users WHERE id = :user
//   2. SELECT * FROM posts WHERE id = :post AND userId = :user.id

effectRoutes(app).get('/users/{user}/posts/{post}', showUserPost)

const showUserPost = action(
  Effect.gen(function* () {
    const user = yield* bound('user')
    const post = yield* bound('post')  // Already scoped to user
    return yield* render('Users/Posts/Show', { user, post })
  })
)

Mixed Notation

// :version is a regular param, {project} is bound
effectRoutes(app).get('/api/:version/projects/{project}', showProject)

const showProject = action(
  Effect.gen(function* () {
    const request = yield* RequestService
    const version = request.param('version')  // Regular param
    const project = yield* bound('project')   // Database model
    return yield* json({ version, project })
  })
)

With Param Validation

// Validate UUID format before database lookup
effectRoutes(app).get(
  '/projects/{project}',
  showProject,
  { params: S.Struct({ project: uuid }) }
)

Auth Examples

Auth Routes Setup

import { effectAuthRoutes } from 'honertia/auth'

effectAuthRoutes(app, {
  // Page components
  loginComponent: 'Auth/Login',
  registerComponent: 'Auth/Register',

  // Form actions
  loginAction: loginUser,
  registerAction: registerUser,
  logoutAction: logoutUser,

  // Paths (defaults shown)
  loginPath: '/login',
  registerPath: '/register',
  logoutPath: '/logout',
  apiPath: '/api/auth',

  // Redirects
  loginRedirect: '/',
  logoutRedirect: '/login',

  // Extended flows
  guestActions: {
    '/login/2fa': verify2FA,
    '/forgot-password': forgotPassword,
  },

  // CORS for API (if frontend on different origin)
  cors: {
    origin: ['http://localhost:5173'],
    credentials: true,
  },
})

Login Action

import { betterAuthFormAction } from 'honertia/auth'
import { Schema as S } from 'effect'
import { email, requiredString } from 'honertia/effect'

const LoginSchema = S.Struct({
  email: email,
  password: requiredString,
})

const mapLoginError = (error: { code?: string }) => {
  switch (error.code) {
    case 'INVALID_EMAIL_OR_PASSWORD':
      return { email: 'Invalid email or password' }
    case 'USER_NOT_FOUND':
      return { email: 'No account found with this email' }
    default:
      return { email: 'Login failed' }
  }
}

export const loginUser = betterAuthFormAction({
  schema: LoginSchema,
  errorComponent: 'Auth/Login',
  redirectTo: '/',
  errorMapper: mapLoginError,
  call: (auth, input, request) =>
    auth.api.signInEmail({
      body: { email: input.email, password: input.password },
      request,
      returnHeaders: true,
    }),
})

Register Action

import { betterAuthFormAction } from 'honertia/auth'
import { Schema as S } from 'effect'
import { email, requiredString, password } from 'honertia/effect'

const RegisterSchema = S.Struct({
  name: requiredString,
  email: email,
  password: password({ min: 8, letters: true, numbers: true }),
})

const mapRegisterError = (error: { code?: string }) => {
  switch (error.code) {
    case 'USER_ALREADY_EXISTS':
      return { email: 'An account with this email already exists' }
    default:
      return { email: 'Registration failed' }
  }
}

export const registerUser = betterAuthFormAction({
  schema: RegisterSchema,
  errorComponent: 'Auth/Register',
  redirectTo: '/',
  errorMapper: mapRegisterError,
  call: (auth, input, request) =>
    auth.api.signUpEmail({
      body: { name: input.name, email: input.email, password: input.password },
      request,
      returnHeaders: true,
    }),
})

Logout Action

import { betterAuthLogoutAction } from 'honertia/auth'

export const logoutUser = betterAuthLogoutAction({
  redirectTo: '/login',
})

Auth Layers

import { RequireAuthLayer, RequireGuestLayer } from 'honertia/auth'

// Require logged-in user (redirects to /login if not)
effectRoutes(app)
  .provide(RequireAuthLayer)
  .group((route) => {
    route.get('/dashboard', showDashboard)
    route.get('/settings', showSettings)
  })

// Require guest (redirects to / if logged in)
effectRoutes(app)
  .provide(RequireGuestLayer)
  .group((route) => {
    route.get('/login', showLogin)
    route.get('/register', showRegister)
  })

Manual Auth Check in Action

import { authorize, isAuthenticated, currentUser } from 'honertia/effect'

// Require auth (fails if not logged in)
const auth = yield* authorize()

// Require specific role
const auth = yield* authorize((a) => a.user.role === 'admin')

// Check without failing
const isLoggedIn = yield* isAuthenticated  // boolean
const user = yield* currentUser            // AuthUser | null

Error Handling Examples

Throwing Errors

import {
  notFound,
  forbidden,
  httpError,
  ValidationError,
  UnauthorizedError,
} from 'honertia/effect'

// 404 Not Found
return yield* notFound('Project', projectId)

// 403 Forbidden
return yield* forbidden('You cannot edit this project')

// Custom HTTP error
return yield* httpError(429, 'Rate limit exceeded', { retryAfter: 60 })

// Manual validation error
yield* Effect.fail(new ValidationError({
  errors: { email: 'This email is already taken' },
  component: 'Auth/Register',
}))

// Manual unauthorized
yield* Effect.fail(new UnauthorizedError({
  message: 'Session expired',
  redirectTo: '/login',
}))

Error Handler Setup

import { registerErrorHandlers } from 'honertia'

registerErrorHandlers(app, {
  component: 'Error',           // Error page component
  showDevErrors: true,          // Show details in dev
  envKey: 'ENVIRONMENT',
  devValue: 'development',
})

Error Page Component

// src/pages/Error.tsx
interface ErrorProps {
  status: number
  code: string
  title: string
  message: string
  hint?: string           // Only in dev
  fixes?: Array<{ description: string }>  // Only in dev
  source?: { file: string; line: number } // Only in dev
}

export default function Error({ status, title, message, hint, fixes }: ErrorProps) {
  return (
    <div className="error-page">
      <h1>{status}</h1>
      <h2>{title}</h2>
      <p>{message}</p>
      {hint && <p className="hint">{hint}</p>}
      {fixes?.map((fix, i) => <div key={i}>{fix.description}</div>)}
    </div>
  )
}

Response Helpers

import { render, redirect, json, notFound, forbidden, httpError } from 'honertia/effect'

// Render page with props
return yield* render('Projects/Index', { projects })

// Render with validation errors
return yield* renderWithErrors('Projects/Create', {
  name: 'Name is required',
})

// Redirect (303 for POST, 302 otherwise)
return yield* redirect('/projects')
return yield* redirect('/login', 302)

// JSON response
return yield* json({ success: true })
return yield* json({ error: 'Not found' }, 404)

// Error responses
return yield* notFound('Project')
return yield* forbidden('Access denied')
return yield* httpError(429, 'Rate limited')

Caching

Honertia provides a CacheService for caching expensive database operations. It's automatically provided and backed by Cloudflare KV by default, but can be swapped for Redis, Memcached, or any other implementation.

Setup

Add KV to your wrangler.toml:

[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id"

Update your bindings type in src/types.ts:

export type Bindings = {
  DATABASE_URL: string
  BETTER_AUTH_SECRET: string
  KV: KVNamespace  // Add this
}

No additional registration needed - CacheService is automatically available in all actions.

Basic Usage

import { Effect, Schema as S, Duration } from 'effect'
import { action, authorize, render, DatabaseService, cache } from 'honertia/effect'
import { eq } from 'drizzle-orm'
import { projects } from '~/db/schema'

const ProjectSchema = S.Struct({
  id: S.String,
  userId: S.String,
  name: S.String,
  description: S.NullOr(S.String),
  createdAt: S.Date,
  updatedAt: S.Date,
})

export const listProjects = action(
  Effect.gen(function* () {
    const auth = yield* authorize()
    const db = yield* DatabaseService

    // Cache the database query for 5 minutes
    const userProjects = yield* cache(
      `projects:user:${auth.user.id}`,
      Effect.tryPromise({
        try: () =>
          db.query.projects.findMany({
            where: eq(projects.userId, auth.user.id),
            orderBy: (p, { desc }) => [desc(p.createdAt)],
          }),
        catch: (error) => new Error(String(error)),
      }),
      S.Array(ProjectSchema),
      Duration.minutes(5)
    )

    return yield* render('Projects/Index', { projects: userProjects })
  })
)

Cache Functions

| Function | Description | |----------|-------------| | cache(key, compute, schema, ttl) | Get from cache or compute and store | | cacheGet(key, schema) | Get value from cache (returns Option) | | cacheSet(key, value, schema, ttl) | Store value in cache | | cacheInvalidate(key) | Delete a single cache key | | cacheInvalidatePrefix(prefix) | Delete all keys with prefix |

Cache Invalidation

Invalidate cache when data changes:

import { Effect, Schema as S } from 'effect'
import {
  action,
  authorize,
  validateRequest,
  DatabaseService,
  redirect,
  asTrusted,
  dbMutation,
  requiredString,
  cacheInvalidate,
} from 'honertia/effect'
import { projects } from '~/db/schema'

const CreateProjectSchema = S.Struct({
  name: requiredString,
  description: S.optional(S.String),
})

export const createProject = action(
  Effect.gen(function* () {
    const auth = yield* authorize()
    const input = yield* validateRequest(CreateProjectSchema, {
      errorComponent: 'Projects/Create',
    })
    const db = yield* DatabaseService

    yield* dbMutation(db, async (db) => {
      await db.insert(projects).values(
        asTrusted({
          name: input.name,
          description: input.description ?? null,
          userId: auth.user.id,
        })
      )
    })

    // Invalidate the user's project list cache
    yield* cacheInvalidate(`projects:user:${auth.user.id}`)

    return yield* redirect('/projects')
  })
)

Invalidate by Prefix

Delete all cache keys matching a prefix:

import { cacheInvalidatePrefix } from 'honertia/effect'

// Invalidate all caches for a user
yield* cacheInvalidatePrefix(`user:${userId}:`)

// Invalidate all project-related caches
yield* cacheInvalidatePrefix('projects:')

Manual Get/Set

For more control over cache operations:

import { Effect, Option, Schema as S, Duration } from 'effect'
import { cacheGet, cacheSet } from 'honertia/effect'

const UserSchema = S.Struct({
  id: S.String,
  name: S.String,
  email: S.String,
})

// Check cache first
const cached = yield* cacheGet(`user:${id}`, UserSchema)

if (Option.isSome(cached)) {
  return cached.value
}

// Compute value
const user = yield* fetchUser(id)

// Store in cache
yield* cacheSet(`user:${id}`, user, UserSchema, Duration.hours(1))

return user

Using CacheService Directly

For advanced use cases, access the underlying service:

import { Effect } from 'effect'
import { CacheService } from 'honertia/effect'

const handler = action(
  Effect.gen(function* () {
    const cache = yield* CacheService

    // Raw get (returns string | null)
    const raw = yield* cache.get('my-key')

    // Raw put
    yield* cache.put('my-key', JSON.stringify({ data: 'value' }), {
      expirationTtl: 3600, // seconds
    })

    // Delete
    yield* cache.delete('my-key')

    // List keys by prefix
    const keys = yield* cache.list({ prefix: 'user:' })
  })
)

Custom Cache Implementation

Swap out Cloudflare KV for Redis or any other backend by providing a custom CacheService layer:

import { Effect, Layer } from 'effect'
import { CacheService, CacheClientError, type CacheClient } from 'honertia/effect'
import { createClient } from 'redis'

const createRedisCacheClient = (redisUrl: string): CacheClient => {
  const client = createClient({ url: redisUrl })

  return {
    get: (key) =>
      Effect.tryPromise({
        try: () => client.get(key),
        catch: (e) => new CacheClientError('Redis get failed', e),
      }),
    put: (key, value, options) =>
      Effect.tryPromise({
        try: () =>
          client.set(key, value, options?.expirationTtl ? { EX: options.expirationTtl } : undefined),
        catch: (e) => new CacheClientError('Redis set failed', e),
      }).pipe(Effect.asVoid),
    delete: (key) =>
      Effect.tryPromise({
        try: () => client.del(key),
        catch: (e) => new CacheClientError('Redis delete failed', e),
      }).pipe(Effect.asVoid),
    list: (options) =>
      Effect.tryPromise({
        try: async () => {
          const keys = await client.keys(options?.prefix ? `${options.prefix}*` : '*')
          return { keys: keys.map((name) => ({ name })) }
        },
        catch: (e) => new CacheClientError('Redis keys failed', e),
      }),
  }
}

// Provide in your app setup
const RedisCacheLayer = Layer.succeed(
  CacheService,
  createRedisCacheClient(process.env.REDIS_URL!)
)

Testing with Cache

Create a test layer that uses an in-memory store:

import { Effect, Layer, Option, Schema as S, Duration } from 'effect'
import { CacheService, cache, cacheGet, cacheInvalidate, type CacheClient } from 'honertia/effect'
import { describe, it, expect } from 'bun:test'

const makeTestCache = (): Layer.Layer<CacheService> => {
  const store = new Map<string, { value: string; expiresAt: number }>()

  const client: CacheClient = {
    get: (key) =>
      Effect.sync(() => {
        const entry = store.get(key)
        if (!entry || entry.expiresAt < Date.now()) {
          store.delete(key)
          return null
        }
        return entry.value
      }),
    put: (key, value, options) =>
      Effect.sync(() => {
        const ttlMs = (options?.expirationTtl ?? 3600) * 1000
        store.set(key, { value, expiresAt: Date.now() + ttlMs })
      }),
    delete: (key) =>
      Effect.sync(() => {
        store.delete(key)
      }),
    list: (options) =>
      Effect.sync(() => ({
        keys: [...store.keys()]
          .filter((k) => !options?.prefix || k.startsWith(options.prefix))
          .map((name) => ({ name })),
      })),
  }

  return Layer.succeed(CacheService, client)
}

const TestSchema = S.Struct({
  id: S.String,
  name: S.String,
})

describe('cache', () => {
  it('returns cached value on second call', () =>
    Effect.gen(function* () {
      let callCount = 0

      const compute = Effect.sync(() => {
        callCount++
        return { id: '1', name: 'Test' }
      })

      const first = yield* cache('test:1', compute, TestSchema, Duration.hours(1))
      const second = yield* cache('test:1', compute, TestSchema, Duration.hours(1))

      expect(first).toEqual(second)
      expect(callCount).toBe(1) // Only computed once
    }).pipe(Effect.provide(makeTestCache()), Effect.runPromise))

  it('recomputes after invalidation', () =>
    Effect.gen(function* () {
      let callCount = 0

      const compute = Effect.sync(() => {
        callCount++
        return { id: '1', name: `Call ${callCount}` }
      })

      yield* cache('test:1', compute, TestSchema, Duration.hours(1))
      yield* cacheInvalidate('test:1')
      yield* cache('test:1', compute, TestSchema, Duration.hours(1))

      expect(callCount).toBe(2) // Computed twice
    }).pipe(Effect.provide(makeTestCache()), Effect.runPromise))

  it('returns Option.none for missing keys', () =>
    Effect.gen(function* () {
      const result = yield* cacheGet('nonexistent', TestSchema)
      expect(Option.isNone(result)).toBe(true)
    }).pipe(Effect.provide(makeTestCache()), Effect.runPromise))
})

Cache Key Patterns

Recommended cache key patterns:

// User-scoped data
`user:${userId}:profile`
`user:${userId}:settings`
`user:${userId}:notifications`

// Resource lists
`projects:user:${userId}`
`posts:category:${categoryId}`

// Individual resources
`project:${projectId}`
`user:${userId}`

// Computed data
`stats:daily:${date}`
`leaderboard:weekly`

// API responses
`api:weather:${city}`
`api:exchange:${currency}`

Services Reference

| Service | Description | Usage | |---------|-------------|-------| | DatabaseService | Drizzle database client | const db = yield* DatabaseService | | AuthService | Better-auth instance | const auth = yield* AuthService | | AuthUserService | Current user session | const user = yield* AuthUserService | | BindingsService | Cloudflare bindings | const { KV } = yield* BindingsService | | CacheService | KV-backed cache client | const cache = yield* CacheService | | RequestService | Request context | const req = yield* RequestService |

Using BindingsService

import { BindingsService } from 'honertia/effect'

const handler = action(
  Effect.gen(function* () {
    const { KV, R2, QUEUE } = yield* BindingsService

    const cached = yield* Effect.tryPromise(() => KV.get('key'))
    yield* Effect.tryPromise(() => QUEUE.send({ type: 'event' }))

    return yield* json({ cached })
  })
)

Environment

# wrangler.toml
[vars]
ENVIRONMENT = "production"
# Secrets (not in source control)
wrangler secret put DATABASE_URL
wrangler secret put BETTER_AUTH_SECRET

Testing

Actions generated with CLI include inline tests:

# Test single action
bun test src/actions/projects/create.ts

# Test all actions in a resource
bun test src/actions/projects/

# Run project checks
honertia check --verbose

License

MIT