honertia 0.1.21 → 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 (74) hide show
  1. package/README.md +1125 -1372
  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/binding.d.ts.map +1 -1
  24. package/dist/effect/binding.js +1 -4
  25. package/dist/effect/bridge.d.ts +1 -0
  26. package/dist/effect/bridge.d.ts.map +1 -1
  27. package/dist/effect/bridge.js +51 -6
  28. package/dist/effect/error-catalog.d.ts +100 -0
  29. package/dist/effect/error-catalog.d.ts.map +1 -0
  30. package/dist/effect/error-catalog.js +700 -0
  31. package/dist/effect/error-context.d.ts +99 -0
  32. package/dist/effect/error-context.d.ts.map +1 -0
  33. package/dist/effect/error-context.js +230 -0
  34. package/dist/effect/error-formatter.d.ts +143 -0
  35. package/dist/effect/error-formatter.d.ts.map +1 -0
  36. package/dist/effect/error-formatter.js +355 -0
  37. package/dist/effect/error-types.d.ts +275 -0
  38. package/dist/effect/error-types.d.ts.map +1 -0
  39. package/dist/effect/error-types.js +7 -0
  40. package/dist/effect/errors.d.ts +183 -15
  41. package/dist/effect/errors.d.ts.map +1 -1
  42. package/dist/effect/errors.js +333 -8
  43. package/dist/effect/handler.d.ts +6 -0
  44. package/dist/effect/handler.d.ts.map +1 -1
  45. package/dist/effect/handler.js +124 -12
  46. package/dist/effect/index.d.ts +13 -5
  47. package/dist/effect/index.d.ts.map +1 -1
  48. package/dist/effect/index.js +18 -4
  49. package/dist/effect/route-registry.d.ts +178 -0
  50. package/dist/effect/route-registry.d.ts.map +1 -0
  51. package/dist/effect/route-registry.js +250 -0
  52. package/dist/effect/routing.d.ts +65 -3
  53. package/dist/effect/routing.d.ts.map +1 -1
  54. package/dist/effect/routing.js +99 -8
  55. package/dist/effect/services.d.ts +10 -1
  56. package/dist/effect/services.d.ts.map +1 -1
  57. package/dist/effect/services.js +2 -0
  58. package/dist/effect/test-layers.d.ts +52 -0
  59. package/dist/effect/test-layers.d.ts.map +1 -0
  60. package/dist/effect/test-layers.js +129 -0
  61. package/dist/effect/testing.d.ts +213 -0
  62. package/dist/effect/testing.d.ts.map +1 -0
  63. package/dist/effect/testing.js +300 -0
  64. package/dist/effect/validated-services.d.ts +17 -0
  65. package/dist/effect/validated-services.d.ts.map +1 -0
  66. package/dist/effect/validated-services.js +13 -0
  67. package/dist/effect/validation.d.ts +26 -11
  68. package/dist/effect/validation.d.ts.map +1 -1
  69. package/dist/effect/validation.js +80 -4
  70. package/dist/helpers.d.ts.map +1 -1
  71. package/dist/helpers.js +5 -1
  72. package/dist/setup.d.ts.map +1 -1
  73. package/dist/setup.js +77 -12
  74. 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
404
 
167
- interface ProjectsIndexProps {
168
- projects: Project[]
169
- }
170
-
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:
196
-
197
- ```
198
- src/pages/Projects/Index.tsx
199
- ```
412
+ ### 7. src/main.tsx (REQUIRED)
200
413
 
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
- ```
441
+ name = "my-app"
442
+ compatibility_date = "2024-01-01"
443
+ main = "src/index.ts"
301
444
 
302
- ### Client Setup (React + Inertia)
445
+ [vars]
446
+ ENVIRONMENT = "development"
303
447
 
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`).
448
+ [[d1_databases]]
449
+ binding = "DB"
450
+ database_name = "my-app-db"
451
+ database_id = "your-database-id"
306
452
 
307
- Install client dependencies:
308
-
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:
341
-
342
- ```css
343
- /* src/styles.css */
344
- @import "tailwindcss";
482
+ ### 10. tsconfig.json (REQUIRED)
345
483
 
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
+ ---
506
+
507
+ ## Project Structure
508
+
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
+ ```
543
+
544
+ ---
545
+
546
+ ## Auth Actions (REQUIRED)
547
+
548
+ These three actions are required for `effectAuthRoutes` to work.
549
+
550
+ ### src/actions/auth/login.ts
357
551
 
