honertia 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1146 -1500
  2. package/dist/cli/check.d.ts +156 -0
  3. package/dist/cli/check.d.ts.map +1 -0
  4. package/dist/cli/check.js +539 -0
  5. package/dist/cli/db.d.ts +255 -0
  6. package/dist/cli/db.d.ts.map +1 -0
  7. package/dist/cli/db.js +532 -0
  8. package/dist/cli/feature.d.ts +132 -0
  9. package/dist/cli/feature.d.ts.map +1 -0
  10. package/dist/cli/feature.js +545 -0
  11. package/dist/cli/generate.d.ts +267 -0
  12. package/dist/cli/generate.d.ts.map +1 -0
  13. package/dist/cli/generate.js +862 -0
  14. package/dist/cli/index.d.ts +110 -0
  15. package/dist/cli/index.d.ts.map +1 -0
  16. package/dist/cli/index.js +260 -0
  17. package/dist/cli/inline-tests.d.ts +61 -0
  18. package/dist/cli/inline-tests.d.ts.map +1 -0
  19. package/dist/cli/inline-tests.js +138 -0
  20. package/dist/cli/openapi.d.ts +196 -0
  21. package/dist/cli/openapi.d.ts.map +1 -0
  22. package/dist/cli/openapi.js +419 -0
  23. package/dist/effect/bridge.d.ts +1 -0
  24. package/dist/effect/bridge.d.ts.map +1 -1
  25. package/dist/effect/bridge.js +40 -3
  26. package/dist/effect/handler.js +1 -1
  27. package/dist/effect/index.d.ts +6 -2
  28. package/dist/effect/index.d.ts.map +1 -1
  29. package/dist/effect/index.js +9 -1
  30. package/dist/effect/route-registry.d.ts +178 -0
  31. package/dist/effect/route-registry.d.ts.map +1 -0
  32. package/dist/effect/route-registry.js +250 -0
  33. package/dist/effect/routing.d.ts +65 -3
  34. package/dist/effect/routing.d.ts.map +1 -1
  35. package/dist/effect/routing.js +99 -8
  36. package/dist/effect/services.d.ts +10 -1
  37. package/dist/effect/services.d.ts.map +1 -1
  38. package/dist/effect/services.js +2 -0
  39. package/dist/effect/test-layers.d.ts +52 -0
  40. package/dist/effect/test-layers.d.ts.map +1 -0
  41. package/dist/effect/test-layers.js +129 -0
  42. package/dist/effect/testing.d.ts +213 -0
  43. package/dist/effect/testing.d.ts.map +1 -0
  44. package/dist/effect/testing.js +300 -0
  45. package/dist/effect/validated-services.d.ts +17 -0
  46. package/dist/effect/validated-services.d.ts.map +1 -0
  47. package/dist/effect/validated-services.js +13 -0
  48. package/dist/effect/validation.d.ts +10 -11
  49. package/dist/effect/validation.d.ts.map +1 -1
  50. package/dist/effect/validation.js +10 -0
  51. package/dist/setup.js +1 -1
  52. package/package.json +5 -1
