honertia
v0.1.32
Published
Inertia.js-style server-driven SPA adapter for Hono
Maintainers
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 --previewSchema 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 --previewGenerate 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,namingOpenAPI 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,/adminDatabase 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 migrationInstallation
bun add honertia hono effect better-auth drizzle-orm
bun add -d @types/bun typescript vite @vitejs/plugin-react @inertiajs/react react react-domRequired 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 app6. 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 - REQUIREDAuth 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
- Run
bun add honertia hono effect better-auth drizzle-orm - Create
src/types.tswith module augmentation - Create
src/db/schema.tswith your tables - Create
src/db/db.tswith database factory - Create
src/lib/auth.tswith auth config - Create
src/index.tswith app setup - Create
src/routes.tswith route definitions - Create
src/actions/auth/*.tswith auth actions - Create
src/main.tsxwith client entry - Create
src/pages/Auth/Login.tsxandRegister.tsx - Create
src/pages/Error.tsx - Create
wrangler.toml,vite.config.ts,tsconfig.json - Run
bun run buildthenwrangler 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 | nullError 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 userUsing 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_SECRETTesting
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 --verboseLicense
MIT