358
- ```tsx
359
- // src/main.tsx
360
- import './styles.css'
361
-
362
- import { createInertiaApp } from '@inertiajs/react'
363
- import { createRoot } from 'react-dom/client'
552
+ ```typescript
553
+ import { betterAuthFormAction } from 'honertia/auth'
554
+ import { Schema as S } from 'effect'
555
+ import { email, requiredString } from 'honertia/effect'
364
556
 
365
- const pages = import.meta.glob('./pages/**/*.tsx')
557
+ const LoginSchema = S.Struct({
558
+ email: email,
559
+ password: requiredString,
560
+ })
366
561
 
367
- createInertiaApp({
368
- resolve: (name) => {
369
- const page = pages[`./pages/${name}.tsx`]
370
- if (!page) {
371
- throw new Error(`Page not found: ${name}`)
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:
878
+ ### PUT with Route Binding and Validation
584
879
 
585
880
  ```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:
601
-
602
- ```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
905
+ const project = yield* bound('project')
648
906
 
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.
907
+ // Authorization check
908
+ if (project.userId !== auth.user.id) {
909
+ return yield* forbidden('You cannot edit this project')
910
+ }
670
911
 
671
- ```typescript
672
- const input = yield* validateRequest(CreateProjectSchema)
912
+ const input = yield* validateRequest(UpdateProjectSchema, {
913
+ errorComponent: 'Projects/Edit',
914
+ })
915
+ const db = yield* DatabaseService
673
916
 
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)
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
+ })
677
925
 
678
- // Explicitly re-brand after adding server data
679
- const values = asTrusted({ ...input, userId: auth.user.id })
680
- await db.insert(projects).values(values)
926
+ return yield* redirect(`/projects/${project.id}`)
927
+ })
928
+ )
681
929
  ```
682
930
 
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:
688
-
689
- ```typescript
690
- import { DatabaseService, dbTransaction, asTrusted } from 'honertia/effect'
691
-
692
- const db = yield* DatabaseService
693
- const user = asTrusted({ name: 'Alice', email: 'alice@example.com' })
694
- const balanceUpdate = asTrusted({ balance: 100 })
695
-
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
- })
702
- ```
703
-
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,844 +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
- >() {}
816
-
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
- },
827
- })
828
-
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
- }
850
-
851
- // ... create project ...
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'
852
974
 
853
- yield* Effect.tryPromise(() => rateLimiter.increment(`create:${auth.user.id}`))
854
- return yield* redirect('/projects')
975
+ const SearchSchema = S.Struct({
976
+ q: S.String,
977
+ limit: S.optional(S.NumberFromString).pipe(S.withDefault(() => 10)),
855
978
  })
856
- ```
857
-
858
- **Multiple services with `Layer.mergeAll`:**
859
979
 
860
- ```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:
980
+ export const searchProjects = action(
981
+ Effect.gen(function* () {
982
+ const { q, limit } = yield* validateRequest(SearchSchema)
983
+ const db = yield* DatabaseService
879
984
 
880
- ```typescript
881
- import {
882
- effectRoutes,
883
- RequireAuthLayer,
884
- RequireGuestLayer,
885
- } from 'honertia'
985
+ const results = yield* Effect.tryPromise(() =>
986
+ db.query.projects.findMany({
987
+ where: like(projects.name, `%${q}%`),
988
+ limit,
989
+ })
990
+ )
886
991
 
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)
992
+ return yield* json({ results, count: results.length })
895
993
  })
896
-
897
- // Guest-only routes
898
- effectRoutes(app)
899
- .provide(RequireGuestLayer)
900
- .group((route) => {
901
- route.get('/login', showLogin)
902
- route.get('/register', showRegister)
903
- })
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
994
  )
925
995
  ```
926
996
 
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:
928
-
929
- ```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
943
-
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:
997
+ ### Action with Role Check
949
998
 
950
999
  ```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)
1000
+ import { Effect } from 'effect'
1001
+ import { action, authorize, DatabaseService, render } from 'honertia/effect'
1061
1002
 
1062
- **Mixed Notation:**
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
1063
1008
 
1064
- You can mix Laravel-style `{binding}` with Hono-style `:param` in the same route. Only `{binding}` params are resolved from the database:
1009
+ const stats = yield* Effect.tryPromise(() =>
1010
+ db.query.users.findMany({ limit: 100 })
1011
+ )
1065
1012
 
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
1013
+ return yield* render('Admin/Dashboard', { stats })
1014
+ })
1072
1015
  )
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
1016
  ```
1081
1017
 
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:
1018
+ ### Action with Custom Error Handling
1089
1019
 
1090
1020
  ```typescript
1091
- import { Effect, Schema as S } from 'effect'
1021
+ import { Effect } from 'effect'
1092
1022
  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