package/README.md CHANGED
@@ -1,113 +1,361 @@
1
1
  # Honertia
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/honertia.svg)](https://www.npmjs.com/package/honertia)
4
- [![Bundle Size](https://img.shields.io/bundlephobia/minzip/honertia)](https://bundlephobia.com/package/honertia)
5
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue.svg)](https://www.typescriptlang.org/)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
3
+ Inertia.js adapter for Hono with Effect.ts. Server-driven app with SPA behavior.
7
4
 
8
- [![Hono](https://img.shields.io/badge/Hono-E36002?logo=hono&logoColor=fff)](https://hono.dev/)
9
- [![Cloudflare Workers](https://img.shields.io/badge/Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=fff)](https://workers.cloudflare.com/)
10
- [![Effect](https://img.shields.io/badge/Effect-TS-black)](https://effect.website/)
5
+ ## CLI Commands
11
6
 
12
- ## Overview
7
+ ### Generate Action
13
8
 
14
- An Inertia.js-style adapter for Hono with Effect.ts integration. Inertia keeps a server-driven app but behaves like an SPA: link clicks and form posts are intercepted, a fetch/XHR request returns a JSON page object (component + props), and the client swaps the page without a full reload. Honertia layers Laravel-style route patterns and Effect actions on top of that so handlers stay clean, readable, and composable.
9
+ ```bash
10
+ # Basic action
11
+ honertia generate:action projects/create --method POST --path /projects
12
+
13
+ # With authentication
14
+ honertia generate:action projects/create --method POST --path /projects --auth required
15
+
16
+ # With validation schema
17
+ honertia generate:action projects/create \
18
+ --method POST \
19
+ --path /projects \
20
+ --auth required \
21
+ --schema "name:string:required, description:string:nullable"
22
+
23
+ # With route model binding
24
+ honertia generate:action projects/update \
25
+ --method PUT \
26
+ --path "/projects/{project}" \
27
+ --auth required \
28
+ --schema "name:string:required"
29
+
30
+ # Preview without writing
31
+ honertia generate:action projects/create --preview
32
+
33
+ # JSON output for programmatic use
34
+ honertia generate:action projects/create --json --preview
35
+ ```
36
+
37
+ Schema format: `fieldName:type:modifier`
38
+ - Types: `string`, `number`, `boolean`, `date`, `uuid`, `email`, `url`
39
+ - Modifiers: `required` (default), `nullable`, `optional`
40
+
41
+ ### Generate CRUD
42
+
43
+ ```bash
44
+ # Full CRUD
45
+ honertia generate:crud projects
46
+
47
+ # With schema for create/update
48
+ honertia generate:crud projects \
49
+ --schema "name:string:required, description:string:nullable"
50
+
51
+ # Only specific actions
52
+ honertia generate:crud projects --only index,show
53
+
54
+ # Exclude actions
55
+ honertia generate:crud projects --except destroy
56
+
57
+ # Preview all generated files
58
+ honertia generate:crud projects --preview
59
+ ```
60
+
61
+ ### Generate Feature
62
+
63
+ ```bash
64
+ # Custom action on a resource
65
+ honertia generate:feature projects/archive \
66
+ --method POST \
67
+ --path "/projects/{project}/archive" \
68
+ --auth required
69
+
70
+ # With fields
71
+ honertia generate:feature users/profile \
72
+ --method PUT \
73
+ --path "/profile" \
74
+ --fields "name:string:required, bio:string:nullable"
75
+ ```
76
+
77
+ ### List Routes
78
+
79
+ ```bash
80
+ honertia routes # Table format
81
+ honertia routes --json # JSON for agents
82
+ honertia routes --minimal # METHOD PATH only
83
+ honertia routes --method get # Filter by method
84
+ honertia routes --prefix /api
85
+ honertia routes --pattern '/projects/*'
86
+ ```
87
+
88
+ ### Project Check
89
+
90
+ ```bash
91
+ honertia check # Run all checks
92
+ honertia check --json # JSON output with fix suggestions
93
+ honertia check --verbose # Detailed output
94
+ honertia check --only routes,naming
95
+ ```
96
+
97
+ ### OpenAPI Generation
98
+
99
+ ```bash
100
+ honertia generate:openapi \
101
+ --title "My API" \
102
+ --version "1.0.0" \
103
+ --server https://api.example.com \
104
+ --output openapi.json
15
105
 
16
- ## Raison d'être
106
+ # Only API routes
107
+ honertia generate:openapi --include /api
17
108
 
18
- I wanted to build on Cloudflare Workers whilst retaining the ergonomics of the Laravel+Inertia combination. There are certain patterns that I always use with Laravel (such as the laravel-actions package) and so we have incorporated those ideas into honertia. Now, we can't have Laravel in javascript - but we can create it in the aggregate. For auth we have used better-auth, for validation we have leant on Effect's Schema, and for the database we are using Drizzle. To make it testable and hardy we wrapped everything in Effect.ts
109
+ # Exclude internal routes
110
+ honertia generate:openapi --exclude /internal,/admin
111
+ ```
112
+
113
+ ### Database Migrations
114
+
115
+ ```bash
116
+ honertia db status # Show migration status
117
+ honertia db status --json # JSON output
118
+ honertia db migrate # Run pending migrations
119
+ honertia db migrate --preview # Preview SQL without executing
120
+ honertia db rollback # Rollback last migration
121
+ honertia db rollback --preview # Preview rollback SQL
122
+ honertia db generate add_email # Generate new migration
123
+ ```
124
+
125
+ ---
19
126
 
20
127
  ## Installation
21
128
 
22
129
  ```bash
23
- bun add honertia
130
+ bun add honertia hono effect better-auth drizzle-orm
131
+ bun add -d @types/bun typescript vite @vitejs/plugin-react @inertiajs/react react react-dom
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Required Files
137
+
138
+ These files MUST exist for the framework to function. Create them in this order.
139
+
140
+ ### 1. src/types.ts (REQUIRED FIRST)
141
+
142
+ Type definitions and module augmentation. Without this, TypeScript errors will occur and services won't be typed.
143
+
144
+ ```typescript
145
+ // src/types.ts
146
+ import type { DrizzleD1Database } from 'drizzle-orm/d1'
147
+ import type { BetterAuthReturn } from 'honertia/auth'
148
+ import * as schema from './db/schema'
149
+
150
+ // Database type
151
+ export type Database = DrizzleD1Database<typeof schema>
152
+
153
+ // Cloudflare bindings
154
+ export type Bindings = {
155
+ DATABASE_URL: string
156
+ BETTER_AUTH_SECRET: string
157
+ ENVIRONMENT?: string
158
+ // Add KV, R2, Queue bindings as needed:
159
+ // KV: KVNamespace
160
+ // R2: R2Bucket
161
+ }
162
+
163
+ // Hono context variables
164
+ export type Variables = {
165
+ db: Database
166
+ auth: BetterAuthReturn
167
+ }
168
+
169
+ // Full environment type for Hono
170
+ export type Env = {
171
+ Bindings: Bindings
172
+ Variables: Variables
173
+ }
174
+
175
+ // CRITICAL: Module augmentation for type-safe services
176
+ declare module 'honertia/effect' {
177
+ interface HonertiaDatabaseType {
178
+ type: Database
179
+ schema: typeof schema
180
+ }
181
+ interface HonertiaAuthType {
182
+ type: BetterAuthReturn
183
+ }
184
+ interface HonertiaBindingsType {
185
+ type: Bindings
186
+ }
187
+ }
24
188
  ```
25
189
 
26
- ### Demo
190
+ ### 2. src/db/schema.ts (REQUIRED)
191
+
192
+ Drizzle schema. Required for route model binding and database queries.
193
+
194
+ ```typescript
195
+ // src/db/schema.ts
196
+ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
197
+ import { relations } from 'drizzle-orm'
198
+
199
+ export const users = sqliteTable('users', {
200
+ id: text('id').primaryKey(),
201
+ name: text('name').notNull(),
202
+ email: text('email').notNull().unique(),
203
+ emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
204
+ image: text('image'),
205
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
206
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
207
+ })
208
+
209
+ export const sessions = sqliteTable('sessions', {
210
+ id: text('id').primaryKey(),
211
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
212
+ token: text('token').notNull().unique(),
213
+ expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
214
+ ipAddress: text('ip_address'),
215
+ userAgent: text('user_agent'),
216
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
217
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
218
+ })
219
+
220
+ export const accounts = sqliteTable('accounts', {
221
+ id: text('id').primaryKey(),
222
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
223
+ accountId: text('account_id').notNull(),
224
+ providerId: text('provider_id').notNull(),
225
+ accessToken: text('access_token'),
226
+ refreshToken: text('refresh_token'),
227
+ accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }),
228
+ refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }),
229
+ scope: text('scope'),
230
+ password: text('password'),
231
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
232
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
233
+ })
234
+
235
+ export const verifications = sqliteTable('verifications', {
236
+ id: text('id').primaryKey(),
237
+ identifier: text('identifier').notNull(),
238
+ value: text('value').notNull(),
239
+ expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
240
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
241
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
242
+ })
243
+
244
+ // Your app tables
245
+ export const projects = sqliteTable('projects', {
246
+ id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
247
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
248
+ name: text('name').notNull(),
249
+ description: text('description'),
250
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
251
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
252
+ })
253
+
254
+ // Relations for query builder
255
+ export const usersRelations = relations(users, ({ many }) => ({
256
+ projects: many(projects),
257
+ sessions: many(sessions),
258
+ accounts: many(accounts),
259
+ }))
27
260
 
28
- Deploy the honertia-worker-demo repo to Cloudflare
261
+ export const projectsRelations = relations(projects, ({ one }) => ({
262
+ user: one(users, { fields: [projects.userId], references: [users.id] }),
263
+ }))
264
+ ```
29
265
 
30
- [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/PatrickOgilvie/honertia-worker-demo)
266
+ ### 3. src/db/db.ts (REQUIRED)
31
267
 
32
- ## Quick Start
268
+ Database client factory.
33
269
 
270
+ ```typescript
271
+ // src/db/db.ts
272
+ import { drizzle } from 'drizzle-orm/d1'
273
+ import * as schema from './schema'
274
+ import type { Database } from '../types'
34
275
 
35
- ### Recommended File Structure
276
+ export function createDb(d1: D1Database): Database {
277
+ return drizzle(d1, { schema })
278
+ }
36
279
 
280
+ // For local development with better-sqlite3:
281
+ // import Database from 'better-sqlite3'
282
+ // import { drizzle } from 'drizzle-orm/better-sqlite3'
283
+ // export function createDb(path: string): Database {
284
+ // const sqlite = new Database(path)
285
+ // return drizzle(sqlite, { schema })
286
+ // }
37
287
  ```
38
- .
39
- ├── src/
40
- │ ├── index.ts # Hono app setup (setupHonertia)
41
- │ ├── routes.ts # effectRoutes / effectAuthRoutes
42
- │ ├── main.tsx # Inertia + React client entry
43
- │ ├── styles.css # Tailwind CSS entry
44
- │ ├── actions/
45
- │ │ └── projects/
46
- │ │ └── list.ts # listProjects action
47
- │ ├── pages/
48
- │ │ └── Projects/
49
- │ │ └── Index.tsx # render('Projects/Index')
50
- │ ├── db/
51
- │ │ └── db.ts
52
- │ │ └── schema.ts
53
- │ ├── lib/
54
- │ │ └── auth.ts
55
- │ └── types.ts
56
- ├── dist/
57
- │ └── manifest.json # generated by Vite build
58
- ├── vite.config.ts
59
- ├── wrangler.toml # or wrangler.jsonc
60
- ├── package.json
61
- └── tsconfig.json
288
+
289
+ ### 4. src/lib/auth.ts (REQUIRED)
290
+
291
+ Better-auth configuration.
292
+
293
+ ```typescript
294
+ // src/lib/auth.ts
295
+ import { betterAuth } from 'better-auth'
296
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
297
+ import type { Database } from '../types'
298
+
299
+ export function createAuth(options: {
300
+ db: Database
301
+ secret: string
302
+ baseURL: string
303
+ }) {
304
+ return betterAuth({
305
+ database: drizzleAdapter(options.db, {
306
+ provider: 'sqlite',
307
+ }),
308
+ secret: options.secret,
309
+ baseURL: options.baseURL,
310
+ emailAndPassword: {
311
+ enabled: true,
312
+ },
313
+ session: {
314
+ expiresIn: 60 * 60 * 24 * 7, // 7 days
315
+ updateAge: 60 * 60 * 24, // 1 day
316
+ },
317
+ })
318
+ }
319
+
320
+ export type Auth = ReturnType<typeof createAuth>
62
321
  ```
63
322
 
323
+ ### 5. src/index.ts (REQUIRED)
324
+
325
+ Main app entry point.
326
+
64
327
  ```typescript
65
328
  // src/index.ts
66
329
  import { Hono } from 'hono'
67
- import { logger } from 'hono/logger'
68
- import { setupHonertia, createTemplate, createVersion, registerErrorHandlers, vite } from 'honertia'
69
- import manifest from '../dist/manifest.json'
70
-
71
- import type { Env } from './types'
330
+ import { setupHonertia, createTemplate, createVersion, registerErrorHandlers } from 'honertia'
331
+ import * as schema from './db/schema'
72
332
  import { createDb } from './db/db'
73
333
  import { createAuth } from './lib/auth'
74
- import * as schema from './db/schema'
75
334
  import { registerRoutes } from './routes'
335
+ import type { Env } from './types'
336
+
337
+ // Import manifest (generated by Vite build)
338
+ // @ts-ignore - Generated at build time
339
+ import manifest from '../dist/manifest.json'
76
340
 
77
341
  const app = new Hono<Env>()
78
- const assetVersion = createVersion(manifest)
79
- const entry = manifest['src/main.tsx']
80
- const assetPath = (path: string) => `/${path}`
81
342
 
82
- // Honertia bundles db/auth setup + core middleware + Effect runtime.
83
343
  app.use('*', setupHonertia<Env>({
84
344
  honertia: {
85
- // Use your asset manifest hash so Inertia reloads on deploy.
86
- version: assetVersion,
87
- render: createTemplate((ctx) => {
88
- const isProd = ctx.env.ENVIRONMENT === 'production'
89
- return {
90
- title: 'My Web App',
91
- scripts: isProd ? [assetPath(entry.file)] : [vite.script()],
92
- styles: isProd ? (entry.css ?? []).map(assetPath) : [],
93
- head: isProd ? '' : vite.hmrHead(),
94
- }
95
- }),
96
- // Database factory (creates c.var.db for each request)
97
- database: (c) => createDb(c.env.DATABASE_URL),
98
- // Auth factory (can access c.var.db since database runs first)
345
+ version: createVersion(manifest),
346
+ render: createTemplate((ctx) => ({
347
+ title: 'My App',
348
+ scripts: [manifest['src/main.tsx']?.file].filter(Boolean),
349
+ styles: manifest['src/main.tsx']?.css ?? [],
350
+ })),
351
+ database: (c) => createDb(c.env.DB),
99
352
  auth: (c) => createAuth({
100
353
  db: c.var.db,
101
354
  secret: c.env.BETTER_AUTH_SECRET,
102
355
  baseURL: new URL(c.req.url).origin,
103
356
  }),
104
- // Schema for route model binding (optional)
105
357
  schema,
106
358
  },
107
- // Optional: extra Hono middleware in the same chain.
108
- middleware: [
109
- logger(),
110
- ],
111
359
  }))
112
360
 
113
361
  registerRoutes(app)
@@ -116,218 +364,112 @@ registerErrorHandlers(app)
116
364
  export default app
117
365
  ```
118
366
 
367
+ ### 6. src/routes.ts (REQUIRED)
368
+
369
+ Route definitions.
370
+
119
371
  ```typescript
120
372
  // src/routes.ts
121
373
  import type { Hono } from 'hono'
122
374
  import type { Env } from './types'
123
375
  import { effectRoutes } from 'honertia/effect'
124
376
  import { effectAuthRoutes, RequireAuthLayer } from 'honertia/auth'
125
- import { showDashboard, listProjects, createProject, showProject, deleteProject } from './actions'
377
+
378
+ // Import your actions
126
379
  import { loginUser, registerUser, logoutUser } from './actions/auth'
380
+ // import { listProjects, showProject, createProject } from './actions/projects'
127
381
 
128
382
  export function registerRoutes(app: Hono<Env>) {
129
- // Auth routes: pages, form actions, logout, and API handler in one place.
383
+ // Auth routes (handles /login, /register, /logout, /api/auth/*)
130
384
  effectAuthRoutes(app, {
131
385
  loginComponent: 'Auth/Login',
132
386
  registerComponent: 'Auth/Register',
133
- // Form actions (automatically wrapped with RequireGuestLayer)
134
387
  loginAction: loginUser,
135
388
  registerAction: registerUser,
136
389
  logoutAction: logoutUser,
137
390
  })
138
391
 
139
- // Effect routes give you typed, DI-friendly handlers (no direct Hono ctx).
392
+ // Protected routes (require authentication)
140
393
  effectRoutes(app)
141
394
  .provide(RequireAuthLayer)
142
395
  .group((route) => {
143
- // Grouped routes share layers and path prefixes.
144
- route.get('/', showDashboard) // GET example.com
145
-
146
- route.prefix('/projects').group((route) => {
147
- route.get('/', listProjects) // GET example.com/projects
148
- route.post('/', createProject) // POST example.com/projects
149
- route.get('/:id', showProject) // GET example.com/projects/2
150
- route.delete('/:id', deleteProject) // DELETE example.com/projects/2
151
- })
396
+ // Add your protected routes here
397
+ // route.get('/', showDashboard)
398
+ // route.prefix('/projects').group((route) => {
399
+ // route.get('/', listProjects)
400
+ // route.get('/{project}', showProject)
401
+ // route.post('/', createProject)
402
+ // })
152
403
  })
153
- }
154
- ```
155
-
156
- ### Example Action
157
-
158
- Here's the `listProjects` action referenced above:
159
-
160
- ```typescript
161
- // src/actions/projects/list.ts
162
- import { Effect } from 'effect'
163
- import { eq } from 'drizzle-orm'
164
- import { DatabaseService, AuthUserService, render, type AuthUser } from 'honertia/effect'
165
- import { schema, type Database, type Project } from '../../db'
166
-
167
- interface ProjectsIndexProps {
168
- projects: Project[]
169
- }
170
404
 
171
- const fetchProjects = (
172
- db: Database,
173
- user: AuthUser
174
- ): Effect.Effect<ProjectsIndexProps, Error, never> =>
175
- Effect.tryPromise({
176
- try: async () => {
177
- const projects = await db.query.projects.findMany({
178
- where: eq(schema.projects.userId, user.user.id),
179
- orderBy: (projects, { desc }) => [desc(projects.createdAt)],
180
- })
181
- return { projects }
182
- },
183
- catch: (error) => error instanceof Error ? error : new Error(String(error)),
405
+ // Public routes (no auth required)
406
+ effectRoutes(app).group((route) => {
407
+ // route.get('/about', showAbout)
184
408
  })
185
-
186
- export const listProjects = Effect.gen(function* () {
187
- const db = yield* DatabaseService
188
- const user = yield* AuthUserService
189
- const props = yield* fetchProjects(db as Database, user)
190
- return yield* render('Projects/Index', props)
191
- })
409
+ }
192
410
  ```
193
411
 
194
- The component name `Projects/Index` maps to a file on disk. A common
195
- Vite + React layout is:
412
+ ### 7. src/main.tsx (REQUIRED)
196
413
 
197
- ```
198
- src/pages/Projects/Index.tsx
199
- ```
200
-
201
- That means the folders mirror the component path, and `Index.tsx` is the file
202
- that exports the page component. In the example below, `Link` comes from
203
- `@inertiajs/react` because it performs Inertia client-side visits (preserving
204
- page state and avoiding full reloads), whereas a plain `<a>` would do a full
205
- navigation.
414
+ Client-side entry point.
206
415
 
207
416
  ```tsx
208
- // src/pages/Projects/Index.tsx
209
- /**
210
- * Projects Index Page
211
- */
212
-
213
- import { Link } from '@inertiajs/react'
214
- import Layout from '~/components/Layout'
215
- import type { PageProps, Project } from '~/types'
417
+ // src/main.tsx
418
+ import './styles.css'
419
+ import { createInertiaApp } from '@inertiajs/react'
420
+ import { createRoot } from 'react-dom/client'
216
421
 
217
- interface Props {
218
- projects: Project[]
219
- }
422
+ const pages = import.meta.glob('./pages/**/*.tsx')
220
423
 
221
- export default function ProjectsIndex({ projects }: PageProps<Props>) {
222
- return (
223
- <Layout>
224
- <div className="flex justify-between items-center mb-6">
225
- <h1 className="text-2xl font-bold text-gray-900">Projects</h1>
226
- <Link
227
- href="/projects/create"
228
- className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"
229
- >
230
- New Project
231
- </Link>
232
- </div>
233
-
234
- <div className="bg-white rounded-lg shadow">
235
- {projects.length === 0 ? (
236
- <div className="p-6 text-center text-gray-500">
237
- No projects yet.{' '}
238
- <Link href="/projects/create" className="text-indigo-600 hover:underline">
239
- Create your first project
240
- </Link>
241
- </div>
242
- ) : (
243
- <ul className="divide-y divide-gray-200">
244
- {projects.map((project) => (
245
- <li key={project.id}>
246
- <Link
247
- href={`/projects/${project.id}`}
248
- className="block px-6 py-4 hover:bg-gray-50"
249
- >
250
- <div className="flex justify-between items-start">
251
- <div>
252
- <h3 className="text-sm font-medium text-gray-900">
253
- {project.name}
254
- </h3>
255
- {project.description && (
256
- <p className="text-sm text-gray-500 mt-1">
257
- {project.description}
258
- </p>
259
- )}
260
- </div>
261
- <span className="text-sm text-gray-400">
262
- {new Date(project.createdAt).toLocaleDateString()}
263
- </span>
264
- </div>
265
- </Link>
266
- </li>
267
- ))}
268
- </ul>
269
- )}
270
- </div>
271
- </Layout>
272
- )
273
- }
424
+ createInertiaApp({
425
+ resolve: (name) => {
426
+ const page = pages[`./pages/${name}.tsx`]
427
+ if (!page) {
428
+ throw new Error(`Page not found: ${name}. Create src/pages/${name}.tsx`)
429
+ }
430
+ return page()
431
+ },
432
+ setup({ el, App, props }) {
433
+ createRoot(el!).render(<App {...props} />)
434
+ },
435
+ })
274
436
  ```
275
437
 
276
- ### Environment Variables
277
-
278
- Honertia reads these from `c.env` (Cloudflare Workers bindings):
438
+ ### 8. wrangler.toml (REQUIRED for Cloudflare)
279
439
 
280
440
  ```toml
281
- # wrangler.toml
282
- ENVIRONMENT = "production"
283
- ```
284
-
285
- If you prefer `wrangler.jsonc`, the same binding looks like:
286
-
287
- ```jsonc
288
- {
289
- "vars": {
290
- "ENVIRONMENT": "production"
291
- }
292
- }
293
- ```
294
-
295
- Set secrets like `DATABASE_URL` and `BETTER_AUTH_SECRET` via Wrangler (not in source control):
296
-
297
- ```bash
298
- wrangler secret put DATABASE_URL
299
- wrangler secret put BETTER_AUTH_SECRET
300
- ```
301
-
302
- ### Client Setup (React + Inertia)
441
+ name = "my-app"
442
+ compatibility_date = "2024-01-01"
443
+ main = "src/index.ts"
303
444
 
304
- Honertia uses the standard Inertia React client. You'll need a client entry
305
- point and a Vite build that emits a manifest (for `createVersion`).
445
+ [vars]
446
+ ENVIRONMENT = "development"
306
447
 
307
- Install client dependencies:
448
+ [[d1_databases]]
449
+ binding = "DB"
450
+ database_name = "my-app-db"
451
+ database_id = "your-database-id"
308
452
 
309
- ```bash
310
- bun add react react-dom @inertiajs/react
311
- bun add -d @vitejs/plugin-react tailwindcss @tailwindcss/vite
453
+ [site]
454
+ bucket = "./dist"
312
455
  ```
313
456
 
314
- Create a Vite config that enables Tailwind v4, sets up an alias used in the
315
- examples, and emits `dist/manifest.json`:
457
+ ### 9. vite.config.ts (REQUIRED)
316
458
 
317
459
  ```typescript
318
460
  // vite.config.ts
319
461
  import { defineConfig } from 'vite'
320
462
  import react from '@vitejs/plugin-react'
321
- import tailwindcss from '@tailwindcss/vite'
322
463
  import path from 'path'
323
464
 
324
465
  export default defineConfig({
325
- plugins: [tailwindcss(), react()],
466
+ plugins: [react()],
326
467
  build: {
327
468
  outDir: 'dist',
328
- // Use an explicit filename so imports match build output.
329
469
  manifest: 'manifest.json',
330
- emptyOutDir: true,
470
+ rollupOptions: {
471
+ input: 'src/main.tsx',
472
+ },
331
473
  },
332
474
  resolve: {
333
475
  alias: {
@@ -337,216 +479,375 @@ export default defineConfig({
337
479
  })
338
480
  ```
339
481
 
340
- Create a Tailwind CSS entry file:
482
+ ### 10. tsconfig.json (REQUIRED)
341
483
 
342
- ```css
343
- /* src/styles.css */
344
- @import "tailwindcss";
345
-
346
- @layer base {
347
- body {
348
- margin: 0;
349
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
350
- background-color: #f8fafc;
351
- color: #0f172a;
352
- }
484
+ ```json
485
+ {
486
+ "compilerOptions": {
487
+ "target": "ES2022",
488
+ "module": "ESNext",
489
+ "moduleResolution": "bundler",
490
+ "strict": true,
491
+ "skipLibCheck": true,
492
+ "esModuleInterop": true,
493
+ "jsx": "react-jsx",
494
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
495
+ "types": ["bun-types", "@cloudflare/workers-types"],
496
+ "paths": {
497
+ "~/*": ["./src/*"]
498
+ }
499
+ },
500
+ "include": ["src/**/*"],
501
+ "exclude": ["node_modules", "dist"]
353
502
  }
354
503
  ```
355
504
 
356
- Set up the Inertia client entry point (default path matches `vite.script()`):
505
+ ---
357
506
 
358
- ```tsx
359
- // src/main.tsx
360
- import './styles.css'
507
+ ## Project Structure
361
508
 
362
- import { createInertiaApp } from '@inertiajs/react'
363
- import { createRoot } from 'react-dom/client'
509
+ ```
510
+ src/
511
+ index.ts # App entry, setupHonertia() - REQUIRED
512
+ routes.ts # Route definitions - REQUIRED
513
+ types.ts # Type definitions - REQUIRED
514
+ main.tsx # Client entry - REQUIRED
515
+ styles.css # Global styles
516
+ db/
517
+ db.ts # Database factory - REQUIRED
518
+ schema.ts # Drizzle schema - REQUIRED
519
+ lib/
520
+ auth.ts # Auth config - REQUIRED
521
+ actions/
522
+ auth/
523
+ login.ts
524
+ register.ts
525
+ logout.ts
526
+ projects/
527
+ index.ts
528
+ show.ts
529
+ create.ts
530
+ pages/
531
+ Auth/
532
+ Login.tsx
533
+ Register.tsx
534
+ Projects/
535
+ Index.tsx
536
+ Show.tsx
537
+ Create.tsx
538
+ Error.tsx # Error page component
539
+ wrangler.toml # Cloudflare config - REQUIRED
540
+ vite.config.ts # Vite config - REQUIRED
541
+ tsconfig.json # TypeScript config - REQUIRED
542
+ ```
364
543
 
365
- const pages = import.meta.glob('./pages/**/*.tsx')
544
+ ---
366
545
 
367
- createInertiaApp({
368
- resolve: (name) => {
369
- const page = pages[`./pages/${name}.tsx`]
370
- if (!page) {
371
- throw new Error(`Page not found: ${name}`)
546
+ ## Auth Actions (REQUIRED)
547
+
548
+ These three actions are required for `effectAuthRoutes` to work.
549
+
550
+ ### src/actions/auth/login.ts
551
+
552
+ ```typescript
553
+ import { betterAuthFormAction } from 'honertia/auth'
554
+ import { Schema as S } from 'effect'
555
+ import { email, requiredString } from 'honertia/effect'
556
+
557
+ const LoginSchema = S.Struct({
558
+ email: email,
559
+ password: requiredString,
560
+ })
561
+
562
+ export const loginUser = betterAuthFormAction({
563
+ schema: LoginSchema,
564
+ errorComponent: 'Auth/Login',
565
+ redirectTo: '/',
566
+ errorMapper: (error) => {
567
+ switch (error.code) {
568
+ case 'INVALID_EMAIL_OR_PASSWORD':
569
+ return { email: 'Invalid email or password' }
570
+ case 'USER_NOT_FOUND':
571
+ return { email: 'No account found with this email' }
572
+ default:
573
+ return { email: 'Login failed' }
372
574
  }
373
- return page()
374
- },
375
- setup({ el, App, props }) {
376
- createRoot(el).render(<App {...props} />)
377
575
  },
576
+ call: (auth, input, request) =>
577
+ auth.api.signInEmail({
578
+ body: { email: input.email, password: input.password },
579
+ request,
580
+ returnHeaders: true,
581
+ }),
378
582
  })
379
583
  ```
380
584
 
381
- The `resolve` function maps `render('Projects/Index')` to
382
- `src/pages/Projects/Index.tsx`.
383
-
384
- Optional: add a `tailwind.config.ts` only if you need theme extensions or
385
- custom content globs.
585
+ ### src/actions/auth/register.ts
386
586
 
387
- ### Build & Deploy Notes
587
+ ```typescript
588
+ import { betterAuthFormAction } from 'honertia/auth'
589
+ import { Schema as S } from 'effect'
590
+ import { email, requiredString, password } from 'honertia/effect'
388
591
 
389
- The server imports `dist/manifest.json`, so it must exist at build time. In
390
- production, read scripts and styles from the manifest (Tailwind's CSS is listed
391
- under your entry's `css` array). When deploying with Wrangler, build the client
392
- assets first:
592
+ const RegisterSchema = S.Struct({
593
+ name: requiredString,
594
+ email: email,
595
+ password: password({ min: 8, letters: true, numbers: true }),
596
+ })
393
597
 
394
- ```bash
395
- # build client assets before deploying the worker
396
- bun run build:client
397
- wrangler deploy
598
+ export const registerUser = betterAuthFormAction({
599
+ schema: RegisterSchema,
600
+ errorComponent: 'Auth/Register',
601
+ redirectTo: '/',
602
+ errorMapper: (error) => {
603
+ switch (error.code) {
604
+ case 'USER_ALREADY_EXISTS':
605
+ return { email: 'An account with this email already exists' }
606
+ default:
607
+ return { email: 'Registration failed' }
608
+ }
609
+ },
610
+ call: (auth, input, request) =>
611
+ auth.api.signUpEmail({
612
+ body: { name: input.name, email: input.email, password: input.password },
613
+ request,
614
+ returnHeaders: true,
615
+ }),
616
+ })
398
617
  ```
399
618
 
400
- Optional dev convenience: if you want to run the worker without building the
401
- client, you can keep a stub `dist/manifest.json` (ignored by git) and replace it
402
- once you run `vite build`.
403
-
404
- ### Vite Helpers
405
-
406
- The `vite` helper provides dev/prod asset management:
619
+ ### src/actions/auth/logout.ts
407
620
 
408
621
  ```typescript
409
- import { vite } from 'honertia'
622
+ import { betterAuthLogoutAction } from 'honertia/auth'
410
623
 
411
- vite.script() // 'http://localhost:5173/src/main.tsx'
412
- vite.hmrHead() // HMR preamble script tags for React Fast Refresh
624
+ export const logoutUser = betterAuthLogoutAction({
625
+ redirectTo: '/login',
626
+ })
413
627
  ```
414
628
 
415
- ## Requirements
629
+ ### src/actions/auth/index.ts
416
630
 
417
- - **Runtime**: Node.js 18+ or Bun 1.0+
418
- - **Peer Dependencies**:
419
- - `hono` >= 4.0.0
420
- - `better-auth` >= 1.0.0
421
- - **Dependencies**:
422
- - `effect` >= 3.12.0
631
+ ```typescript
632
+ export { loginUser } from './login'
633
+ export { registerUser } from './register'
634
+ export { logoutUser } from './logout'
635
+ ```
423
636
 
424
- ## Anatomy of an Action
637
+ ---
425
638
 
426
- Actions in Honertia are fully composable Effect computations. Instead of using different action factories for different combinations of features, you opt-in to exactly what you need by yielding services and helpers inside your action.
639
+ ## Minimum Page Components (REQUIRED)
427
640
 
428
- This design is inspired by Laravel's [laravel-actions](https://laravelactions.com/) package, where you opt-in to capabilities by adding methods to your action class. In Honertia, you opt-in by yielding services - the order of your `yield*` statements determines the execution order.
641
+ ### src/pages/Auth/Login.tsx
429
642
 
430
- ### The `action` Wrapper
643
+ ```tsx
644
+ import { useForm } from '@inertiajs/react'
431
645
 
432
- The `action` function is a semantic wrapper that marks an Effect as an action:
646
+ export default function Login() {
647
+ const { data, setData, post, processing, errors } = useForm({
648
+ email: '',
649
+ password: '',
650
+ })
433
651
 
434
- ```typescript
435
- import { Effect } from 'effect'
436
- import { action } from 'honertia/effect'
652
+ const submit = (e: React.FormEvent) => {
653
+ e.preventDefault()
654
+ post('/login')
655
+ }
437
656
 
438
- export const myAction = action(
439
- Effect.gen(function* () {
440
- // Your action logic here
441
- return new Response('OK')
442
- })
443
- )
657
+ return (
658
+ <form onSubmit={submit}>
659
+ <div>
660
+ <input
661
+ type="email"
662
+ value={data.email}
663
+ onChange={(e) => setData('email', e.target.value)}
664
+ placeholder="Email"
665
+ />
666
+ {errors.email && <span>{errors.email}</span>}
667
+ </div>
668
+ <div>
669
+ <input
670
+ type="password"
671
+ value={data.password}
672
+ onChange={(e) => setData('password', e.target.value)}
673
+ placeholder="Password"
674
+ />
675
+ {errors.password && <span>{errors.password}</span>}
676
+ </div>
677
+ <button type="submit" disabled={processing}>
678
+ Login
679
+ </button>
680
+ </form>
681
+ )
682
+ }
444
683
  ```
445
684
 
446
- It's intentionally minimal - all the power comes from what you yield inside.
685
+ ### src/pages/Auth/Register.tsx
447
686
 
448
- ### Composable Helpers
687
+ ```tsx
688
+ import { useForm } from '@inertiajs/react'
449
689
 
450
- #### `authorize` - Authentication & Authorization
690
+ export default function Register() {
691
+ const { data, setData, post, processing, errors } = useForm({
692
+ name: '',
693
+ email: '',
694
+ password: '',
695
+ })
451
696
 
452
- Opt-in to authentication and authorization checks. Returns the authenticated user, fails with `UnauthorizedError` if no user is present, and fails with `ForbiddenError` if the check returns `false`.
697
+ const submit = (e: React.FormEvent) => {
698
+ e.preventDefault()
699
+ post('/register')
700
+ }
453
701
 
454
- ```typescript
455
- import { authorize } from 'honertia/effect'
702
+ return (
703
+ <form onSubmit={submit}>
704
+ <div>
705
+ <input
706
+ type="text"
707
+ value={data.name}
708
+ onChange={(e) => setData('name', e.target.value)}
709
+ placeholder="Name"
710
+ />
711
+ {errors.name && <span>{errors.name}</span>}
712
+ </div>
713
+ <div>
714
+ <input
715
+ type="email"
716
+ value={data.email}
717
+ onChange={(e) => setData('email', e.target.value)}
718
+ placeholder="Email"
719
+ />
720
+ {errors.email && <span>{errors.email}</span>}
721
+ </div>
722
+ <div>
723
+ <input
724
+ type="password"
725
+ value={data.password}
726
+ onChange={(e) => setData('password', e.target.value)}
727
+ placeholder="Password"
728
+ />
729
+ {errors.password && <span>{errors.password}</span>}
730
+ </div>
731
+ <button type="submit" disabled={processing}>
732
+ Register
733
+ </button>
734
+ </form>
735
+ )
736
+ }
737
+ ```
456
738
 
457
- // Just require authentication (any logged-in user)
458
- const auth = yield* authorize()
739
+ ### src/pages/Error.tsx
459
740
 
460
- // Require a specific role
461
- const auth = yield* authorize((a) => a.user.role === 'admin')
741
+ ```tsx
742
+ interface ErrorProps {
743
+ status: number
744
+ title: string
745
+ message: string
746
+ }
462
747
 
463
- // Require resource ownership (see caveat below)
464
- const auth = yield* authorize((a) => a.user.id === project.userId)
748
+ export default function Error({ status, title, message }: ErrorProps) {
749
+ return (
750
+ <div>
751
+ <h1>{status}</h1>
752
+ <h2>{title}</h2>
753
+ <p>{message}</p>
754
+ <a href="/">Go home</a>
755
+ </div>
756
+ )
757
+ }
465
758
  ```
466
759
 
467
- If the check function returns `false`, the action fails immediately with a `ForbiddenError`.
468
-
469
- > **Query-level vs authorize() checks**
470
- >
471
- > Use `authorize()` for:
472
- > - Role/permission checks before any DB work: `authorize((a) => a.user.role === 'admin')`
473
- > - Checks that can't be expressed in SQL
474
- >
475
- > For resource ownership, prefer query-level filtering:
476
- > ```typescript
477
- > // Better: single query, no information leakage
478
- > const auth = yield* authorize()
479
- > const project = yield* Effect.tryPromise(() =>
480
- > db.query.projects.findFirst({
481
- > where: and(eq(projects.id, id), eq(projects.userId, auth.user.id)),
482
- > })
483
- > )
484
- > if (!project) return yield* notFound('Project')
485
- > ```
486
- >
487
- > This approach is more secure (no difference between "not found" and "not yours") and more efficient (single query).
488
-
489
- #### `validateRequest` - Schema Validation
490
-
491
- Opt-in to request validation using Effect Schema:
760
+ ---
761
+
762
+ ## Setup Checklist
763
+
764
+ 1. Run `bun add honertia hono effect better-auth drizzle-orm`
765
+ 2. Create `src/types.ts` with module augmentation
766
+ 3. Create `src/db/schema.ts` with your tables
767
+ 4. Create `src/db/db.ts` with database factory
768
+ 5. Create `src/lib/auth.ts` with auth config
769
+ 6. Create `src/index.ts` with app setup
770
+ 7. Create `src/routes.ts` with route definitions
771
+ 8. Create `src/actions/auth/*.ts` with auth actions
772
+ 9. Create `src/main.tsx` with client entry
773
+ 10. Create `src/pages/Auth/Login.tsx` and `Register.tsx`
774
+ 11. Create `src/pages/Error.tsx`
775
+ 12. Create `wrangler.toml`, `vite.config.ts`, `tsconfig.json`
776
+ 13. Run `bun run build` then `wrangler dev`
777
+
778
+ ---
779
+
780
+ ## Action Examples
781
+
782
+ ### Simple GET (Public Page)
492
783
 
493
784
  ```typescript
494
- import { Schema as S } from 'effect'
495
- import { validateRequest, requiredString } from 'honertia/effect'
785
+ import { Effect } from 'effect'
786
+ import { action, render } from 'honertia/effect'
496
787
 
497
- const input = yield* validateRequest(
498
- S.Struct({ name: requiredString, description: S.optional(S.String) }),
499
- { errorComponent: 'Projects/Create' }
788
+ export const showAbout = action(
789
+ Effect.gen(function* () {
790
+ return yield* render('About', {})
791
+ })
500
792
  )
501
- // input is Validated<{ name: string, description?: string }>
502
793
  ```
503
794
 
504
- On validation failure, re-renders `errorComponent` with field-level errors.
505
- `validateRequest` returns a branded `Validated<T>` value that you can require for writes.
795
+ ### GET with Authentication
506
796
 
507
- #### `DatabaseService` - Database Access
797
+ ```typescript
798
+ import { Effect } from 'effect'
799
+ import { action, authorize, render, DatabaseService } from 'honertia/effect'
800
+ import { eq } from 'drizzle-orm'
801
+ import { projects } from '~/db/schema'
508
802
 
509
- Opt-in to database access:
803
+ export const listProjects = action(
804
+ Effect.gen(function* () {
805
+ const auth = yield* authorize()
806
+ const db = yield* DatabaseService
510
807
 
511
- ```typescript
512
- import { DatabaseService } from 'honertia/effect'
808
+ const userProjects = yield* Effect.tryPromise(() =>
809
+ db.query.projects.findMany({
810
+ where: eq(projects.userId, auth.user.id),
811
+ orderBy: (p, { desc }) => [desc(p.createdAt)],
812
+ })
813
+ )
513
814
 
514
- const db = yield* DatabaseService
515
- const projects = yield* Effect.tryPromise(() =>
516
- db.query.projects.findMany()
815
+ return yield* render('Projects/Index', { projects: userProjects })
816
+ })
517
817
  )
518
818
  ```
519
819
 
520
- #### `render` / `redirect` - Responses
521
-
522
- Return responses from your action:
820
+ ### GET with Route Model Binding
523
821
 
524
822
  ```typescript
525
- import { render, redirect } from 'honertia/effect'
823
+ import { Effect } from 'effect'
824
+ import { action, authorize, bound, render } from 'honertia/effect'
526
825
 
527
- // Render a page
528
- return yield* render('Projects/Index', { projects })
826
+ export const showProject = action(
827
+ Effect.gen(function* () {
828
+ const auth = yield* authorize()
829
+ const project = yield* bound('project') // Auto-fetched from {project} param
529
830
 
530
- // Redirect after mutation
531
- return yield* redirect('/projects')
831
+ return yield* render('Projects/Show', { project })
832
+ })
833
+ )
532
834
  ```
533
835
 
534
- ### Building an Action
535
-
536
- Here's how these composables work together:
836
+ ### POST with Validation
537
837
 
538
838
  ```typescript
539
839
  import { Effect, Schema as S } from 'effect'
540
840
  import {
541
841
  action,
542
842
  authorize,
543
- asTrusted,
544
843
  validateRequest,
545
- dbMutation,
546
844
  DatabaseService,
547
845
  redirect,
846
+ asTrusted,
847
+ dbMutation,
548
848
  requiredString,
549
849
  } from 'honertia/effect'
850
+ import { projects } from '~/db/schema'
550
851
 
551
852
  const CreateProjectSchema = S.Struct({
552
853
  name: requiredString,
@@ -555,204 +856,107 @@ const CreateProjectSchema = S.Struct({
555
856
 
556
857
  export const createProject = action(
557
858
  Effect.gen(function* () {
558
- // 1. Authorization - fail fast if not allowed
559
- const auth = yield* authorize((a) => a.user.role === 'author')
560
-
561
- // 2. Validation - parse and validate request body
859
+ const auth = yield* authorize()
562
860
  const input = yield* validateRequest(CreateProjectSchema, {
563
861
  errorComponent: 'Projects/Create',
564
862
  })
565
-
566
- // 3. Database - perform the mutation
567
863
  const db = yield* DatabaseService
864
+
568
865
  yield* dbMutation(db, async (db) => {
569
866
  await db.insert(projects).values(asTrusted({
570
- ...input,
867
+ name: input.name,
868
+ description: input.description ?? null,
571
869
  userId: auth.user.id,
572
870
  }))
573
871
  })
574
872
 
575
- // 4. Response - redirect on success
576
873
  return yield* redirect('/projects')
577
874
  })
578
875
  )
579
876
  ```
580
877
 
581
- ### Execution Order Matters
582
-
583
- The order you yield services determines when they execute:
584
-
585
- ```typescript
586
- // Authorization BEFORE validation (recommended for most actions)
587
- // Don't waste cycles validating if user can't perform the action
588
- const auth = yield* authorize((a) => a.user.role === 'admin')
589
- const input = yield* validateRequest(schema)
590
-
591
- // Validation BEFORE authorization (when you need to fetch the resource first)
592
- // Validate the ID format, fetch from DB, then check ownership against the DB record
593
- const { id } = yield* validateRequest(Schema.Struct({ id: Schema.UUID }))
594
- const project = yield* db.findProjectById(id)
595
- const auth = yield* authorize((a) => a.user.id === project.ownerId)
596
- ```
597
-
598
- ### Type Safety
599
-
600
- Effect tracks all service requirements at the type level. Your action's type signature shows exactly what it needs:
878
+ ### PUT with Route Binding and Validation
601
879
 
602
880
  ```typescript
603
- // This action requires: RequestService, DatabaseService
604
- export const createProject: Effect.Effect<
605
- Response | Redirect,
606
- ValidationError | UnauthorizedError | ForbiddenError | Error,
607
- RequestService | DatabaseService
608
- >
609
- ```
610
-
611
- The compiler ensures all required services are provided when the action runs.
612
- Note: `authorize` uses an optional `AuthUserService`, so it won't appear in the required service list unless you `yield* AuthUserService` directly or provide `RequireAuthLayer` explicitly.
613
-
614
- ### Minimal Actions
615
-
616
- Not every action needs all features. Use only what you need:
881
+ import { Effect, Schema as S } from 'effect'
882
+ import {
883
+ action,
884
+ authorize,
885
+ bound,
886
+ validateRequest,
887
+ DatabaseService,
888
+ redirect,
889
+ asTrusted,
890
+ dbMutation,
891
+ requiredString,
892
+ forbidden,
893
+ } from 'honertia/effect'
894
+ import { eq } from 'drizzle-orm'
895
+ import { projects } from '~/db/schema'
617
896
 
618
- ```typescript
619
- // Public page - no auth, no validation
620
- export const showAbout = action(
621
- Effect.gen(function* () {
622
- return yield* render('About', {})
623
- })
624
- )
897
+ const UpdateProjectSchema = S.Struct({
898
+ name: requiredString,
899
+ description: S.optional(S.String),
900
+ })
625
901
 
626
- // Read-only authenticated page
627
- export const showDashboard = action(
902
+ export const updateProject = action(
628
903
  Effect.gen(function* () {
629
904
  const auth = yield* authorize()
630
- const db = yield* DatabaseService
631
- const stats = yield* fetchStats(db, auth)
632
- return yield* render('Dashboard', { stats })
633
- })
634
- )
635
-
636
- // API endpoint with just validation
637
- export const searchProjects = action(
638
- Effect.gen(function* () {
639
- const { query } = yield* validateRequest(S.Struct({ query: S.String }))
640
- const db = yield* DatabaseService
641
- const results = yield* search(db, query)
642
- return yield* json({ results })
643
- })
644
- )
645
- ```
646
-
647
- ### Helper Utilities
648
-
649
- #### `dbMutation` - Safe Writes
650
-
651
- Use `dbMutation` for writes that require validated or trusted input:
652
-
653
- ```typescript
654
- import { DatabaseService, dbMutation, validateRequest, asTrusted, authorize } from 'honertia/effect'
655
-
656
- const auth = yield* authorize()
657
- const input = yield* validateRequest(CreateProjectSchema)
658
- const values = asTrusted({ userId: auth.user.id, ...input })
659
- const db = yield* DatabaseService
660
-
661
- yield* dbMutation(db, async (db) => {
662
- await db.insert(projects).values(values)
663
- })
664
- ```
665
-
666
- Use `asTrusted` for server-derived values like audit logs or usage meters, or when combining validated input with server-only fields.
667
- `dbMutation` also wraps `execute`/`run` params to require validated or trusted inputs.
668
-
669
- Why this design: we intentionally use nominal brands that do not survive spreads or merges. That means any modified or combined object must be explicitly re-branded with `asTrusted`, which makes trust boundaries visible and prevents accidental writes of unvalidated data.
670
-
671
- ```typescript
672
- const input = yield* validateRequest(CreateProjectSchema)
673
-
674
- // This fails typechecking because the brand is dropped by the merge
675
- const merged = { ...input, userId: auth.user.id }
676
- // await db.insert(projects).values(merged)
677
-
678
- // Explicitly re-brand after adding server data
679
- const values = asTrusted({ ...input, userId: auth.user.id })
680
- await db.insert(projects).values(values)
681
- ```
905
+ const project = yield* bound('project')
682
906
 
683
- For multi-step writes, use `dbTransaction` so writes require validated or trusted input.
684
-
685
- #### `dbTransaction` - Database Transactions
686
-
687
- Run multiple database operations in a transaction with automatic rollback on failure. The database instance is passed explicitly to keep the dependency visible and consistent with other service patterns:
907
+ // Authorization check
908
+ if (project.userId !== auth.user.id) {
909
+ return yield* forbidden('You cannot edit this project')
910
+ }
688
911
 
689
- ```typescript
690
- import { DatabaseService, dbTransaction, asTrusted } from 'honertia/effect'
912
+ const input = yield* validateRequest(UpdateProjectSchema, {
913
+ errorComponent: 'Projects/Edit',
914
+ })
915
+ const db = yield* DatabaseService
691
916
 
692
- const db = yield* DatabaseService
693
- const user = asTrusted({ name: 'Alice', email: 'alice@example.com' })
694
- const balanceUpdate = asTrusted({ balance: 100 })
917
+ yield* dbMutation(db, async (db) => {
918
+ await db.update(projects)
919
+ .set(asTrusted({
920
+ name: input.name,
921
+ description: input.description ?? null,
922
+ }))
923
+ .where(eq(projects.id, project.id))
924
+ })
695
925
 
696
- yield* dbTransaction(db, async (tx) => {
697
- await tx.insert(users).values(user)
698
- await tx.update(accounts).set(balanceUpdate).where(eq(accounts.userId, id))
699
- // If any operation fails, the entire transaction rolls back
700
- return { success: true }
701
- })
926
+ return yield* redirect(`/projects/${project.id}`)
927
+ })
928
+ )
702
929
  ```
703
930
 
704
- `dbTransaction` wraps `insert`, `update`, and `execute`/`run` params; `delete` has no payload, so build its conditions from validated or trusted values.
705
-
706
- ## Core Concepts
707
-
708
- ### Effect-Based Handlers
709
-
710
- Route handlers are Effect computations that return `Response | Redirect`. Actions are fully composable - you opt-in to features by yielding services:
931
+ ### DELETE with Route Binding
711
932
 
712
933
  ```typescript
713
934
  import { Effect } from 'effect'
714
935
  import {
715
936
  action,
716
937
  authorize,
717
- asTrusted,
718
- validateRequest,
719
- dbMutation,
938
+ bound,
720
939
  DatabaseService,
721
- render,
722
940
  redirect,
941
+ forbidden,
942
+ dbMutation,
723
943
  } from 'honertia/effect'
944
+ import { eq } from 'drizzle-orm'
945
+ import { projects } from '~/db/schema'
724
946
 
725
- // Simple page render with auth
726
- export const showDashboard = action(
947
+ export const destroyProject = action(
727
948
  Effect.gen(function* () {
728
949
  const auth = yield* authorize()
729
- const db = yield* DatabaseService
730
-
731
- const projects = yield* Effect.tryPromise(() =>
732
- db.query.projects.findMany({
733
- where: eq(schema.projects.userId, auth.user.id),
734
- limit: 5,
735
- })
736
- )
950
+ const project = yield* bound('project')
737
951
 
738
- return yield* render('Dashboard/Index', { projects })
739
- })
740
- )
952
+ if (project.userId !== auth.user.id) {
953
+ return yield* forbidden('You cannot delete this project')
954
+ }
741
955
 
742
- // Form submission with permissions, validation, and redirect
743
- export const createProject = action(
744
- Effect.gen(function* () {
745
- const auth = yield* authorize((a) => a.user.role === 'admin')
746
- const input = yield* validateRequest(CreateProjectSchema, {
747
- errorComponent: 'Projects/Create',
748
- })
749
956
  const db = yield* DatabaseService
750
957
 
751
958
  yield* dbMutation(db, async (db) => {
752
- await db.insert(schema.projects).values(asTrusted({
753
- ...input,
754
- userId: auth.user.id,
755
- }))
959
+ await db.delete(projects).where(eq(projects.id, project.id))
756
960
  })
757
961
 
758
962
  return yield* redirect('/projects')
@@ -760,951 +964,365 @@ export const createProject = action(
760
964
  )
761
965
  ```
762
966
 
763
- ### Services
764
-
765
- Honertia provides these services via Effect's dependency injection:
766
-
767
- | Service | Description |
768
- |---------|-------------|
769
- | `DatabaseService` | Database client (from `c.var.db`) |
770
- | `AuthService` | Auth instance (from `c.var.auth`) |
771
- | `AuthUserService` | Authenticated user session |
772
- | `BindingsService` | Environment bindings (Cloudflare KV, D1, R2, etc.) |
773
- | `HonertiaService` | Page renderer |
774
- | `RequestService` | Request context (params, query, body) |
775
- | `ResponseFactoryService` | Response builders |
776
-
777
- #### Accessing Cloudflare Bindings
778
-
779
- Use `BindingsService` to access your Cloudflare bindings (KV, D1, R2, Queues, etc.):
780
-
781
- ```typescript
782
- import { Effect } from 'effect'
783
- import { BindingsService, json } from 'honertia/effect'
784
-
785
- const getDataFromKV = Effect.gen(function* () {
786
- const { KV } = yield* BindingsService
787
- const value = yield* Effect.tryPromise(() => KV.get('my-key'))
788
- return yield* json({ value })
789
- })
790
- ```
791
-
792
- For full type safety, add `HonertiaBindingsType` to your module augmentation (see [TypeScript](#typescript) section). You can reference the same `Bindings` type you use for Hono—no duplication needed.
793
-
794
- #### Custom Effect Services
795
-
796
- For more complex scenarios, use `effect.services` to create proper Effect services:
797
-
798
- **When to use custom services instead of `request.env`:**
799
- - Services that need initialization or cleanup logic
800
- - Services you want to mock in tests
801
- - Abstracting third-party APIs into a typed interface
802
- - Services that combine multiple bindings with business logic
967
+ ### API Endpoint (JSON Response)
803
968
 
804
969
  ```typescript
805
- import { Effect, Layer, Context } from 'effect'
806
- import { setupHonertia } from 'honertia'
807
-
808
- // Example: A rate limiter service that combines KV with business logic
809
- class RateLimiterService extends Context.Tag('app/RateLimiter')<
810
- RateLimiterService,
811
- {
812
- check: (key: string, limit: number, windowSeconds: number) => Promise<boolean>
813
- increment: (key: string) => Promise<void>
814
- }
815
- >() {}
970
+ import { Effect, Schema as S } from 'effect'
971
+ import { action, validateRequest, DatabaseService, json } from 'honertia/effect'
972
+ import { like } from 'drizzle-orm'
973
+ import { projects } from '~/db/schema'
816
974
 
817
- // Create the service with initialization logic
818
- const createRateLimiter = (kv: KVNamespace) => ({
819
- check: async (key: string, limit: number, windowSeconds: number) => {
820
- const count = parseInt(await kv.get(key) ?? '0')
821
- return count < limit
822
- },
823
- increment: async (key: string) => {
824
- const count = parseInt(await kv.get(key) ?? '0')
825
- await kv.put(key, String(count + 1), { expirationTtl: 60 })
826
- },
975
+ const SearchSchema = S.Struct({
976
+ q: S.String,
977
+ limit: S.optional(S.NumberFromString).pipe(S.withDefault(() => 10)),
827
978
  })
828
979
 
829
- app.use('*', setupHonertia<Env, RateLimiterService>({
830
- honertia: { version, render },
831
- effect: {
832
- services: (c) => Layer.succeed(
833
- RateLimiterService,
834
- createRateLimiter(c.env.RATE_LIMIT_KV)
835
- ),
836
- },
837
- }))
838
-
839
- // Use in your action - clean, testable, typed
840
- const createProject = Effect.gen(function* () {
841
- const rateLimiter = yield* RateLimiterService
842
- const auth = yield* authorize()
843
-
844
- const allowed = yield* Effect.tryPromise(() =>
845
- rateLimiter.check(`create:${auth.user.id}`, 10, 60)
846
- )
847
- if (!allowed) {
848
- return yield* httpError(429, 'Rate limit exceeded')
849
- }
980
+ export const searchProjects = action(
981
+ Effect.gen(function* () {
982
+ const { q, limit } = yield* validateRequest(SearchSchema)
983
+ const db = yield* DatabaseService
850
984
 
851
- // ... create project ...
985
+ const results = yield* Effect.tryPromise(() =>
986
+ db.query.projects.findMany({
987
+ where: like(projects.name, `%${q}%`),
988
+ limit,
989
+ })
990
+ )
852
991
 
853
- yield* Effect.tryPromise(() => rateLimiter.increment(`create:${auth.user.id}`))
854
- return yield* redirect('/projects')
855
- })
992
+ return yield* json({ results, count: results.length })
993
+ })
994
+ )
856
995
  ```
857
996
 
858
- **Multiple services with `Layer.mergeAll`:**
997
+ ### Action with Role Check
859
998
 
860
999
  ```typescript
861
- app.use('*', setupHonertia<Env, RateLimiterService | QueueService>({
862
- honertia: { version, render },
863
- effect: {
864
- services: (c) => Layer.mergeAll(
865
- Layer.succeed(RateLimiterService, createRateLimiter(c.env.RATE_LIMIT_KV)),
866
- Layer.succeed(QueueService, { queue: c.env.MY_QUEUE }),
867
- ),
868
- },
869
- }))
870
- ```
871
-
872
- **Summary:**
873
- - **Simple binding access**: Use `BindingsService` (automatically provided, typed via module augmentation)
874
- - **Complex services**: Use `effect.services` when you need initialization, testability, or abstraction
875
-
876
- ### Routing
877
-
878
- Use `effectRoutes` for Laravel-style route definitions:
1000
+ import { Effect } from 'effect'
1001
+ import { action, authorize, DatabaseService, render } from 'honertia/effect'
879
1002
 
880
- ```typescript
881
- import {
882
- effectRoutes,
883
- RequireAuthLayer,
884
- RequireGuestLayer,
885
- } from 'honertia'
1003
+ export const adminDashboard = action(
1004
+ Effect.gen(function* () {
1005
+ // authorize() with callback checks role
1006
+ const auth = yield* authorize((a) => a.user.role === 'admin')
1007
+ const db = yield* DatabaseService
886
1008
 
887
- // Protected routes (require authentication)
888
- effectRoutes(app)
889
- .provide(RequireAuthLayer)
890
- .prefix('/dashboard')
891
- .group((route) => {
892
- route.get('/', showDashboard)
893
- route.get('/settings', showSettings)
894
- route.post('/settings', updateSettings)
895
- })
1009
+ const stats = yield* Effect.tryPromise(() =>
1010
+ db.query.users.findMany({ limit: 100 })
1011
+ )
896
1012
 
897
- // Guest-only routes
898
- effectRoutes(app)
899
- .provide(RequireGuestLayer)
900
- .group((route) => {
901
- route.get('/login', showLogin)
902
- route.get('/register', showRegister)
1013
+ return yield* render('Admin/Dashboard', { stats })
903
1014
  })
904
-
905
- // Public routes (no layer)
906
- effectRoutes(app).group((route) => {
907
- route.get('/about', showAbout)
908
- route.get('/pricing', showPricing)
909
- })
910
- ```
911
-
912
- #### Route Parameter Validation
913
-
914
- You can pass a `params` schema to validate route parameters before your handler runs. Invalid values automatically return a 404:
915
-
916
- ```typescript
917
- import { Schema as S } from 'effect'
918
- import { uuid } from 'honertia/effect'
919
-
920
- effectRoutes(app).get(
921
- '/projects/:id',
922
- showProject,
923
- { params: S.Struct({ id: uuid }) }
924
1015
  )
925
1016
  ```
926
1017
 
927
- This runs validation *before* the handler executes, so invalid UUIDs never hit your database. You can validate multiple params and use any Effect Schema:
1018
+ ### Action with Custom Error Handling
928
1019
 
929
1020
  ```typescript
930
- effectRoutes(app).get(
931
- '/api/:version/projects/:id',
932
- showProject,
933
- {
934
- params: S.Struct({
935
- version: S.Literal('v1', 'v2'),
936
- id: uuid,
937
- }),
938
- }
939
- )
940
- ```
941
-
942
- #### Laravel-Style Route Model Binding
1021
+ import { Effect } from 'effect'
1022
+ import {
1023
+ action,
1024
+ authorize,
1025
+ DatabaseService,
1026
+ render,
1027
+ notFound,
1028
+ httpError,
1029
+ } from 'honertia/effect'
1030
+ import { eq } from 'drizzle-orm'
1031
+ import { projects } from '~/db/schema'
943
1032
 
944
- Honertia supports Laravel-style route model binding with the `{param}` syntax. This automatically resolves route parameters to database models, returning 404 if the model isn't found.
945
-
946
- **Setup:**
947
-
948
- 1. Add your Drizzle schema to the module augmentation:
949
-
950
- ```typescript
951
- // src/types.d.ts
952
- import type { Database } from '~/db/db'
953
- import type { auth } from '~/lib/auth'
954
- import * as schema from '~/db/schema'
955
-
956
- declare module 'honertia/effect' {
957
- interface HonertiaDatabaseType {
958
- type: Database
959
- schema: typeof schema // Add this for route model binding
960
- }
961
-
962
- interface HonertiaAuthType {
963
- type: typeof auth
964
- }
965
- }
966
- ```
967
-
968
- 2. Pass your schema to `setupHonertia`:
969
-
970
- ```typescript
971
- import * as schema from '~/db/schema'
972
-
973
- app.use('*', setupHonertia({
974
- honertia: {
975
- version: '1.0.0',
976
- render: createTemplate({ ... }),
977
- database: (c) => createDb(c.env.DATABASE_URL),
978
- schema, // Schema is shared with all effectRoutes
979
- },
980
- }))
981
- ```
982
-
983
- **Basic Usage:**
984
-
985
- ```typescript
986
- import { bound } from 'honertia/effect'
987
-
988
- // Route: /projects/{project}
989
- // Automatically queries: SELECT * FROM projects WHERE id = :project
990
-
991
- effectRoutes(app).get('/projects/{project}', showProject)
992
-
993
- const showProject = Effect.gen(function* () {
994
- const project = yield* bound('project') // Already fetched, guaranteed to exist
995
- return yield* render('Projects/Show', { project })
996
- })
997
- ```
998
-
999
- **Custom Column Binding:**
1000
-
1001
- By default, bindings query the `id` column. Use `{param:column}` syntax to bind by a different column:
1002
-
1003
- ```typescript
1004
- // Bind by slug instead of id
1005
- effectRoutes(app).get('/projects/{project:slug}', showProject)
1006
- // Queries: SELECT * FROM projects WHERE slug = :project
1007
- ```
1008
-
1009
- **Nested Route Scoping:**
1010
-
1011
- For nested routes, Honertia automatically scopes child models to their parents using Drizzle relations:
1012
-
1013
- ```typescript
1014
- // Route: /users/{user}/posts/{post}
1015
- effectRoutes(app).get('/users/{user}/posts/{post}', showUserPost)
1016
-
1017
- // Queries:
1018
- // 1. SELECT * FROM users WHERE id = :user
1019
- // 2. SELECT * FROM posts WHERE id = :post AND userId = :user.id
1020
- ```
1021
-
1022
- This uses your Drizzle relations to discover the foreign key:
1023
-
1024
- ```typescript
1025
- // db/schema.ts
1026
- export const postsRelations = relations(posts, ({ one }) => ({
1027
- user: one(users, {
1028
- fields: [posts.userId],
1029
- references: [users.id],
1030
- }),
1031
- }))
1032
- ```
1033
-
1034
- If no relation is found, the child is resolved without scoping (useful for unrelated resources in the same route).
1035
-
1036
- **How Binding Works:**
1037
-
1038
- 1. `{project}` is converted to `:project` for Hono's router
1039
- 2. At request time, the param value is extracted
1040
- 3. The table name is derived by pluralizing the param (`project` → `projects`)
1041
- 4. A database query is executed against that table
1042
- 5. If not found, a 404 is returned before your handler runs
1043
- 6. If found, the model is available via `bound('project')`
1044
-
1045
- **Combining with Param Validation:**
1046
-
1047
- Route model binding and param validation work together. Validation runs first:
1048
-
1049
- ```typescript
1050
- effectRoutes(app).get(
1051
- '/projects/{project}',
1052
- showProject,
1053
- { params: S.Struct({ project: uuid }) } // Validates UUID format first
1054
- )
1055
- ```
1056
-
1057
- Order of execution:
1058
- 1. Param validation (returns 404 if schema fails)
1059
- 2. Model binding (returns 404 if not found in database)
1060
- 3. Your handler (model guaranteed to exist)
1061
-
1062
- **Mixed Notation:**
1063
-
1064
- You can mix Laravel-style `{binding}` with Hono-style `:param` in the same route. Only `{binding}` params are resolved from the database:
1065
-
1066
- ```typescript
1067
- // :version is a regular Hono param (not bound)
1068
- // {project} is resolved from the database
1069
- effectRoutes(app).get(
1070
- '/api/:version/projects/{project}',
1071
- showProject
1072
- )
1073
-
1074
- const showProject = Effect.gen(function* () {
1075
- const request = yield* RequestService
1076
- const version = request.param('version') // 'v1', 'v2', etc.
1077
- const project = yield* bound('project') // Database model
1078
- // ...
1079
- })
1080
- ```
1081
-
1082
- **Performance:**
1083
-
1084
- Routes without `{bindings}` have zero overhead—binding resolution only runs when Laravel-style params are detected. The binding check is a simple regex test at route registration time.
1085
-
1086
- ## Validation
1087
-
1088
- Honertia uses Effect Schema with Laravel-inspired validators:
1089
-
1090
- ```typescript
1091
- import { Effect, Schema as S } from 'effect'
1092
- import {
1093
- validateRequest,
1094
- requiredString,
1095
- nullableString,
1096
- email,
1097
- password,
1098
- redirect,
1099
- } from 'honertia'
1100
-
1101
- // Define schema
1102
- const CreateProjectSchema = S.Struct({
1103
- name: requiredString.pipe(
1104
- S.minLength(3, { message: () => 'Name must be at least 3 characters' }),
1105
- S.maxLength(100)
1106
- ),
1107
- description: nullableString,
1108
- })
1109
-
1110
- // Use in handler
1111
- export const createProject = Effect.gen(function* () {
1112
- const input = yield* validateRequest(CreateProjectSchema, {
1113
- errorComponent: 'Projects/Create', // Re-render with errors on validation failure
1114
- })
1115
-
1116
- // input is Validated<{ name: string, description: string | null }>
1117
- yield* insertProject(input)
1118
-
1119
- return yield* redirect('/projects')
1120
- })
1121
- ```
1122
-
1123
- ### Validation Options
1124
-
1125
- `validateRequest` accepts an options object with:
1126
-
1127
- ```typescript
1128
- const input = yield* validateRequest(schema, {
1129
- // Re-render this component with errors on validation failure
1130
- // If not set, redirects back to the previous page
1131
- errorComponent: 'Projects/Create',
1132
-
1133
- // Override default error messages per field
1134
- messages: {
1135
- name: 'Please enter a project name',
1136
- email: 'That email address is not valid',
1137
- },
1138
-
1139
- // Human-readable field names for the :attribute placeholder
1140
- // Use with messages like 'The :attribute field is required'
1141
- attributes: {
1142
- name: 'project name',
1143
- email: 'email address',
1144
- },
1145
- })
1146
- ```
1147
-
1148
- **Example with `:attribute` placeholder:**
1149
-
1150
- ```typescript
1151
- const schema = S.Struct({
1152
- email: S.String.pipe(S.minLength(1, { message: () => 'The :attribute field is required' })),
1153
- })
1154
-
1155
- const input = yield* validateRequest(schema, {
1156
- attributes: { email: 'email address' },
1157
- errorComponent: 'Auth/Register',
1158
- })
1159
- // Error: "The email address field is required"
1160
- ```
1161
-
1162
- ### Available Validators
1163
-
1164
- #### Strings
1165
- ```typescript
1166
- import {
1167
- requiredString, // Trimmed, non-empty string
1168
- nullableString, // Converts empty to null
1169
- required, // Custom message: required('Name is required')
1170
- alpha, // Letters only
1171
- alphaDash, // Letters, numbers, dashes, underscores
1172
- alphaNum, // Letters and numbers only
1173
- email, // Validated email
1174
- url, // Validated URL
1175
- uuid, // UUID format
1176
- min, // min(5) - at least 5 chars
1177
- max, // max(100) - at most 100 chars
1178
- size, // size(10) - exactly 10 chars
1179
- } from 'honertia'
1180
- ```
1181
-
1182
- #### Numbers
1183
- ```typescript
1184
- import {
1185
- coercedNumber, // Coerce string to number
1186
- positiveInt, // Positive integer
1187
- nonNegativeInt, // 0 or greater
1188
- between, // between(1, 100)
1189
- gt, gte, lt, lte, // Comparisons
1190
- } from 'honertia'
1191
- ```
1192
-
1193
- #### Booleans & Dates
1194
- ```typescript
1195
- import {
1196
- coercedBoolean, // Coerce "true", "1", etc.
1197
- checkbox, // HTML checkbox (defaults to false)
1198
- accepted, // Must be truthy
1199
- coercedDate, // Coerce to Date
1200
- nullableDate, // Empty string -> null
1201
- after, // after(new Date())
1202
- before, // before('2025-01-01')
1203
- } from 'honertia'
1204
- ```
1205
-
1206
- #### Password
1207
- ```typescript
1208
- import { password } from 'honertia'
1209
-
1210
- const PasswordSchema = password({
1211
- min: 8,
1212
- letters: true,
1213
- mixedCase: true,
1214
- numbers: true,
1215
- symbols: true,
1216
- })
1217
- ```
1218
-
1219
- ## Response Helpers
1220
-
1221
- ```typescript
1222
- import {
1223
- render,
1224
- renderWithErrors,
1225
- redirect,
1226
- json,
1227
- notFound,
1228
- forbidden,
1229
- } from 'honertia'
1230
-
1231
- // Render a page
1232
- return yield* render('Projects/Show', { project })
1233
-
1234
- // Render with validation errors
1235
- return yield* renderWithErrors('Projects/Create', {
1236
- name: 'Name is required',
1237
- })
1238
-
1239
- // Redirect (303 by default for POST)
1240
- return yield* redirect('/projects')
1241
- return yield* redirect('/login', 302)
1242
-
1243
- // JSON response
1244
- return yield* json({ success: true })
1245
- return yield* json({ error: 'Not found' }, 404)
1246
-
1247
- // Error responses
1248
- return yield* notFound('Project')
1249
- return yield* forbidden('You cannot edit this project')
1250
- ```
1251
-
1252
- ## Error Handling
1253
-
1254
- Honertia provides typed errors that integrate with Effect's error channel. Each error type has specific handling behavior designed for Inertia-style applications.
1255
-
1256
- ### Built-in Error Types
1257
-
1258
- | Error Type | HTTP Status | Handling Behavior |
1259
- |------------|-------------|-------------------|
1260
- | `ValidationError` | 422 / redirect | Re-renders form with field errors, or redirects back |
1261
- | `UnauthorizedError` | 302/303 | Redirects to login page |
1262
- | `NotFoundError` | 404 | Uses Hono's `notFound()` handler → renders via Honertia |
1263
- | `ForbiddenError` | 403 | Returns JSON response (for API compatibility) |
1264
- | `HttpError` | Custom | Returns JSON with custom status (developer-controlled) |
1265
- | `RouteConfigurationError` | 500 | Throws to Hono's `onError` → renders error page |
1266
- | Unexpected errors | 500 | Throws to Hono's `onError` → renders error page |
1267
-
1268
- ### Error Type Details
1269
-
1270
- #### `ValidationError`
1271
-
1272
- Thrown when request validation fails. Automatically re-renders the form with field-level errors.
1273
-
1274
- ```typescript
1275
- import { ValidationError, validateRequest } from 'honertia/effect'
1276
-
1277
- // Automatic: validateRequest throws ValidationError on failure
1278
- const input = yield* validateRequest(schema, {
1279
- errorComponent: 'Projects/Create', // Re-renders this component with errors
1280
- })
1281
-
1282
- // Manual: throw ValidationError directly
1283
- yield* Effect.fail(new ValidationError({
1284
- errors: { email: 'Invalid email format' },
1285
- component: 'Auth/Register', // Optional: component to re-render
1286
- }))
1287
- ```
1288
-
1289
- **Behavior:**
1290
- - If request prefers JSON (API calls): returns `{ errors: {...} }` with 422 status
1291
- - If `component` is set: re-renders that component with errors in props
1292
- - Otherwise: redirects back to referer with errors in session
1293
-
1294
- #### `UnauthorizedError`
1295
-
1296
- Thrown when authentication is required but the user is not logged in.
1297
-
1298
- ```typescript
1299
- import { UnauthorizedError, authorize } from 'honertia/effect'
1300
-
1301
- // Automatic: authorize() throws UnauthorizedError if no user
1302
- const auth = yield* authorize()
1303
-
1304
- // Manual: throw with custom redirect
1305
- yield* Effect.fail(new UnauthorizedError({
1306
- message: 'Please log in to continue',
1307
- redirectTo: '/login', // Defaults to '/login'
1308
- }))
1309
- ```
1310
-
1311
- **Behavior:** Redirects to the specified URL (302 for regular requests, 303 for Inertia requests).
1312
-
1313
- #### `NotFoundError`
1314
-
1315
- Thrown when a requested resource doesn't exist.
1316
-
1317
- ```typescript
1318
- import { NotFoundError, notFound } from 'honertia/effect'
1319
-
1320
- // Helper function
1321
- return yield* notFound('Project', projectId)
1322
-
1323
- // Manual
1324
- yield* Effect.fail(new NotFoundError({
1325
- resource: 'Project',
1326
- id: projectId,
1327
- }))
1328
- ```
1329
-
1330
- **Behavior:** Triggers Hono's `notFound()` handler. If you've set up `registerErrorHandlers()`, this renders your error component with status 404.
1331
-
1332
- #### `ForbiddenError`
1333
-
1334
- Thrown when the user is authenticated but not authorized to perform an action.
1335
-
1336
- ```typescript
1337
- import { ForbiddenError, forbidden, authorize } from 'honertia/effect'
1338
-
1339
- // Automatic: authorize() throws ForbiddenError if check fails
1340
- const auth = yield* authorize((a) => a.user.role === 'admin')
1341
-
1342
- // Helper function
1343
- return yield* forbidden('You cannot edit this project')
1344
-
1345
- // Manual
1346
- yield* Effect.fail(new ForbiddenError({
1347
- message: 'Admin access required',
1348
- }))
1349
- ```
1350
-
1351
- **Behavior:** Returns JSON `{ message: "..." }` with 403 status. This is intentionally JSON for API compatibility.
1352
-
1353
- #### `HttpError`
1354
-
1355
- A generic error for custom HTTP responses. Use when you need precise control over the response.
1356
-
1357
- ```typescript
1358
- import { HttpError, httpError } from 'honertia/effect'
1359
-
1360
- // Helper function
1361
- return yield* httpError(429, 'Rate limited', { retryAfter: 60 })
1362
-
1363
- // Manual
1364
- yield* Effect.fail(new HttpError({
1365
- status: 429,
1366
- message: 'Too many requests',
1367
- body: { retryAfter: 60 }, // Optional additional data
1368
- }))
1369
- ```
1370
-
1371
- **Behavior:** Returns JSON `{ message: "...", ...body }` with the specified status code.
1372
-
1373
- #### `RouteConfigurationError`
1374
-
1375
- Thrown when there's a developer configuration error, such as using route model binding without providing a schema.
1376
-
1377
- ```typescript
1378
- import { RouteConfigurationError } from 'honertia/effect'
1379
-
1380
- // This error is thrown automatically when:
1381
- // - You use bound('project') but didn't pass schema to effectRoutes()
1382
- // - Other route configuration mistakes
1383
-
1384
- // You typically don't throw this manually
1385
- ```
1386
-
1387
- **Behavior:** Re-throws to Hono's `onError` handler, which renders your error component. The error message and hint are logged to the console for debugging.
1388
-
1389
- ### Setting Up Error Pages
1390
-
1391
- To render errors via Honertia instead of returning plain text/JSON, use `registerErrorHandlers`:
1392
-
1393
- ```typescript
1394
- import { registerErrorHandlers } from 'honertia'
1395
-
1396
- // In your app setup
1397
- registerErrorHandlers(app, {
1398
- component: 'Error', // Your error page component
1399
- showDevErrors: true, // Show detailed errors in development
1400
- envKey: 'ENVIRONMENT', // Env var to check
1401
- devValue: 'development', // Value that enables dev errors
1402
- })
1403
- ```
1404
-
1405
- Your `Error` component receives structured error props that vary by environment:
1406
-
1407
- **In Development** (`ENVIRONMENT=development`):
1408
-
1409
- ```json
1410
- {
1411
- "status": 500,
1412
- "code": "HON_CFG_100_DATABASE_NOT_CONFIGURED",
1413
- "title": "Database Not Configured",
1414
- "message": "DatabaseService is not configured. Add it to setupHonertia.",
1415
- "hint": "Add database to setupHonertia config",
1416
- "fixes": [{ "description": "Add database config", "confidence": "high" }],
1417
- "source": { "file": "src/routes/projects.ts", "line": 42 },
1418
- "docsUrl": "https://..."
1419
- }
1420
- ```
1421
-
1422
- **In Production** (`ENVIRONMENT=production` or unset):
1423
-
1424
- ```json
1425
- {
1426
- "status": 500,
1427
- "code": "HON_CFG_100_DATABASE_NOT_CONFIGURED",
1428
- "title": "Database Not Configured",
1429
- "message": "An error occurred. Please try again later."
1430
- }
1431
- ```
1432
-
1433
- In production, sensitive details (stack traces, source locations, hints, fixes) are automatically hidden while the error code and title remain visible for debugging reference.
1434
-
1435
- **Example React Error Component:**
1436
-
1437
- ```tsx
1438
- // src/pages/Error.tsx
1439
- interface ErrorProps {
1440
- status: number
1441
- code: string
1442
- title: string
1443
- message: string
1444
- hint?: string
1445
- fixes?: Array<{ description: string; confidence: string }>
1446
- source?: { file: string; line: number }
1447
- docsUrl?: string
1448
- }
1449
-
1450
- export default function Error(props: ErrorProps) {
1451
- const { status, title, message, hint, fixes, source, docsUrl } = props
1452
-
1453
- return (
1454
- <div className="error-page">
1455
- <h1>{status}</h1>
1456
- <h2>{title}</h2>
1457
- <p>{message}</p>
1033
+ export const showProject = action(
1034
+ Effect.gen(function* () {
1035
+ const auth = yield* authorize()
1036
+ const db = yield* DatabaseService
1037
+ const request = yield* RequestService
1038
+ const projectId = request.param('id')
1458
1039
 
1459
- {/* Only shown in dev (these props won't exist in prod) */}
1460
- {hint && <p className="hint">{hint}</p>}
1040
+ const project = yield* Effect.tryPromise(() =>
1041
+ db.query.projects.findFirst({
1042
+ where: eq(projects.id, projectId),
1043
+ })
1044
+ )
1461
1045
 
1462
- {source && (
1463
- <code>{source.file}:{source.line}</code>
1464
- )}
1046
+ if (!project) {
1047
+ return yield* notFound('Project', projectId)
1048
+ }
1465
1049
 
1466
- {fixes?.map((fix, i) => (
1467
- <div key={i} className={`fix fix-${fix.confidence}`}>
1468
- {fix.description}
1469
- </div>
1470
- ))}
1050
+ if (project.userId !== auth.user.id) {
1051
+ return yield* httpError(403, 'Access denied')
1052
+ }
1471
1053
 
1472
- {docsUrl && <a href={docsUrl}>View Documentation</a>}
1473
- </div>
1474
- )
1475
- }
1054
+ return yield* render('Projects/Show', { project })
1055
+ })
1056
+ )
1476
1057
  ```
1477
1058
 
1478
- ### Environment Detection
1059
+ ---
1060
+
1061
+ ## Validation Examples
1479
1062
 
1480
- Honertia automatically detects development mode by checking environment variables:
1063
+ ### String Validators
1481
1064
 
1482
1065
  ```typescript
1483
- // Checks these in order:
1484
- // 1. env.ENVIRONMENT === 'development'
1485
- // 2. env.NODE_ENV === 'development'
1486
- // 3. env.CF_PAGES_BRANCH !== undefined (Cloudflare Pages preview deployments)
1066
+ import { Schema as S } from 'effect'
1067
+ import {
1068
+ requiredString, // Trimmed, non-empty
1069
+ nullableString, // Empty string -> null
1070
+ email, // Email format
1071
+ url, // URL format
1072
+ uuid, // UUID format
1073
+ alpha, // Letters only
1074
+ alphaDash, // Letters, numbers, dashes, underscores
1075
+ alphaNum, // Letters and numbers
1076
+ min, // min(5) - at least 5 chars
1077
+ max, // max(100) - at most 100 chars
1078
+ size, // size(10) - exactly 10 chars
1079
+ } from 'honertia/effect'
1080
+
1081
+ const UserSchema = S.Struct({
1082
+ name: requiredString,
1083
+ bio: nullableString,
1084
+ email: email,
1085
+ website: S.optional(url),
1086
+ username: alphaDash.pipe(min(3), max(20)),
1087
+ })
1487
1088
  ```
1488
1089
 
1489
- To enable development mode, set the environment variable in your Hono app:
1090
+ ### Number Validators
1490
1091
 
1491
- ```toml
1492
- # wrangler.toml
1493
- [vars]
1494
- ENVIRONMENT = "development"
1092
+ ```typescript
1093
+ import { Schema as S } from 'effect'
1094
+ import {
1095
+ coercedNumber, // String -> number
1096
+ positiveInt, // > 0
1097
+ nonNegativeInt, // >= 0
1098
+ between, // between(1, 100)
1099
+ gt, // gt(0) - greater than
1100
+ gte, // gte(0) - greater than or equal
1101
+ lt, // lt(100)
1102
+ lte, // lte(100)
1103
+ } from 'honertia/effect'
1104
+
1105
+ const ProductSchema = S.Struct({
1106
+ price: coercedNumber.pipe(gte(0)),
1107
+ quantity: positiveInt,
1108
+ discount: S.optional(coercedNumber.pipe(between(0, 100))),
1109
+ })
1495
1110
  ```
1496
1111
 
1497
- Or for Bun/Node:
1112
+ ### Boolean and Date Validators
1498
1113
 
1499
1114
  ```typescript
1500
- // Pass env via Bun.serve or test setup
1501
- const app = new Hono<{ Bindings: { ENVIRONMENT: string } }>()
1115
+ import { Schema as S } from 'effect'
1116
+ import {
1117
+ coercedBoolean, // "true", "1", "on" -> true
1118
+ checkbox, // HTML checkbox (defaults to false)
1119
+ accepted, // Must be truthy (for terms acceptance)
1120
+ coercedDate, // String -> Date
1121
+ nullableDate, // Empty string -> null
1122
+ after, // after(new Date()) - must be in future
1123
+ before, // before('2025-12-31')
1124
+ } from 'honertia/effect'
1125
+
1126
+ const EventSchema = S.Struct({
1127
+ isPublic: checkbox,
1128
+ termsAccepted: accepted,
1129
+ startDate: coercedDate.pipe(after(new Date())),
1130
+ endDate: S.optional(nullableDate),
1131
+ })
1502
1132
  ```
1503
1133
 
1504
- ### Safe Message Filtering
1134
+ ### Password Validator
1505
1135
 
1506
- For sensitive error categories, production automatically shows generic messages instead of implementation details:
1136
+ ```typescript
1137
+ import { password } from 'honertia/effect'
1507
1138
 
1508
- | Category | Production Message |
1509
- |----------|-------------------|
1510
- | `configuration` | "An error occurred. Please try again later." |
1511
- | `internal` | "An error occurred. Please try again later." |
1512
- | `database` | "An error occurred. Please try again later." |
1513
- | `validation` | Original message (safe to show) |
1514
- | `auth` | Original message (safe to show) |
1139
+ const RegisterSchema = S.Struct({
1140
+ email: email,
1141
+ password: password({
1142
+ min: 8,
1143
+ letters: true,
1144
+ mixedCase: true,
1145
+ numbers: true,
1146
+ symbols: true,
1147
+ }),
1148
+ })
1149
+ ```
1515
1150
 
1516
- This ensures that configuration mistakes, database errors, and internal implementation details are never leaked to end users in production.
1151
+ ### Full Form Example
1517
1152
 
1518
- ### Error Handler Options
1153
+ ```typescript
1154
+ import { Schema as S } from 'effect'
1155
+ import {
1156
+ requiredString,
1157
+ nullableString,
1158
+ email,
1159
+ coercedNumber,
1160
+ checkbox,
1161
+ coercedDate,
1162
+ between,
1163
+ } from 'honertia/effect'
1519
1164
 
1520
- The `registerErrorHandlers` function accepts these options:
1165
+ const CreateEventSchema = S.Struct({
1166
+ title: requiredString.pipe(S.maxLength(200)),
1167
+ description: nullableString,
1168
+ organizerEmail: email,
1169
+ maxAttendees: coercedNumber.pipe(between(1, 10000)),
1170
+ isPublic: checkbox,
1171
+ startDate: coercedDate,
1172
+ endDate: S.optional(coercedDate),
1173
+ })
1521
1174
 
1522
- ```typescript
1523
- registerErrorHandlers(app, {
1524
- component: 'Error', // React component name (required)
1525
- showDevErrors: true, // Set false to always hide details (default: true)
1526
- envKey: 'ENVIRONMENT', // Which env var to check (default: 'ENVIRONMENT')
1527
- devValue: 'development', // What value means "dev mode" (default: 'development')
1175
+ // In action
1176
+ const input = yield* validateRequest(CreateEventSchema, {
1177
+ errorComponent: 'Events/Create',
1178
+ messages: {
1179
+ title: 'Please enter an event title',
1180
+ organizerEmail: 'Please enter a valid email address',
1181
+ },
1182
+ attributes: {
1183
+ maxAttendees: 'maximum attendees',
1184
+ },
1528
1185
  })
1529
1186
  ```
1530
1187
 
1531
- ### Error Handling Flow
1188
+ ---
1532
1189
 
1533
- ```
1534
- Effect Handler
1535
-
1536
-
1537
- ┌─────────────────────────────────────────────────────────┐
1538
- │ errorToResponse() │
1539
- ├─────────────────────────────────────────────────────────┤
1540
- │ ValidationError → Re-render form / redirect back │
1541
- │ UnauthorizedError → Redirect to login │
1542
- │ NotFoundError → c.notFound() → Hono notFound handler │
1543
- │ ForbiddenError → JSON 403 │
1544
- │ HttpError → JSON with custom status │
1545
- │ Other errors → throw → Hono onError handler │
1546
- └─────────────────────────────────────────────────────────┘
1547
-
1548
- ▼ (for thrown errors)
1549
- ┌─────────────────────────────────────────────────────────┐
1550
- │ Hono onError handler │
1551
- │ (from registerErrorHandlers) │
1552
- ├─────────────────────────────────────────────────────────┤
1553
- │ Renders error component via Honertia │
1554
- │ Shows detailed message in dev, generic in prod │
1555
- └─────────────────────────────────────────────────────────┘
1556
- ```
1190
+ ## Route Model Binding Examples
1557
1191
 
1558
- ### Usage Examples
1192
+ ### Basic Binding (by ID)
1559
1193
 
1560
1194
  ```typescript
1561
- import {
1562
- ValidationError,
1563
- UnauthorizedError,
1564
- NotFoundError,
1565
- ForbiddenError,
1566
- HttpError,
1567
- } from 'honertia/effect'
1195
+ // Route: /projects/{project}
1196
+ // Queries: SELECT * FROM projects WHERE id = :project
1568
1197
 
1569
- // Validation errors automatically re-render with field errors
1570
- const input = yield* validateRequest(schema, {
1571
- errorComponent: 'Projects/Create',
1572
- })
1198
+ effectRoutes(app).get('/projects/{project}', showProject)
1573
1199
 
1574
- // Manual error handling
1575
- const project = yield* Effect.tryPromise(() =>
1576
- db.query.projects.findFirst({ where: eq(id, projectId) })
1200
+ const showProject = action(
1201
+ Effect.gen(function* () {
1202
+ const project = yield* bound('project')
1203
+ return yield* render('Projects/Show', { project })
1204
+ })
1577
1205
  )
1206
+ ```
1578
1207
 
1579
- if (!project) {
1580
- return yield* notFound('Project', projectId)
1581
- }
1208
+ ### Binding by Slug
1582
1209
 
1583
- if (project.userId !== user.user.id) {
1584
- return yield* forbidden('You cannot view this project')
1585
- }
1586
- ```
1210
+ ```typescript
1211
+ // Route: /projects/{project:slug}
1212
+ // Queries: SELECT * FROM projects WHERE slug = :project
1587
1213
 
1588
- ## Authentication
1214
+ effectRoutes(app).get('/projects/{project:slug}', showProject)
1215
+ ```
1589
1216
 
1590
- ### Layers
1217
+ ### Nested Binding (Scoped)
1591
1218
 
1592
1219
  ```typescript
1593
- import { RequireAuthLayer, RequireGuestLayer } from 'honertia'
1220
+ // Route: /users/{user}/posts/{post}
1221
+ // Queries:
1222
+ // 1. SELECT * FROM users WHERE id = :user
1223
+ // 2. SELECT * FROM posts WHERE id = :post AND userId = :user.id
1594
1224
 
1595
- // Require authentication - fails with UnauthorizedError if no user
1596
- effectRoutes(app)
1597
- .provide(RequireAuthLayer)
1598
- .get('/dashboard', showDashboard)
1225
+ effectRoutes(app).get('/users/{user}/posts/{post}', showUserPost)
1599
1226
 
1600
- // Require guest - fails if user IS logged in
1601
- effectRoutes(app)
1602
- .provide(RequireGuestLayer)
1603
- .get('/login', showLogin)
1227
+ const showUserPost = action(
1228
+ Effect.gen(function* () {
1229
+ const user = yield* bound('user')
1230
+ const post = yield* bound('post') // Already scoped to user
1231
+ return yield* render('Users/Posts/Show', { user, post })
1232
+ })
1233
+ )
1604
1234
  ```
1605
1235
 
1606
- ### Helpers
1236
+ ### Mixed Notation
1607
1237
 
1608
1238
  ```typescript
1609
- import {
1610
- requireAuth,
1611
- requireGuest,
1612
- isAuthenticated,
1613
- currentUser,
1614
- } from 'honertia'
1615
-
1616
- // In a handler
1617
- export const showProfile = Effect.gen(function* () {
1618
- const user = yield* requireAuth('/login') // Redirect to /login if not auth'd
1619
- return yield* render('Profile', { user: user.user })
1620
- })
1239
+ // :version is a regular param, {project} is bound
1240
+ effectRoutes(app).get('/api/:version/projects/{project}', showProject)
1621
1241
 
1622
- // Check without failing
1623
- const authed = yield* isAuthenticated // boolean
1624
- const user = yield* currentUser // AuthUser | null
1242
+ const showProject = action(
1243
+ Effect.gen(function* () {
1244
+ const request = yield* RequestService
1245
+ const version = request.param('version') // Regular param
1246
+ const project = yield* bound('project') // Database model
1247
+ return yield* json({ version, project })
1248
+ })
1249
+ )
1250
+ ```
1251
+
1252
+ ### With Param Validation
1253
+
1254
+ ```typescript
1255
+ // Validate UUID format before database lookup
1256
+ effectRoutes(app).get(
1257
+ '/projects/{project}',
1258
+ showProject,
1259
+ { params: S.Struct({ project: uuid }) }
1260
+ )
1625
1261
  ```
1626
1262
 
1627
- ### Built-in Auth Routes
1263
+ ---
1264
+
1265
+ ## Auth Examples
1266
+
1267
+ ### Auth Routes Setup
1628
1268
 
1629
1269
  ```typescript
1630
1270
  import { effectAuthRoutes } from 'honertia/auth'
1631
- import { loginUser, registerUser, logoutUser, verify2FA, forgotPassword } from './actions/auth'
1632
1271
 
1633
1272
  effectAuthRoutes(app, {
1634
- // Page routes
1635
- loginPath: '/login', // GET: show login page
1636
- registerPath: '/register', // GET: show register page
1637
- logoutPath: '/logout', // POST: logout and redirect
1638
- apiPath: '/api/auth', // Better-auth API handler
1639
- logoutRedirect: '/login',
1640
- loginRedirect: '/',
1273
+ // Page components
1641
1274
  loginComponent: 'Auth/Login',
1642
1275
  registerComponent: 'Auth/Register',
1643
1276
 
1644
- // Form actions (automatically wrapped with RequireGuestLayer)
1645
- loginAction: loginUser, // POST /login
1646
- registerAction: registerUser, // POST /register
1647
- logoutAction: logoutUser, // POST /logout (overrides default)
1277
+ // Form actions
1278
+ loginAction: loginUser,
1279
+ registerAction: registerUser,
1280
+ logoutAction: logoutUser,
1281
+
1282
+ // Paths (defaults shown)
1283
+ loginPath: '/login',
1284
+ registerPath: '/register',
1285
+ logoutPath: '/logout',
1286
+ apiPath: '/api/auth',
1287
+
1288
+ // Redirects
1289
+ loginRedirect: '/',
1290
+ logoutRedirect: '/login',
1648
1291
 
1649
- // Extended auth flows (all guest-only POST routes)
1292
+ // Extended flows
1650
1293
  guestActions: {
1651
1294
  '/login/2fa': verify2FA,
1652
1295
  '/forgot-password': forgotPassword,
1653
1296
  },
1654
- })
1655
- ```
1656
-
1657
- All `loginAction`, `registerAction`, and `guestActions` are automatically wrapped with
1658
- `RequireGuestLayer`, so authenticated users will be redirected. The `logoutAction` is
1659
- not wrapped (logout should work regardless of auth state).
1660
-
1661
- To enable CORS for the auth API handler (`/api/auth/*`), pass a `cors` config.
1662
- By default, no CORS headers are added (recommended when your UI and API share the same origin).
1663
- Use this when your frontend is on a different origin (local dev, separate domain, mobile app, etc.).
1664
1297
 
1665
- ```typescript
1666
- effectAuthRoutes(app, {
1667
- apiPath: '/api/auth',
1298
+ // CORS for API (if frontend on different origin)
1668
1299
  cors: {
1669
- origin: ['http://localhost:5173', 'http://localhost:3000'],
1300
+ origin: ['http://localhost:5173'],
1670
1301
  credentials: true,
1671
1302
  },
1672
1303
  })
1673
1304
  ```
1674
1305
 
1675
- This sets the appropriate `Access-Control-*` headers and handles `OPTIONS` preflight for the auth API routes.
1676
- Always keep the `origin` list tight; avoid `'*'` for auth endpoints, especially with `credentials: true`.
1677
-
1678
- ### Better-auth Form Actions
1679
-
1680
- Honertia provides `betterAuthFormAction` to handle the common pattern of form-based
1681
- authentication: validate input, call better-auth, map errors to field-level messages,
1682
- and redirect on success. This bridges better-auth's JSON responses with Inertia's
1683
- form handling conventions.
1306
+ ### Login Action
1684
1307
 
1685
1308
  ```typescript
1686
- // src/actions/auth/login.ts
1687
1309
  import { betterAuthFormAction } from 'honertia/auth'
1688
1310
  import { Schema as S } from 'effect'
1689
- import { requiredString, email } from 'honertia'
1690
- import type { Auth } from './lib/auth' // your better-auth instance type
1311
+ import { email, requiredString } from 'honertia/effect'
1691
1312
 
1692
1313
  const LoginSchema = S.Struct({
1693
- email,
1314
+ email: email,
1694
1315
  password: requiredString,
1695
1316
  })
1696
1317
 
1697
- // Map better-auth error codes to user-friendly field errors
1698
- const mapLoginError = (error: { code?: string; message?: string }) => {
1318
+ const mapLoginError = (error: { code?: string }) => {
1699
1319
  switch (error.code) {
1700
1320
  case 'INVALID_EMAIL_OR_PASSWORD':
1701
1321
  return { email: 'Invalid email or password' }
1702
1322
  case 'USER_NOT_FOUND':
1703
1323
  return { email: 'No account found with this email' }
1704
- case 'INVALID_PASSWORD':
1705
- return { password: 'Incorrect password' }
1706
1324
  default:
1707
- return { email: error.message ?? 'Login failed' }
1325
+ return { email: 'Login failed' }
1708
1326
  }
1709
1327
  }
1710
1328
 
@@ -1713,10 +1331,7 @@ export const loginUser = betterAuthFormAction({
1713
1331
  errorComponent: 'Auth/Login',
1714
1332
  redirectTo: '/',
1715
1333
  errorMapper: mapLoginError,
1716
- // `auth` is the better-auth instance from AuthService
1717
- // `input` is the validated form data
1718
- // `request` is the original Request (needed for session cookies)
1719
- call: (auth: Auth, input, request) =>
1334
+ call: (auth, input, request) =>
1720
1335
  auth.api.signInEmail({
1721
1336
  body: { email: input.email, password: input.password },
1722
1337
  request,
@@ -1725,27 +1340,25 @@ export const loginUser = betterAuthFormAction({
1725
1340
  })
1726
1341
  ```
1727
1342
 
1343
+ ### Register Action
1344
+
1728
1345
  ```typescript
1729
- // src/actions/auth/register.ts
1730
1346
  import { betterAuthFormAction } from 'honertia/auth'
1731
1347
  import { Schema as S } from 'effect'
1732
- import { requiredString, email, password } from 'honertia'
1733
- import type { Auth } from './lib/auth'
1348
+ import { email, requiredString, password } from 'honertia/effect'
1734
1349
 
1735
1350
  const RegisterSchema = S.Struct({
1736
1351
  name: requiredString,
1737
- email,
1352
+ email: email,
1738
1353
  password: password({ min: 8, letters: true, numbers: true }),
1739
1354
  })
1740
1355
 
1741
- const mapRegisterError = (error: { code?: string; message?: string }) => {
1356
+ const mapRegisterError = (error: { code?: string }) => {
1742
1357
  switch (error.code) {
1743
1358
  case 'USER_ALREADY_EXISTS':
1744
1359
  return { email: 'An account with this email already exists' }
1745
- case 'PASSWORD_TOO_SHORT':
1746
- return { password: 'Password must be at least 8 characters' }
1747
1360
  default:
1748
- return { email: error.message ?? 'Registration failed' }
1361
+ return { email: 'Registration failed' }
1749
1362
  }
1750
1363
  }
1751
1364
 
@@ -1754,7 +1367,7 @@ export const registerUser = betterAuthFormAction({
1754
1367
  errorComponent: 'Auth/Register',
1755
1368
  redirectTo: '/',
1756
1369
  errorMapper: mapRegisterError,
1757
- call: (auth: Auth, input, request) =>
1370
+ call: (auth, input, request) =>
1758
1371
  auth.api.signUpEmail({
1759
1372
  body: { name: input.name, email: input.email, password: input.password },
1760
1373
  request,
@@ -1763,10 +1376,9 @@ export const registerUser = betterAuthFormAction({
1763
1376
  })
1764
1377
  ```
1765
1378
 
1766
- For logout, use the simpler `betterAuthLogoutAction`:
1379
+ ### Logout Action
1767
1380
 
1768
1381
  ```typescript
1769
- // src/actions/auth/logout.ts
1770
1382
  import { betterAuthLogoutAction } from 'honertia/auth'
1771
1383
 
1772
1384
  export const logoutUser = betterAuthLogoutAction({
@@ -1774,179 +1386,213 @@ export const logoutUser = betterAuthLogoutAction({
1774
1386
  })
1775
1387
  ```
1776
1388
 
1777
- **How errors are handled:**
1389
+ ### Auth Layers
1778
1390
 
1779
- 1. **Schema validation fails** → Re-renders `errorComponent` with field errors from Effect Schema
1780
- 2. **better-auth returns an error** Calls your `errorMapper`, then re-renders `errorComponent` with those errors
1781
- 3. **Success** → Sets session cookies from better-auth's response headers, then 303 redirects to `redirectTo`
1391
+ ```typescript
1392
+ import { RequireAuthLayer, RequireGuestLayer } from 'honertia/auth'
1393
+
1394
+ // Require logged-in user (redirects to /login if not)
1395
+ effectRoutes(app)
1396
+ .provide(RequireAuthLayer)
1397
+ .group((route) => {
1398
+ route.get('/dashboard', showDashboard)
1399
+ route.get('/settings', showSettings)
1400
+ })
1782
1401
 
1783
- ## React Integration
1402
+ // Require guest (redirects to / if logged in)
1403
+ effectRoutes(app)
1404
+ .provide(RequireGuestLayer)
1405
+ .group((route) => {
1406
+ route.get('/login', showLogin)
1407
+ route.get('/register', showRegister)
1408
+ })
1409
+ ```
1784
1410
 
1785
- ### Page Component Type
1411
+ ### Manual Auth Check in Action
1786
1412
 
1787
1413
  ```typescript
1788
- import type { HonertiaPage } from 'honertia'
1414
+ import { authorize, isAuthenticated, currentUser } from 'honertia/effect'
1789
1415
 
1790
- interface ProjectsProps {
1791
- projects: Project[]
1792
- }
1416
+ // Require auth (fails if not logged in)
1417
+ const auth = yield* authorize()
1793
1418
 
1794
- const ProjectsIndex: HonertiaPage<ProjectsProps> = ({ projects, errors }) => {
1795
- return (
1796
- <div>
1797
- {errors?.name && <span className="error">{errors.name}</span>}
1798
- {projects.map(p => <ProjectCard key={p.id} project={p} />)}
1799
- </div>
1800
- )
1801
- }
1419
+ // Require specific role
1420
+ const auth = yield* authorize((a) => a.user.role === 'admin')
1802
1421
 
1803
- export default ProjectsIndex
1422
+ // Check without failing
1423
+ const isLoggedIn = yield* isAuthenticated // boolean
1424
+ const user = yield* currentUser // AuthUser | null
1804
1425
  ```
1805
1426
 
1806
- ### Shared Props
1427
+ ---
1428
+
1429
+ ## Error Handling Examples
1807
1430
 
1808
- All pages receive shared props set via middleware:
1431
+ ### Throwing Errors
1809
1432
 
1810
1433
  ```typescript
1811
- // Server: shareAuthMiddleware() adds auth data
1812
- // Client: access via props
1813
- const Layout: HonertiaPage<Props> = ({ auth, children }) => {
1814
- return (
1815
- <div>
1816
- {auth?.user ? (
1817
- <span>Welcome, {auth.user.name}</span>
1818
- ) : (
1819
- <a href="/login">Login</a>
1820
- )}
1821
- {children}
1822
- </div>
1823
- )
1824
- }
1825
- ```
1434
+ import {
1435
+ notFound,
1436
+ forbidden,
1437
+ httpError,
1438
+ ValidationError,
1439
+ UnauthorizedError,
1440
+ } from 'honertia/effect'
1826
1441
 
1827
- ## TypeScript
1442
+ // 404 Not Found
1443
+ return yield* notFound('Project', projectId)
1828
1444
 
1829
- ### Typed Services via Module Augmentation
1445
+ // 403 Forbidden
1446
+ return yield* forbidden('You cannot edit this project')
1830
1447
 
1831
- Define your types once in `types.ts` and use them for both Hono and Effect services:
1448
+ // Custom HTTP error
1449
+ return yield* httpError(429, 'Rate limit exceeded', { retryAfter: 60 })
1832
1450
 
1833
- ```typescript
1834
- // src/types.ts
1835
- import type { D1Database } from '@cloudflare/workers-types'
1836
- import type { Database } from '~/db/db'
1837
- import type { Auth } from '~/lib/auth'
1838
- import * as schema from '~/db/schema'
1451
+ // Manual validation error
1452
+ yield* Effect.fail(new ValidationError({
1453
+ errors: { email: 'This email is already taken' },
1454
+ component: 'Auth/Register',
1455
+ }))
1839
1456
 
1840
- // Define bindings ONCE
1841
- export type Bindings = {
1842
- DB: D1Database
1843
- BETTER_AUTH_SECRET: string
1844
- ENVIRONMENT?: string
1845
- }
1457
+ // Manual unauthorized
1458
+ yield* Effect.fail(new UnauthorizedError({
1459
+ message: 'Session expired',
1460
+ redirectTo: '/login',
1461
+ }))
1462
+ ```
1846
1463
 
1847
- export type Variables = {
1848
- db: Database
1849
- auth: Auth
1850
- }
1464
+ ### Error Handler Setup
1851
1465
 
1852
- // Export for Hono
1853
- export type Env = {
1854
- Bindings: Bindings
1855
- Variables: Variables
1856
- }
1466
+ ```typescript
1467
+ import { registerErrorHandlers } from 'honertia'
1857
1468
 
1858
- // Module augmentation references the same types
1859
- declare module 'honertia/effect' {
1860
- interface HonertiaDatabaseType {
1861
- type: Database
1862
- schema: typeof schema
1863
- }
1469
+ registerErrorHandlers(app, {
1470
+ component: 'Error', // Error page component
1471
+ showDevErrors: true, // Show details in dev
1472
+ envKey: 'ENVIRONMENT',
1473
+ devValue: 'development',
1474
+ })
1475
+ ```
1864
1476
 
1865
- interface HonertiaAuthType {
1866
- type: Auth
1867
- }
1477
+ ### Error Page Component
1868
1478
 
1869
- interface HonertiaBindingsType {
1870
- type: Bindings
1479
+ ```tsx
1480
+ // src/pages/Error.tsx
1481
+ interface ErrorProps {
1482
+ status: number
1483
+ code: string
1484
+ title: string
1485
+ message: string
1486
+ hint?: string // Only in dev
1487
+ fixes?: Array<{ description: string }> // Only in dev
1488
+ source?: { file: string; line: number } // Only in dev
1489
+ }
1490
+
1491
+ export default function Error({ status, title, message, hint, fixes }: ErrorProps) {
1492
+ return (
1493
+ <div className="error-page">
1494
+ <h1>{status}</h1>
1495
+ <h2>{title}</h2>
1496
+ <p>{message}</p>
1497
+ {hint && <p className="hint">{hint}</p>}
1498
+ {fixes?.map((fix, i) => <div key={i}>{fix.description}</div>)}
1499
+ </div>
1500
+ )
1871
1501
  }
1872
1502
  ```
1873
1503
 
1874
- Now use the `Env` type for Hono and get full type safety everywhere:
1504
+ ---
1505
+
1506
+ ## Response Helpers
1875
1507
 
1876
1508
  ```typescript
1877
- // src/index.ts
1878
- import type { Env } from './types'
1509
+ import { render, redirect, json, notFound, forbidden, httpError } from 'honertia/effect'
1879
1510
 
1880
- const app = new Hono<Env>()
1511
+ // Render page with props
1512
+ return yield* render('Projects/Index', { projects })
1881
1513
 
1882
- app.use('*', setupHonertia<Env>({
1883
- honertia: {
1884
- database: (c) => createDb(c.env.DB), // ✅ c.env.DB is typed
1885
- auth: (c) => createAuth({
1886
- db: c.var.db, // ✅ c.var.db is typed
1887
- secret: c.env.BETTER_AUTH_SECRET,
1888
- }),
1889
- // ...
1890
- },
1891
- }))
1892
- ```
1514
+ // Render with validation errors
1515
+ return yield* renderWithErrors('Projects/Create', {
1516
+ name: 'Name is required',
1517
+ })
1893
1518
 
1894
- ```typescript
1895
- // In your actions
1896
- import { DatabaseService, BindingsService } from 'honertia/effect'
1519
+ // Redirect (303 for POST, 302 otherwise)
1520
+ return yield* redirect('/projects')
1521
+ return yield* redirect('/login', 302)
1897
1522
 
1898
- const db = yield* DatabaseService // typed as Database
1899
- const { DB } = yield* BindingsService // typed as Bindings
1523
+ // JSON response
1524
+ return yield* json({ success: true })
1525
+ return yield* json({ error: 'Not found' }, 404)
1900
1526
 
1901
- const projects = yield* Effect.tryPromise(() =>
1902
- db.query.projects.findMany() // ✅ full type safety
1903
- )
1527
+ // Error responses
1528
+ return yield* notFound('Project')
1529
+ return yield* forbidden('Access denied')
1530
+ return yield* httpError(429, 'Rate limited')
1904
1531
  ```
1905
1532
 
1906
- One type definition, used everywhere—no duplication.
1533
+ ---
1907
1534
 
1908
- ## Architecture Notes
1535
+ ## Services Reference
1909
1536
 
1910
- ### Request-Scoped Services
1537
+ | Service | Description | Usage |
1538
+ |---------|-------------|-------|
1539
+ | `DatabaseService` | Drizzle database client | `const db = yield* DatabaseService` |
1540
+ | `AuthService` | Better-auth instance | `const auth = yield* AuthService` |
1541
+ | `AuthUserService` | Current user session | `const user = yield* AuthUserService` |
1542
+ | `BindingsService` | Cloudflare bindings | `const { KV } = yield* BindingsService` |
1543
+ | `RequestService` | Request context | `const req = yield* RequestService` |
1911
1544
 
1912
- Honertia creates a fresh Effect runtime per request via `effectBridge()`. This is required for Cloudflare Workers where I/O objects cannot be shared between requests.
1545
+ ### Using BindingsService
1913
1546
 
1914
1547
  ```typescript
1915
- // This happens automatically in effectBridge middleware:
1916
- const layer = buildContextLayer(c) // Build layers from Hono context
1917
- const runtime = ManagedRuntime.make(layer) // New runtime per request
1918
- // ... handle request ...
1919
- await runtime.dispose() // Cleanup after request
1548
+ import { BindingsService } from 'honertia/effect'
1549
+
1550
+ const handler = action(
1551
+ Effect.gen(function* () {
1552
+ const { KV, R2, QUEUE } = yield* BindingsService
1553
+
1554
+ const cached = yield* Effect.tryPromise(() => KV.get('key'))
1555
+ yield* Effect.tryPromise(() => QUEUE.send({ type: 'event' }))
1556
+
1557
+ return yield* json({ cached })
1558
+ })
1559
+ )
1920
1560
  ```
1921
1561
 
1922
- This approach provides full type safety - your handlers declare their service requirements, and the type system ensures they're provided.
1562
+ ---
1923
1563
 
1924
- ### Why Not Global Runtime?
1564
+ ## Environment
1925
1565
 
1926
- On Cloudflare Workers, database connections and other I/O objects are isolated per request. Using a global runtime with `FiberRef` would lose type safety. The per-request runtime approach ensures:
1566
+ ```toml
1567
+ # wrangler.toml
1568
+ [vars]
1569
+ ENVIRONMENT = "production"
1570
+ ```
1927
1571
 
1928
- 1. Type-safe dependency injection
1929
- 2. Proper resource cleanup
1930
- 3. Full compatibility with Workers' isolation model
1572
+ ```bash
1573
+ # Secrets (not in source control)
1574
+ wrangler secret put DATABASE_URL
1575
+ wrangler secret put BETTER_AUTH_SECRET
1576
+ ```
1931
1577
 
1932
- If you're using PlanetScale with Hyperdrive, the "connection" you create per request is lightweight - it's just a client pointing at Hyperdrive's persistent connection pool.
1578
+ ---
1933
1579
 
1934
- ## Acknowledgements
1580
+ ## Testing
1935
1581
 
1936
- - Inertia.js by Jonathan Reinink and its contributors
1937
- - Laravel by Taylor Otwell and the Laravel community
1582
+ Actions generated with CLI include inline tests:
1938
1583
 
1939
- 🐐
1584
+ ```bash
1585
+ # Test single action
1586
+ bun test src/actions/projects/create.ts
1940
1587
 
1941
- ## Contributing
1588
+ # Test all actions in a resource
1589
+ bun test src/actions/projects/
1942
1590
 
1943
- Contributions are welcome! Please feel free to submit a Pull Request.
1591
+ # Run project checks
1592
+ honertia check --verbose
1593
+ ```
1944
1594
 
1945
- 1. Fork the repository
1946
- 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
1947
- 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
1948
- 4. Push to the branch (`git push origin feature/amazing-feature`)
1949
- 5. Open a Pull Request
1595
+ ---
1950
1596
 
1951
1597
  ## License
1952
1598