- ```
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'
1122
1032
 
1123
- ### Validation Options
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')
1124
1039
 
1125
- `validateRequest` accepts an options object with:
1040
+ const project = yield* Effect.tryPromise(() =>
1041
+ db.query.projects.findFirst({
1042
+ where: eq(projects.id, projectId),
1043
+ })
1044
+ )
1126
1045
 
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',
1046
+ if (!project) {
1047
+ return yield* notFound('Project', projectId)
1048
+ }
1132
1049
 
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
- },
1050
+ if (project.userId !== auth.user.id) {
1051
+ return yield* httpError(403, 'Access denied')
1052
+ }
1138
1053
 
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
- })
1054
+ return yield* render('Projects/Show', { project })
1055
+ })
1056
+ )
1146
1057
  ```
1147
1058
 
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
- })
1059
+ ---
1154
1060
 
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
- ```
1061
+ ## Validation Examples
1161
1062
 
1162
- ### Available Validators
1063
+ ### String Validators
1163
1064
 
1164
- #### Strings
1165
1065
  ```typescript
1066
+ import { Schema as S } from 'effect'
1166
1067
  import {
1167
- requiredString, // Trimmed, non-empty string
1168
- nullableString, // Converts empty to null
1169
- required, // Custom message: required('Name is required')
1068
+ requiredString, // Trimmed, non-empty
1069
+ nullableString, // Empty string -> null
1070
+ email, // Email format
1071
+ url, // URL format
1072
+ uuid, // UUID format
1170
1073
  alpha, // Letters only
1171
1074
  alphaDash, // Letters, numbers, dashes, underscores
1172
- alphaNum, // Letters and numbers only
1173
- email, // Validated email
1174
- url, // Validated URL
1175
- uuid, // UUID format
1075
+ alphaNum, // Letters and numbers
1176
1076
  min, // min(5) - at least 5 chars
1177
1077
  max, // max(100) - at most 100 chars
1178
1078
  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
1079
+ } from 'honertia/effect'
1293
1080
 
1294
- #### `UnauthorizedError`
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
+ })
1088
+ ```
1295
1089
 
1296
- Thrown when authentication is required but the user is not logged in.
1090
+ ### Number Validators
1297
1091
 
1298
1092
  ```typescript
1299
- import { UnauthorizedError, authorize } from 'honertia/effect'
1300
-
1301
- // Automatic: authorize() throws UnauthorizedError if no user
1302
- const auth = yield* authorize()
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'
1303
1104
 
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
- }))
1105
+ const ProductSchema = S.Struct({
1106
+ price: coercedNumber.pipe(gte(0)),
1107
+ quantity: positiveInt,
1108
+ discount: S.optional(coercedNumber.pipe(between(0, 100))),
1109
+ })
1309
1110
  ```
1310
1111
 
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.
1112
+ ### Boolean and Date Validators
1316
1113
 
1317
1114
  ```typescript
1318
- import { NotFoundError, notFound } from 'honertia/effect'
1319
-
1320
- // Helper function
1321
- return yield* notFound('Project', projectId)
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'
1322
1125
 
1323
- // Manual
1324
- yield* Effect.fail(new NotFoundError({
1325
- resource: 'Project',
1326
- id: projectId,
1327
- }))
1126
+ const EventSchema = S.Struct({
1127
+ isPublic: checkbox,
1128
+ termsAccepted: accepted,
1129
+ startDate: coercedDate.pipe(after(new Date())),
1130
+ endDate: S.optional(nullableDate),
1131
+ })
1328
1132
  ```
1329
1133
 
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.
1134
+ ### Password Validator
1335
1135
 
1336
1136
  ```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')
1137
+ import { password } from 'honertia/effect'
1341
1138
 
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
- }))
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
+ })
1349
1149
  ```
1350
1150
 
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.
1151
+ ### Full Form Example
1356
1152
 
1357
1153
  ```typescript
1358
- import { HttpError, httpError } from 'honertia/effect'
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'
1359
1164
 
1360
- // Helper function
1361
- return yield* httpError(429, 'Rate limited', { retryAfter: 60 })
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
+ })
1362
1174
 
1363
- // Manual
1364
- yield* Effect.fail(new HttpError({
1365
- status: 429,
1366
- message: 'Too many requests',
1367
- body: { retryAfter: 60 }, // Optional additional data
1368
- }))
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
+ },
1185
+ })
1369
1186
  ```
1370
1187
 
1371
- **Behavior:** Returns JSON `{ message: "...", ...body }` with the specified status code.
1188
+ ---
1372
1189
 
1373
- #### `RouteConfigurationError`
1190
+ ## Route Model Binding Examples
1374
1191
 
1375
- Thrown when there's a developer configuration error, such as using route model binding without providing a schema.
1192
+ ### Basic Binding (by ID)
1376
1193
 
1377
1194
  ```typescript
1378
- import { RouteConfigurationError } from 'honertia/effect'
1195
+ // Route: /projects/{project}
1196
+ // Queries: SELECT * FROM projects WHERE id = :project
1379
1197
 
1380
- // This error is thrown automatically when:
1381
- // - You use bound('project') but didn't pass schema to effectRoutes()
1382
- // - Other route configuration mistakes
1198
+ effectRoutes(app).get('/projects/{project}', showProject)
1383
1199
 
1384
- // You typically don't throw this manually
1200
+ const showProject = action(
1201
+ Effect.gen(function* () {
1202
+ const project = yield* bound('project')
1203
+ return yield* render('Projects/Show', { project })
1204
+ })
1205
+ )
1385
1206
  ```
1386
1207
 
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`:
1208
+ ### Binding by Slug
1392
1209
 
1393
1210
  ```typescript
1394
- import { registerErrorHandlers } from 'honertia'
1211
+ // Route: /projects/{project:slug}
1212
+ // Queries: SELECT * FROM projects WHERE slug = :project
1395
1213
 
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
- })
1214
+ effectRoutes(app).get('/projects/{project:slug}', showProject)
1403
1215
  ```
1404
1216
 
1405
- Your `Error` component receives:
1406
-
1407
- ```tsx
1408
- // src/pages/Error.tsx
1409
- interface ErrorProps {
1410
- status: number // 404, 500, etc.
1411
- message: string // Error message (detailed in dev, generic in prod)
1412
- }
1217
+ ### Nested Binding (Scoped)
1413
1218
 
1414
- export default function Error({ status, message }: ErrorProps) {
1415
- return (
1416
- <div className="error-page">
1417
- <h1>{status}</h1>
1418
- <p>{message}</p>
1419
- </div>
1420
- )
1421
- }
1422
- ```
1219
+ ```typescript
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
1423
1224
 
1424
- ### Error Handling Flow
1225
+ effectRoutes(app).get('/users/{user}/posts/{post}', showUserPost)
1425
1226
 
1426
- ```
1427
- Effect Handler
1428
-
1429
-
1430
- ┌─────────────────────────────────────────────────────────┐
1431
- │ errorToResponse()
1432
- ├─────────────────────────────────────────────────────────┤
1433
- │ ValidationError → Re-render form / redirect back │
1434
- │ UnauthorizedError → Redirect to login │
1435
- │ NotFoundError → c.notFound() → Hono notFound handler │
1436
- │ ForbiddenError → JSON 403 │
1437
- │ HttpError → JSON with custom status │
1438
- │ Other errors → throw → Hono onError handler │
1439
- └─────────────────────────────────────────────────────────┘
1440
-
1441
- ▼ (for thrown errors)
1442
- ┌─────────────────────────────────────────────────────────┐
1443
- │ Hono onError handler │
1444
- │ (from registerErrorHandlers) │
1445
- ├─────────────────────────────────────────────────────────┤
1446
- │ Renders error component via Honertia │
1447
- │ Shows detailed message in dev, generic in prod │
1448
- └─────────────────────────────────────────────────────────┘
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
+ )
1449
1234
  ```
1450
1235
 
1451
- ### Usage Examples
1236
+ ### Mixed Notation
1452
1237
 
1453
1238
  ```typescript
1454
- import {
1455
- ValidationError,
1456
- UnauthorizedError,
1457
- NotFoundError,
1458
- ForbiddenError,
1459
- HttpError,
1460
- } from 'honertia/effect'
1461
-
1462
- // Validation errors automatically re-render with field errors
1463
- const input = yield* validateRequest(schema, {
1464
- errorComponent: 'Projects/Create',
1465
- })
1239
+ // :version is a regular param, {project} is bound
1240
+ effectRoutes(app).get('/api/:version/projects/{project}', showProject)
1466
1241
 
1467
- // Manual error handling
1468
- const project = yield* Effect.tryPromise(() =>
1469
- db.query.projects.findFirst({ where: eq(id, projectId) })
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
+ })
1470
1249
  )
1471
-
1472
- if (!project) {
1473
- return yield* notFound('Project', projectId)
1474
- }
1475
-
1476
- if (project.userId !== user.user.id) {
1477
- return yield* forbidden('You cannot view this project')
1478
- }
1479
1250
  ```
1480
1251
 
1481
- ## Authentication
1482
-
1483
- ### Layers
1252
+ ### With Param Validation
1484
1253
 
1485
1254
  ```typescript
1486
- import { RequireAuthLayer, RequireGuestLayer } from 'honertia'
1487
-
1488
- // Require authentication - fails with UnauthorizedError if no user
1489
- effectRoutes(app)
1490
- .provide(RequireAuthLayer)
1491
- .get('/dashboard', showDashboard)
1492
-
1493
- // Require guest - fails if user IS logged in
1494
- effectRoutes(app)
1495
- .provide(RequireGuestLayer)
1496
- .get('/login', showLogin)
1255
+ // Validate UUID format before database lookup
1256
+ effectRoutes(app).get(
1257
+ '/projects/{project}',
1258
+ showProject,
1259
+ { params: S.Struct({ project: uuid }) }
1260
+ )
1497
1261
  ```
1498
1262
 
1499
- ### Helpers
1500
-
1501
- ```typescript
1502
- import {
1503
- requireAuth,
1504
- requireGuest,
1505
- isAuthenticated,
1506
- currentUser,
1507
- } from 'honertia'
1508
-
1509
- // In a handler
1510
- export const showProfile = Effect.gen(function* () {
1511
- const user = yield* requireAuth('/login') // Redirect to /login if not auth'd
1512
- return yield* render('Profile', { user: user.user })
1513
- })
1263
+ ---
1514
1264
 
1515
- // Check without failing
1516
- const authed = yield* isAuthenticated // boolean
1517
- const user = yield* currentUser // AuthUser | null
1518
- ```
1265
+ ## Auth Examples
1519
1266
 
1520
- ### Built-in Auth Routes
1267
+ ### Auth Routes Setup
1521
1268
 
1522
1269
  ```typescript
1523
1270
  import { effectAuthRoutes } from 'honertia/auth'
1524
- import { loginUser, registerUser, logoutUser, verify2FA, forgotPassword } from './actions/auth'
1525
1271
 
1526
1272
  effectAuthRoutes(app, {
1527
- // Page routes
1528
- loginPath: '/login', // GET: show login page
1529
- registerPath: '/register', // GET: show register page
1530
- logoutPath: '/logout', // POST: logout and redirect
1531
- apiPath: '/api/auth', // Better-auth API handler
1532
- logoutRedirect: '/login',
1533
- loginRedirect: '/',
1273
+ // Page components
1534
1274
  loginComponent: 'Auth/Login',
1535
1275
  registerComponent: 'Auth/Register',
1536
1276
 
1537
- // Form actions (automatically wrapped with RequireGuestLayer)
1538
- loginAction: loginUser, // POST /login
1539
- registerAction: registerUser, // POST /register
1540
- 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',
1541
1291
 
1542
- // Extended auth flows (all guest-only POST routes)
1292
+ // Extended flows
1543
1293
  guestActions: {
1544
1294
  '/login/2fa': verify2FA,
1545
1295
  '/forgot-password': forgotPassword,
1546
1296
  },
1547
- })
1548
- ```
1549
-
1550
- All `loginAction`, `registerAction`, and `guestActions` are automatically wrapped with
1551
- `RequireGuestLayer`, so authenticated users will be redirected. The `logoutAction` is
1552
- not wrapped (logout should work regardless of auth state).
1553
1297
 
1554
- To enable CORS for the auth API handler (`/api/auth/*`), pass a `cors` config.
1555
- By default, no CORS headers are added (recommended when your UI and API share the same origin).
1556
- Use this when your frontend is on a different origin (local dev, separate domain, mobile app, etc.).
1557
-
1558
- ```typescript
1559
- effectAuthRoutes(app, {
1560
- apiPath: '/api/auth',
1298
+ // CORS for API (if frontend on different origin)
1561
1299
  cors: {
1562
- origin: ['http://localhost:5173', 'http://localhost:3000'],
1300
+ origin: ['http://localhost:5173'],
1563
1301
  credentials: true,
1564
1302
  },
1565
1303
  })
1566
1304
  ```
1567
1305
 
1568
- This sets the appropriate `Access-Control-*` headers and handles `OPTIONS` preflight for the auth API routes.
1569
- Always keep the `origin` list tight; avoid `'*'` for auth endpoints, especially with `credentials: true`.
1570
-
1571
- ### Better-auth Form Actions
1572
-
1573
- Honertia provides `betterAuthFormAction` to handle the common pattern of form-based
1574
- authentication: validate input, call better-auth, map errors to field-level messages,
1575
- and redirect on success. This bridges better-auth's JSON responses with Inertia's
1576
- form handling conventions.
1306
+ ### Login Action
1577
1307
 
1578
1308
  ```typescript
1579
- // src/actions/auth/login.ts
1580
1309
  import { betterAuthFormAction } from 'honertia/auth'
1581
1310
  import { Schema as S } from 'effect'
1582
- import { requiredString, email } from 'honertia'
1583
- import type { Auth } from './lib/auth' // your better-auth instance type
1311
+ import { email, requiredString } from 'honertia/effect'
1584
1312
 
1585
1313
  const LoginSchema = S.Struct({
1586
- email,
1314
+ email: email,
1587
1315
  password: requiredString,
1588
1316
  })
1589
1317
 
1590
- // Map better-auth error codes to user-friendly field errors
1591
- const mapLoginError = (error: { code?: string; message?: string }) => {
1318
+ const mapLoginError = (error: { code?: string }) => {
1592
1319
  switch (error.code) {
1593
1320
  case 'INVALID_EMAIL_OR_PASSWORD':
1594
1321
  return { email: 'Invalid email or password' }
1595
1322
  case 'USER_NOT_FOUND':
1596
1323
  return { email: 'No account found with this email' }
1597
- case 'INVALID_PASSWORD':
1598
- return { password: 'Incorrect password' }
1599
1324
  default:
1600
- return { email: error.message ?? 'Login failed' }
1325
+ return { email: 'Login failed' }
1601
1326
  }
1602
1327
  }
1603
1328
 
@@ -1606,10 +1331,7 @@ export const loginUser = betterAuthFormAction({
1606
1331
  errorComponent: 'Auth/Login',
1607
1332
  redirectTo: '/',
1608
1333
  errorMapper: mapLoginError,
1609
- // `auth` is the better-auth instance from AuthService
1610
- // `input` is the validated form data
1611
- // `request` is the original Request (needed for session cookies)
1612
- call: (auth: Auth, input, request) =>
1334
+ call: (auth, input, request) =>
1613
1335
  auth.api.signInEmail({
1614
1336
  body: { email: input.email, password: input.password },
1615
1337
  request,
@@ -1618,27 +1340,25 @@ export const loginUser = betterAuthFormAction({
1618
1340
  })
1619
1341
  ```
1620
1342
 
1343
+ ### Register Action
1344
+
1621
1345
  ```typescript
1622
- // src/actions/auth/register.ts
1623
1346
  import { betterAuthFormAction } from 'honertia/auth'
1624
1347
  import { Schema as S } from 'effect'
1625
- import { requiredString, email, password } from 'honertia'
1626
- import type { Auth } from './lib/auth'
1348
+ import { email, requiredString, password } from 'honertia/effect'
1627
1349
 
1628
1350
  const RegisterSchema = S.Struct({
1629
1351
  name: requiredString,
1630
- email,
1352
+ email: email,
1631
1353
  password: password({ min: 8, letters: true, numbers: true }),
1632
1354
  })
1633
1355
 
1634
- const mapRegisterError = (error: { code?: string; message?: string }) => {
1356
+ const mapRegisterError = (error: { code?: string }) => {
1635
1357
  switch (error.code) {
1636
1358
  case 'USER_ALREADY_EXISTS':
1637
1359
  return { email: 'An account with this email already exists' }
1638
- case 'PASSWORD_TOO_SHORT':
1639
- return { password: 'Password must be at least 8 characters' }
1640
1360
  default:
1641
- return { email: error.message ?? 'Registration failed' }
1361
+ return { email: 'Registration failed' }
1642
1362
  }
1643
1363
  }
1644
1364
 
@@ -1647,7 +1367,7 @@ export const registerUser = betterAuthFormAction({
1647
1367
  errorComponent: 'Auth/Register',
1648
1368
  redirectTo: '/',
1649
1369
  errorMapper: mapRegisterError,
1650
- call: (auth: Auth, input, request) =>
1370
+ call: (auth, input, request) =>
1651
1371
  auth.api.signUpEmail({
1652
1372
  body: { name: input.name, email: input.email, password: input.password },
1653
1373
  request,
@@ -1656,10 +1376,9 @@ export const registerUser = betterAuthFormAction({
1656
1376
  })
1657
1377
  ```
1658
1378
 
1659
- For logout, use the simpler `betterAuthLogoutAction`:
1379
+ ### Logout Action
1660
1380
 
1661
1381
  ```typescript
1662
- // src/actions/auth/logout.ts
1663
1382
  import { betterAuthLogoutAction } from 'honertia/auth'
1664
1383
 
1665
1384
  export const logoutUser = betterAuthLogoutAction({
@@ -1667,179 +1386,213 @@ export const logoutUser = betterAuthLogoutAction({
1667
1386
  })
1668
1387
  ```
1669
1388
 
1670
- **How errors are handled:**
1389
+ ### Auth Layers
1390
+
1391
+ ```typescript
1392
+ import { RequireAuthLayer, RequireGuestLayer } from 'honertia/auth'
1671
1393
 
1672
- 1. **Schema validation fails** → Re-renders `errorComponent` with field errors from Effect Schema
1673
- 2. **better-auth returns an error** → Calls your `errorMapper`, then re-renders `errorComponent` with those errors
1674
- 3. **Success** → Sets session cookies from better-auth's response headers, then 303 redirects to `redirectTo`
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
+ })
1675
1401
 
1676
- ## 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
+ ```
1677
1410
 
1678
- ### Page Component Type
1411
+ ### Manual Auth Check in Action
1679
1412
 
1680
1413
  ```typescript
1681
- import type { HonertiaPage } from 'honertia'
1414
+ import { authorize, isAuthenticated, currentUser } from 'honertia/effect'
1682
1415
 
1683
- interface ProjectsProps {
1684
- projects: Project[]
1685
- }
1416
+ // Require auth (fails if not logged in)
1417
+ const auth = yield* authorize()
1686
1418
 
1687
- const ProjectsIndex: HonertiaPage<ProjectsProps> = ({ projects, errors }) => {
1688
- return (
1689
- <div>
1690
- {errors?.name && <span className="error">{errors.name}</span>}
1691
- {projects.map(p => <ProjectCard key={p.id} project={p} />)}
1692
- </div>
1693
- )
1694
- }
1419
+ // Require specific role
1420
+ const auth = yield* authorize((a) => a.user.role === 'admin')
1695
1421
 
1696
- export default ProjectsIndex
1422
+ // Check without failing
1423
+ const isLoggedIn = yield* isAuthenticated // boolean
1424
+ const user = yield* currentUser // AuthUser | null
1697
1425
  ```
1698
1426
 
1699
- ### Shared Props
1427
+ ---
1700
1428
 
1701
- All pages receive shared props set via middleware:
1429
+ ## Error Handling Examples
1430
+
1431
+ ### Throwing Errors
1702
1432
 
1703
1433
  ```typescript
1704
- // Server: shareAuthMiddleware() adds auth data
1705
- // Client: access via props
1706
- const Layout: HonertiaPage<Props> = ({ auth, children }) => {
1707
- return (
1708
- <div>
1709
- {auth?.user ? (
1710
- <span>Welcome, {auth.user.name}</span>
1711
- ) : (
1712
- <a href="/login">Login</a>
1713
- )}
1714
- {children}
1715
- </div>
1716
- )
1717
- }
1718
- ```
1434
+ import {
1435
+ notFound,
1436
+ forbidden,
1437
+ httpError,
1438
+ ValidationError,
1439
+ UnauthorizedError,
1440
+ } from 'honertia/effect'
1719
1441
 
1720
- ## TypeScript
1442
+ // 404 Not Found
1443
+ return yield* notFound('Project', projectId)
1721
1444
 
1722
- ### Typed Services via Module Augmentation
1445
+ // 403 Forbidden
1446
+ return yield* forbidden('You cannot edit this project')
1723
1447
 
1724
- 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 })
1725
1450
 
1726
- ```typescript
1727
- // src/types.ts
1728
- import type { D1Database } from '@cloudflare/workers-types'
1729
- import type { Database } from '~/db/db'
1730
- import type { Auth } from '~/lib/auth'
1731
- 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
+ }))
1732
1456
 
1733
- // Define bindings ONCE
1734
- export type Bindings = {
1735
- DB: D1Database
1736
- BETTER_AUTH_SECRET: string
1737
- ENVIRONMENT?: string
1738
- }
1457
+ // Manual unauthorized
1458
+ yield* Effect.fail(new UnauthorizedError({
1459
+ message: 'Session expired',
1460
+ redirectTo: '/login',
1461
+ }))
1462
+ ```
1739
1463
 
1740
- export type Variables = {
1741
- db: Database
1742
- auth: Auth
1743
- }
1464
+ ### Error Handler Setup
1744
1465
 
1745
- // Export for Hono
1746
- export type Env = {
1747
- Bindings: Bindings
1748
- Variables: Variables
1749
- }
1466
+ ```typescript
1467
+ import { registerErrorHandlers } from 'honertia'
1750
1468
 
1751
- // Module augmentation references the same types
1752
- declare module 'honertia/effect' {
1753
- interface HonertiaDatabaseType {
1754
- type: Database
1755
- schema: typeof schema
1756
- }
1469
+ registerErrorHandlers(app, {
1470
+ component: 'Error', // Error page component
1471
+ showDevErrors: true, // Show details in dev
1472
+ envKey: 'ENVIRONMENT',
1473
+ devValue: 'development',
1474
+ })
1475
+ ```
1757
1476
 
1758
- interface HonertiaAuthType {
1759
- type: Auth
1760
- }
1477
+ ### Error Page Component
1761
1478
 
1762
- interface HonertiaBindingsType {
1763
- 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
+ )
1764
1501
  }
1765
1502
  ```
1766
1503
 
1767
- Now use the `Env` type for Hono and get full type safety everywhere:
1504
+ ---
1505
+
1506
+ ## Response Helpers
1768
1507
 
1769
1508
  ```typescript
1770
- // src/index.ts
1771
- import type { Env } from './types'
1509
+ import { render, redirect, json, notFound, forbidden, httpError } from 'honertia/effect'
1772
1510
 
1773
- const app = new Hono<Env>()
1511
+ // Render page with props
1512
+ return yield* render('Projects/Index', { projects })
1774
1513
 
1775
- app.use('*', setupHonertia<Env>({
1776
- honertia: {
1777
- database: (c) => createDb(c.env.DB), // ✅ c.env.DB is typed
1778
- auth: (c) => createAuth({
1779
- db: c.var.db, // ✅ c.var.db is typed
1780
- secret: c.env.BETTER_AUTH_SECRET,
1781
- }),
1782
- // ...
1783
- },
1784
- }))
1785
- ```
1514
+ // Render with validation errors
1515
+ return yield* renderWithErrors('Projects/Create', {
1516
+ name: 'Name is required',
1517
+ })
1786
1518
 
1787
- ```typescript
1788
- // In your actions
1789
- import { DatabaseService, BindingsService } from 'honertia/effect'
1519
+ // Redirect (303 for POST, 302 otherwise)
1520
+ return yield* redirect('/projects')
1521
+ return yield* redirect('/login', 302)
1790
1522
 
1791
- const db = yield* DatabaseService // typed as Database
1792
- const { DB } = yield* BindingsService // typed as Bindings
1523
+ // JSON response
1524
+ return yield* json({ success: true })
1525
+ return yield* json({ error: 'Not found' }, 404)
1793
1526
 
1794
- const projects = yield* Effect.tryPromise(() =>
1795
- db.query.projects.findMany() // ✅ full type safety
1796
- )
1527
+ // Error responses
1528
+ return yield* notFound('Project')
1529
+ return yield* forbidden('Access denied')
1530
+ return yield* httpError(429, 'Rate limited')
1797
1531
  ```
1798
1532
 
1799
- One type definition, used everywhere—no duplication.
1533
+ ---
1800
1534
 
1801
- ## Architecture Notes
1535
+ ## Services Reference
1802
1536
 
1803
- ### 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` |
1804
1544
 
1805
- 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
1806
1546
 
1807
1547
  ```typescript
1808
- // This happens automatically in effectBridge middleware:
1809
- const layer = buildContextLayer(c) // Build layers from Hono context
1810
- const runtime = ManagedRuntime.make(layer) // New runtime per request
1811
- // ... handle request ...
1812
- 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
+ )
1813
1560
  ```
1814
1561
 
1815
- This approach provides full type safety - your handlers declare their service requirements, and the type system ensures they're provided.
1562
+ ---
1816
1563
 
1817
- ### Why Not Global Runtime?
1564
+ ## Environment
1818
1565
 
1819
- 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
+ ```
1820
1571
 
1821
- 1. Type-safe dependency injection
1822
- 2. Proper resource cleanup
1823
- 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
+ ```
1824
1577
 
1825
- 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
+ ---
1826
1579
 
1827
- ## Acknowledgements
1580
+ ## Testing
1828
1581
 
1829
- - Inertia.js by Jonathan Reinink and its contributors
1830
- - Laravel by Taylor Otwell and the Laravel community
1582
+ Actions generated with CLI include inline tests:
1831
1583
 
1832
- 🐐
1584
+ ```bash
1585
+ # Test single action
1586
+ bun test src/actions/projects/create.ts
1833
1587
 
1834
- ## Contributing
1588
+ # Test all actions in a resource
1589
+ bun test src/actions/projects/
1835
1590
 
1836
- Contributions are welcome! Please feel free to submit a Pull Request.
1591
+ # Run project checks
1592
+ honertia check --verbose
1593
+ ```
1837
1594
 
1838
- 1. Fork the repository
1839
- 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
1840
- 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
1841
- 4. Push to the branch (`git push origin feature/amazing-feature`)
1842
- 5. Open a Pull Request
1595
+ ---
1843
1596
 
1844
1597
  ## License
1845
1598