honertia 0.1.22 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1146 -1500
- package/dist/cli/check.d.ts +156 -0
- package/dist/cli/check.d.ts.map +1 -0
- package/dist/cli/check.js +539 -0
- package/dist/cli/db.d.ts +255 -0
- package/dist/cli/db.d.ts.map +1 -0
- package/dist/cli/db.js +532 -0
- package/dist/cli/feature.d.ts +132 -0
- package/dist/cli/feature.d.ts.map +1 -0
- package/dist/cli/feature.js +545 -0
- package/dist/cli/generate.d.ts +267 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +862 -0
- package/dist/cli/index.d.ts +110 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +260 -0
- package/dist/cli/inline-tests.d.ts +61 -0
- package/dist/cli/inline-tests.d.ts.map +1 -0
- package/dist/cli/inline-tests.js +138 -0
- package/dist/cli/openapi.d.ts +196 -0
- package/dist/cli/openapi.d.ts.map +1 -0
- package/dist/cli/openapi.js +419 -0
- package/dist/effect/bridge.d.ts +1 -0
- package/dist/effect/bridge.d.ts.map +1 -1
- package/dist/effect/bridge.js +40 -3
- package/dist/effect/handler.js +1 -1
- package/dist/effect/index.d.ts +6 -2
- package/dist/effect/index.d.ts.map +1 -1
- package/dist/effect/index.js +9 -1
- package/dist/effect/route-registry.d.ts +178 -0
- package/dist/effect/route-registry.d.ts.map +1 -0
- package/dist/effect/route-registry.js +250 -0
- package/dist/effect/routing.d.ts +65 -3
- package/dist/effect/routing.d.ts.map +1 -1
- package/dist/effect/routing.js +99 -8
- package/dist/effect/services.d.ts +10 -1
- package/dist/effect/services.d.ts.map +1 -1
- package/dist/effect/services.js +2 -0
- package/dist/effect/test-layers.d.ts +52 -0
- package/dist/effect/test-layers.d.ts.map +1 -0
- package/dist/effect/test-layers.js +129 -0
- package/dist/effect/testing.d.ts +213 -0
- package/dist/effect/testing.d.ts.map +1 -0
- package/dist/effect/testing.js +300 -0
- package/dist/effect/validated-services.d.ts +17 -0
- package/dist/effect/validated-services.d.ts.map +1 -0
- package/dist/effect/validated-services.js +13 -0
- package/dist/effect/validation.d.ts +10 -11
- package/dist/effect/validation.d.ts.map +1 -1
- package/dist/effect/validation.js +10 -0
- package/dist/setup.js +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -1,113 +1,361 @@
|
|
|
1
1
|
# Honertia
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://bundlephobia.com/package/honertia)
|
|
5
|
-
[](https://www.typescriptlang.org/)
|
|
6
|
-
[](https://opensource.org/licenses/MIT)
|
|
3
|
+
Inertia.js adapter for Hono with Effect.ts. Server-driven app with SPA behavior.
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
[](https://workers.cloudflare.com/)
|
|
10
|
-
[](https://effect.website/)
|
|
5
|
+
## CLI Commands
|
|
11
6
|
|
|
12
|
-
|
|
7
|
+
### Generate Action
|
|
13
8
|
|
|
14
|
-
|
|
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
|
-
|
|
106
|
+
# Only API routes
|
|
107
|
+
honertia generate:openapi --include /api
|
|
17
108
|
|
|
18
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
261
|
+
export const projectsRelations = relations(projects, ({ one }) => ({
|
|
262
|
+
user: one(users, { fields: [projects.userId], references: [users.id] }),
|
|
263
|
+
}))
|
|
264
|
+
```
|
|
29
265
|
|
|
30
|
-
|
|
266
|
+
### 3. src/db/db.ts (REQUIRED)
|
|
31
267
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 {
|
|
68
|
-
import
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
392
|
+
// Protected routes (require authentication)
|
|
140
393
|
effectRoutes(app)
|
|
141
394
|
.provide(RequireAuthLayer)
|
|
142
395
|
.group((route) => {
|
|
143
|
-
//
|
|
144
|
-
route.get('/', showDashboard)
|
|
145
|
-
|
|
146
|
-
route.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
route.delete('/:id', deleteProject) // DELETE example.com/projects/2
|
|
151
|
-
})
|
|
396
|
+
// Add your protected routes here
|
|
397
|
+
// route.get('/', showDashboard)
|
|
398
|
+
// route.prefix('/projects').group((route) => {
|
|
399
|
+
// route.get('/', listProjects)
|
|
400
|
+
// route.get('/{project}', showProject)
|
|
401
|
+
// route.post('/', createProject)
|
|
402
|
+
// })
|
|
152
403
|
})
|
|
153
|
-
}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
### Example Action
|
|
157
|
-
|
|
158
|
-
Here's the `listProjects` action referenced above:
|
|
159
|
-
|
|
160
|
-
```typescript
|
|
161
|
-
// src/actions/projects/list.ts
|
|
162
|
-
import { Effect } from 'effect'
|
|
163
|
-
import { eq } from 'drizzle-orm'
|
|
164
|
-
import { DatabaseService, AuthUserService, render, type AuthUser } from 'honertia/effect'
|
|
165
|
-
import { schema, type Database, type Project } from '../../db'
|
|
166
|
-
|
|
167
|
-
interface ProjectsIndexProps {
|
|
168
|
-
projects: Project[]
|
|
169
|
-
}
|
|
170
404
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
195
|
-
Vite + React layout is:
|
|
412
|
+
### 7. src/main.tsx (REQUIRED)
|
|
196
413
|
|
|
197
|
-
|
|
198
|
-
src/pages/Projects/Index.tsx
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
That means the folders mirror the component path, and `Index.tsx` is the file
|
|
202
|
-
that exports the page component. In the example below, `Link` comes from
|
|
203
|
-
`@inertiajs/react` because it performs Inertia client-side visits (preserving
|
|
204
|
-
page state and avoiding full reloads), whereas a plain `<a>` would do a full
|
|
205
|
-
navigation.
|
|
414
|
+
Client-side entry point.
|
|
206
415
|
|
|
207
416
|
```tsx
|
|
208
|
-
// src/
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
218
|
-
projects: Project[]
|
|
219
|
-
}
|
|
422
|
+
const pages = import.meta.glob('./pages/**/*.tsx')
|
|
220
423
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
###
|
|
277
|
-
|
|
278
|
-
Honertia reads these from `c.env` (Cloudflare Workers bindings):
|
|
438
|
+
### 8. wrangler.toml (REQUIRED for Cloudflare)
|
|
279
439
|
|
|
280
440
|
```toml
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
If you prefer `wrangler.jsonc`, the same binding looks like:
|
|
286
|
-
|
|
287
|
-
```jsonc
|
|
288
|
-
{
|
|
289
|
-
"vars": {
|
|
290
|
-
"ENVIRONMENT": "production"
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
Set secrets like `DATABASE_URL` and `BETTER_AUTH_SECRET` via Wrangler (not in source control):
|
|
296
|
-
|
|
297
|
-
```bash
|
|
298
|
-
wrangler secret put DATABASE_URL
|
|
299
|
-
wrangler secret put BETTER_AUTH_SECRET
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
### Client Setup (React + Inertia)
|
|
441
|
+
name = "my-app"
|
|
442
|
+
compatibility_date = "2024-01-01"
|
|
443
|
+
main = "src/index.ts"
|
|
303
444
|
|
|
304
|
-
|
|
305
|
-
|
|
445
|
+
[vars]
|
|
446
|
+
ENVIRONMENT = "development"
|
|
306
447
|
|
|
307
|
-
|
|
448
|
+
[[d1_databases]]
|
|
449
|
+
binding = "DB"
|
|
450
|
+
database_name = "my-app-db"
|
|
451
|
+
database_id = "your-database-id"
|
|
308
452
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
bun add -d @vitejs/plugin-react tailwindcss @tailwindcss/vite
|
|
453
|
+
[site]
|
|
454
|
+
bucket = "./dist"
|
|
312
455
|
```
|
|
313
456
|
|
|
314
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
482
|
+
### 10. tsconfig.json (REQUIRED)
|
|
341
483
|
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
505
|
+
---
|
|
357
506
|
|
|
358
|
-
|
|
359
|
-
// src/main.tsx
|
|
360
|
-
import './styles.css'
|
|
507
|
+
## Project Structure
|
|
361
508
|
|
|
362
|
-
|
|
363
|
-
|
|
509
|
+
```
|
|
510
|
+
src/
|
|
511
|
+
index.ts # App entry, setupHonertia() - REQUIRED
|
|
512
|
+
routes.ts # Route definitions - REQUIRED
|
|
513
|
+
types.ts # Type definitions - REQUIRED
|
|
514
|
+
main.tsx # Client entry - REQUIRED
|
|
515
|
+
styles.css # Global styles
|
|
516
|
+
db/
|
|
517
|
+
db.ts # Database factory - REQUIRED
|
|
518
|
+
schema.ts # Drizzle schema - REQUIRED
|
|
519
|
+
lib/
|
|
520
|
+
auth.ts # Auth config - REQUIRED
|
|
521
|
+
actions/
|
|
522
|
+
auth/
|
|
523
|
+
login.ts
|
|
524
|
+
register.ts
|
|
525
|
+
logout.ts
|
|
526
|
+
projects/
|
|
527
|
+
index.ts
|
|
528
|
+
show.ts
|
|
529
|
+
create.ts
|
|
530
|
+
pages/
|
|
531
|
+
Auth/
|
|
532
|
+
Login.tsx
|
|
533
|
+
Register.tsx
|
|
534
|
+
Projects/
|
|
535
|
+
Index.tsx
|
|
536
|
+
Show.tsx
|
|
537
|
+
Create.tsx
|
|
538
|
+
Error.tsx # Error page component
|
|
539
|
+
wrangler.toml # Cloudflare config - REQUIRED
|
|
540
|
+
vite.config.ts # Vite config - REQUIRED
|
|
541
|
+
tsconfig.json # TypeScript config - REQUIRED
|
|
542
|
+
```
|
|
364
543
|
|
|
365
|
-
|
|
544
|
+
---
|
|
366
545
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
546
|
+
## Auth Actions (REQUIRED)
|
|
547
|
+
|
|
548
|
+
These three actions are required for `effectAuthRoutes` to work.
|
|
549
|
+
|
|
550
|
+
### src/actions/auth/login.ts
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
import { betterAuthFormAction } from 'honertia/auth'
|
|
554
|
+
import { Schema as S } from 'effect'
|
|
555
|
+
import { email, requiredString } from 'honertia/effect'
|
|
556
|
+
|
|
557
|
+
const LoginSchema = S.Struct({
|
|
558
|
+
email: email,
|
|
559
|
+
password: requiredString,
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
export const loginUser = betterAuthFormAction({
|
|
563
|
+
schema: LoginSchema,
|
|
564
|
+
errorComponent: 'Auth/Login',
|
|
565
|
+
redirectTo: '/',
|
|
566
|
+
errorMapper: (error) => {
|
|
567
|
+
switch (error.code) {
|
|
568
|
+
case 'INVALID_EMAIL_OR_PASSWORD':
|
|
569
|
+
return { email: 'Invalid email or password' }
|
|
570
|
+
case 'USER_NOT_FOUND':
|
|
571
|
+
return { email: 'No account found with this email' }
|
|
572
|
+
default:
|
|
573
|
+
return { email: 'Login failed' }
|
|
372
574
|
}
|
|
373
|
-
return page()
|
|
374
|
-
},
|
|
375
|
-
setup({ el, App, props }) {
|
|
376
|
-
createRoot(el).render(<App {...props} />)
|
|
377
575
|
},
|
|
576
|
+
call: (auth, input, request) =>
|
|
577
|
+
auth.api.signInEmail({
|
|
578
|
+
body: { email: input.email, password: input.password },
|
|
579
|
+
request,
|
|
580
|
+
returnHeaders: true,
|
|
581
|
+
}),
|
|
378
582
|
})
|
|
379
583
|
```
|
|
380
584
|
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
592
|
+
const RegisterSchema = S.Struct({
|
|
593
|
+
name: requiredString,
|
|
594
|
+
email: email,
|
|
595
|
+
password: password({ min: 8, letters: true, numbers: true }),
|
|
596
|
+
})
|
|
393
597
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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 {
|
|
622
|
+
import { betterAuthLogoutAction } from 'honertia/auth'
|
|
410
623
|
|
|
411
|
-
|
|
412
|
-
|
|
624
|
+
export const logoutUser = betterAuthLogoutAction({
|
|
625
|
+
redirectTo: '/login',
|
|
626
|
+
})
|
|
413
627
|
```
|
|
414
628
|
|
|
415
|
-
|
|
629
|
+
### src/actions/auth/index.ts
|
|
416
630
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
637
|
+
---
|
|
425
638
|
|
|
426
|
-
|
|
639
|
+
## Minimum Page Components (REQUIRED)
|
|
427
640
|
|
|
428
|
-
|
|
641
|
+
### src/pages/Auth/Login.tsx
|
|
429
642
|
|
|
430
|
-
|
|
643
|
+
```tsx
|
|
644
|
+
import { useForm } from '@inertiajs/react'
|
|
431
645
|
|
|
432
|
-
|
|
646
|
+
export default function Login() {
|
|
647
|
+
const { data, setData, post, processing, errors } = useForm({
|
|
648
|
+
email: '',
|
|
649
|
+
password: '',
|
|
650
|
+
})
|
|
433
651
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
652
|
+
const submit = (e: React.FormEvent) => {
|
|
653
|
+
e.preventDefault()
|
|
654
|
+
post('/login')
|
|
655
|
+
}
|
|
437
656
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
685
|
+
### src/pages/Auth/Register.tsx
|
|
447
686
|
|
|
448
|
-
|
|
687
|
+
```tsx
|
|
688
|
+
import { useForm } from '@inertiajs/react'
|
|
449
689
|
|
|
450
|
-
|
|
690
|
+
export default function Register() {
|
|
691
|
+
const { data, setData, post, processing, errors } = useForm({
|
|
692
|
+
name: '',
|
|
693
|
+
email: '',
|
|
694
|
+
password: '',
|
|
695
|
+
})
|
|
451
696
|
|
|
452
|
-
|
|
697
|
+
const submit = (e: React.FormEvent) => {
|
|
698
|
+
e.preventDefault()
|
|
699
|
+
post('/register')
|
|
700
|
+
}
|
|
453
701
|
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
458
|
-
const auth = yield* authorize()
|
|
739
|
+
### src/pages/Error.tsx
|
|
459
740
|
|
|
460
|
-
|
|
461
|
-
|
|
741
|
+
```tsx
|
|
742
|
+
interface ErrorProps {
|
|
743
|
+
status: number
|
|
744
|
+
title: string
|
|
745
|
+
message: string
|
|
746
|
+
}
|
|
462
747
|
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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 {
|
|
495
|
-
import {
|
|
785
|
+
import { Effect } from 'effect'
|
|
786
|
+
import { action, render } from 'honertia/effect'
|
|
496
787
|
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
505
|
-
`validateRequest` returns a branded `Validated<T>` value that you can require for writes.
|
|
795
|
+
### GET with Authentication
|
|
506
796
|
|
|
507
|
-
|
|
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
|
-
|
|
803
|
+
export const listProjects = action(
|
|
804
|
+
Effect.gen(function* () {
|
|
805
|
+
const auth = yield* authorize()
|
|
806
|
+
const db = yield* DatabaseService
|
|
510
807
|
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
db.query.projects.findMany()
|
|
815
|
+
return yield* render('Projects/Index', { projects: userProjects })
|
|
816
|
+
})
|
|
517
817
|
)
|
|
518
818
|
```
|
|
519
819
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
Return responses from your action:
|
|
820
|
+
### GET with Route Model Binding
|
|
523
821
|
|
|
524
822
|
```typescript
|
|
525
|
-
import {
|
|
823
|
+
import { Effect } from 'effect'
|
|
824
|
+
import { action, authorize, bound, render } from 'honertia/effect'
|
|
526
825
|
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
831
|
+
return yield* render('Projects/Show', { project })
|
|
832
|
+
})
|
|
833
|
+
)
|
|
532
834
|
```
|
|
533
835
|
|
|
534
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
582
|
-
|
|
583
|
-
The order you yield services determines when they execute:
|
|
584
|
-
|
|
585
|
-
```typescript
|
|
586
|
-
// Authorization BEFORE validation (recommended for most actions)
|
|
587
|
-
// Don't waste cycles validating if user can't perform the action
|
|
588
|
-
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
589
|
-
const input = yield* validateRequest(schema)
|
|
590
|
-
|
|
591
|
-
// Validation BEFORE authorization (when you need to fetch the resource first)
|
|
592
|
-
// Validate the ID format, fetch from DB, then check ownership against the DB record
|
|
593
|
-
const { id } = yield* validateRequest(Schema.Struct({ id: Schema.UUID }))
|
|
594
|
-
const project = yield* db.findProjectById(id)
|
|
595
|
-
const auth = yield* authorize((a) => a.user.id === project.ownerId)
|
|
596
|
-
```
|
|
597
|
-
|
|
598
|
-
### Type Safety
|
|
599
|
-
|
|
600
|
-
Effect tracks all service requirements at the type level. Your action's type signature shows exactly what it needs:
|
|
878
|
+
### PUT with Route Binding and Validation
|
|
601
879
|
|
|
602
880
|
```typescript
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
627
|
-
export const showDashboard = action(
|
|
902
|
+
export const updateProject = action(
|
|
628
903
|
Effect.gen(function* () {
|
|
629
904
|
const auth = yield* authorize()
|
|
630
|
-
const
|
|
631
|
-
const stats = yield* fetchStats(db, auth)
|
|
632
|
-
return yield* render('Dashboard', { stats })
|
|
633
|
-
})
|
|
634
|
-
)
|
|
635
|
-
|
|
636
|
-
// API endpoint with just validation
|
|
637
|
-
export const searchProjects = action(
|
|
638
|
-
Effect.gen(function* () {
|
|
639
|
-
const { query } = yield* validateRequest(S.Struct({ query: S.String }))
|
|
640
|
-
const db = yield* DatabaseService
|
|
641
|
-
const results = yield* search(db, query)
|
|
642
|
-
return yield* json({ results })
|
|
643
|
-
})
|
|
644
|
-
)
|
|
645
|
-
```
|
|
646
|
-
|
|
647
|
-
### Helper Utilities
|
|
648
|
-
|
|
649
|
-
#### `dbMutation` - Safe Writes
|
|
650
|
-
|
|
651
|
-
Use `dbMutation` for writes that require validated or trusted input:
|
|
652
|
-
|
|
653
|
-
```typescript
|
|
654
|
-
import { DatabaseService, dbMutation, validateRequest, asTrusted, authorize } from 'honertia/effect'
|
|
655
|
-
|
|
656
|
-
const auth = yield* authorize()
|
|
657
|
-
const input = yield* validateRequest(CreateProjectSchema)
|
|
658
|
-
const values = asTrusted({ userId: auth.user.id, ...input })
|
|
659
|
-
const db = yield* DatabaseService
|
|
660
|
-
|
|
661
|
-
yield* dbMutation(db, async (db) => {
|
|
662
|
-
await db.insert(projects).values(values)
|
|
663
|
-
})
|
|
664
|
-
```
|
|
665
|
-
|
|
666
|
-
Use `asTrusted` for server-derived values like audit logs or usage meters, or when combining validated input with server-only fields.
|
|
667
|
-
`dbMutation` also wraps `execute`/`run` params to require validated or trusted inputs.
|
|
668
|
-
|
|
669
|
-
Why this design: we intentionally use nominal brands that do not survive spreads or merges. That means any modified or combined object must be explicitly re-branded with `asTrusted`, which makes trust boundaries visible and prevents accidental writes of unvalidated data.
|
|
670
|
-
|
|
671
|
-
```typescript
|
|
672
|
-
const input = yield* validateRequest(CreateProjectSchema)
|
|
673
|
-
|
|
674
|
-
// This fails typechecking because the brand is dropped by the merge
|
|
675
|
-
const merged = { ...input, userId: auth.user.id }
|
|
676
|
-
// await db.insert(projects).values(merged)
|
|
677
|
-
|
|
678
|
-
// Explicitly re-brand after adding server data
|
|
679
|
-
const values = asTrusted({ ...input, userId: auth.user.id })
|
|
680
|
-
await db.insert(projects).values(values)
|
|
681
|
-
```
|
|
905
|
+
const project = yield* bound('project')
|
|
682
906
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
Run multiple database operations in a transaction with automatic rollback on failure. The database instance is passed explicitly to keep the dependency visible and consistent with other service patterns:
|
|
907
|
+
// Authorization check
|
|
908
|
+
if (project.userId !== auth.user.id) {
|
|
909
|
+
return yield* forbidden('You cannot edit this project')
|
|
910
|
+
}
|
|
688
911
|
|
|
689
|
-
|
|
690
|
-
|
|
912
|
+
const input = yield* validateRequest(UpdateProjectSchema, {
|
|
913
|
+
errorComponent: 'Projects/Edit',
|
|
914
|
+
})
|
|
915
|
+
const db = yield* DatabaseService
|
|
691
916
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
917
|
+
yield* dbMutation(db, async (db) => {
|
|
918
|
+
await db.update(projects)
|
|
919
|
+
.set(asTrusted({
|
|
920
|
+
name: input.name,
|
|
921
|
+
description: input.description ?? null,
|
|
922
|
+
}))
|
|
923
|
+
.where(eq(projects.id, project.id))
|
|
924
|
+
})
|
|
695
925
|
|
|
696
|
-
yield*
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
// If any operation fails, the entire transaction rolls back
|
|
700
|
-
return { success: true }
|
|
701
|
-
})
|
|
926
|
+
return yield* redirect(`/projects/${project.id}`)
|
|
927
|
+
})
|
|
928
|
+
)
|
|
702
929
|
```
|
|
703
930
|
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
726
|
-
export const showDashboard = action(
|
|
947
|
+
export const destroyProject = action(
|
|
727
948
|
Effect.gen(function* () {
|
|
728
949
|
const auth = yield* authorize()
|
|
729
|
-
const
|
|
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
|
-
|
|
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.
|
|
753
|
-
...input,
|
|
754
|
-
userId: auth.user.id,
|
|
755
|
-
}))
|
|
959
|
+
await db.delete(projects).where(eq(projects.id, project.id))
|
|
756
960
|
})
|
|
757
961
|
|
|
758
962
|
return yield* redirect('/projects')
|
|
@@ -760,951 +964,365 @@ export const createProject = action(
|
|
|
760
964
|
)
|
|
761
965
|
```
|
|
762
966
|
|
|
763
|
-
###
|
|
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,
|
|
806
|
-
import {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
class RateLimiterService extends Context.Tag('app/RateLimiter')<
|
|
810
|
-
RateLimiterService,
|
|
811
|
-
{
|
|
812
|
-
check: (key: string, limit: number, windowSeconds: number) => Promise<boolean>
|
|
813
|
-
increment: (key: string) => Promise<void>
|
|
814
|
-
}
|
|
815
|
-
>() {}
|
|
970
|
+
import { Effect, Schema as S } from 'effect'
|
|
971
|
+
import { action, validateRequest, DatabaseService, json } from 'honertia/effect'
|
|
972
|
+
import { like } from 'drizzle-orm'
|
|
973
|
+
import { projects } from '~/db/schema'
|
|
816
974
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const count = parseInt(await kv.get(key) ?? '0')
|
|
821
|
-
return count < limit
|
|
822
|
-
},
|
|
823
|
-
increment: async (key: string) => {
|
|
824
|
-
const count = parseInt(await kv.get(key) ?? '0')
|
|
825
|
-
await kv.put(key, String(count + 1), { expirationTtl: 60 })
|
|
826
|
-
},
|
|
975
|
+
const SearchSchema = S.Struct({
|
|
976
|
+
q: S.String,
|
|
977
|
+
limit: S.optional(S.NumberFromString).pipe(S.withDefault(() => 10)),
|
|
827
978
|
})
|
|
828
979
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
RateLimiterService,
|
|
834
|
-
createRateLimiter(c.env.RATE_LIMIT_KV)
|
|
835
|
-
),
|
|
836
|
-
},
|
|
837
|
-
}))
|
|
838
|
-
|
|
839
|
-
// Use in your action - clean, testable, typed
|
|
840
|
-
const createProject = Effect.gen(function* () {
|
|
841
|
-
const rateLimiter = yield* RateLimiterService
|
|
842
|
-
const auth = yield* authorize()
|
|
843
|
-
|
|
844
|
-
const allowed = yield* Effect.tryPromise(() =>
|
|
845
|
-
rateLimiter.check(`create:${auth.user.id}`, 10, 60)
|
|
846
|
-
)
|
|
847
|
-
if (!allowed) {
|
|
848
|
-
return yield* httpError(429, 'Rate limit exceeded')
|
|
849
|
-
}
|
|
980
|
+
export const searchProjects = action(
|
|
981
|
+
Effect.gen(function* () {
|
|
982
|
+
const { q, limit } = yield* validateRequest(SearchSchema)
|
|
983
|
+
const db = yield* DatabaseService
|
|
850
984
|
|
|
851
|
-
|
|
985
|
+
const results = yield* Effect.tryPromise(() =>
|
|
986
|
+
db.query.projects.findMany({
|
|
987
|
+
where: like(projects.name, `%${q}%`),
|
|
988
|
+
limit,
|
|
989
|
+
})
|
|
990
|
+
)
|
|
852
991
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
992
|
+
return yield* json({ results, count: results.length })
|
|
993
|
+
})
|
|
994
|
+
)
|
|
856
995
|
```
|
|
857
996
|
|
|
858
|
-
|
|
997
|
+
### Action with Role Check
|
|
859
998
|
|
|
860
999
|
```typescript
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
effect: {
|
|
864
|
-
services: (c) => Layer.mergeAll(
|
|
865
|
-
Layer.succeed(RateLimiterService, createRateLimiter(c.env.RATE_LIMIT_KV)),
|
|
866
|
-
Layer.succeed(QueueService, { queue: c.env.MY_QUEUE }),
|
|
867
|
-
),
|
|
868
|
-
},
|
|
869
|
-
}))
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
**Summary:**
|
|
873
|
-
- **Simple binding access**: Use `BindingsService` (automatically provided, typed via module augmentation)
|
|
874
|
-
- **Complex services**: Use `effect.services` when you need initialization, testability, or abstraction
|
|
875
|
-
|
|
876
|
-
### Routing
|
|
877
|
-
|
|
878
|
-
Use `effectRoutes` for Laravel-style route definitions:
|
|
1000
|
+
import { Effect } from 'effect'
|
|
1001
|
+
import { action, authorize, DatabaseService, render } from 'honertia/effect'
|
|
879
1002
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
} from 'honertia'
|
|
1003
|
+
export const adminDashboard = action(
|
|
1004
|
+
Effect.gen(function* () {
|
|
1005
|
+
// authorize() with callback checks role
|
|
1006
|
+
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1007
|
+
const db = yield* DatabaseService
|
|
886
1008
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
.prefix('/dashboard')
|
|
891
|
-
.group((route) => {
|
|
892
|
-
route.get('/', showDashboard)
|
|
893
|
-
route.get('/settings', showSettings)
|
|
894
|
-
route.post('/settings', updateSettings)
|
|
895
|
-
})
|
|
1009
|
+
const stats = yield* Effect.tryPromise(() =>
|
|
1010
|
+
db.query.users.findMany({ limit: 100 })
|
|
1011
|
+
)
|
|
896
1012
|
|
|
897
|
-
|
|
898
|
-
effectRoutes(app)
|
|
899
|
-
.provide(RequireGuestLayer)
|
|
900
|
-
.group((route) => {
|
|
901
|
-
route.get('/login', showLogin)
|
|
902
|
-
route.get('/register', showRegister)
|
|
1013
|
+
return yield* render('Admin/Dashboard', { stats })
|
|
903
1014
|
})
|
|
904
|
-
|
|
905
|
-
// Public routes (no layer)
|
|
906
|
-
effectRoutes(app).group((route) => {
|
|
907
|
-
route.get('/about', showAbout)
|
|
908
|
-
route.get('/pricing', showPricing)
|
|
909
|
-
})
|
|
910
|
-
```
|
|
911
|
-
|
|
912
|
-
#### Route Parameter Validation
|
|
913
|
-
|
|
914
|
-
You can pass a `params` schema to validate route parameters before your handler runs. Invalid values automatically return a 404:
|
|
915
|
-
|
|
916
|
-
```typescript
|
|
917
|
-
import { Schema as S } from 'effect'
|
|
918
|
-
import { uuid } from 'honertia/effect'
|
|
919
|
-
|
|
920
|
-
effectRoutes(app).get(
|
|
921
|
-
'/projects/:id',
|
|
922
|
-
showProject,
|
|
923
|
-
{ params: S.Struct({ id: uuid }) }
|
|
924
1015
|
)
|
|
925
1016
|
```
|
|
926
1017
|
|
|
927
|
-
|
|
1018
|
+
### Action with Custom Error Handling
|
|
928
1019
|
|
|
929
1020
|
```typescript
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
#### Laravel-Style Route Model Binding
|
|
1021
|
+
import { Effect } from 'effect'
|
|
1022
|
+
import {
|
|
1023
|
+
action,
|
|
1024
|
+
authorize,
|
|
1025
|
+
DatabaseService,
|
|
1026
|
+
render,
|
|
1027
|
+
notFound,
|
|
1028
|
+
httpError,
|
|
1029
|
+
} from 'honertia/effect'
|
|
1030
|
+
import { eq } from 'drizzle-orm'
|
|
1031
|
+
import { projects } from '~/db/schema'
|
|
943
1032
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
```typescript
|
|
951
|
-
// src/types.d.ts
|
|
952
|
-
import type { Database } from '~/db/db'
|
|
953
|
-
import type { auth } from '~/lib/auth'
|
|
954
|
-
import * as schema from '~/db/schema'
|
|
955
|
-
|
|
956
|
-
declare module 'honertia/effect' {
|
|
957
|
-
interface HonertiaDatabaseType {
|
|
958
|
-
type: Database
|
|
959
|
-
schema: typeof schema // Add this for route model binding
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
interface HonertiaAuthType {
|
|
963
|
-
type: typeof auth
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
```
|
|
967
|
-
|
|
968
|
-
2. Pass your schema to `setupHonertia`:
|
|
969
|
-
|
|
970
|
-
```typescript
|
|
971
|
-
import * as schema from '~/db/schema'
|
|
972
|
-
|
|
973
|
-
app.use('*', setupHonertia({
|
|
974
|
-
honertia: {
|
|
975
|
-
version: '1.0.0',
|
|
976
|
-
render: createTemplate({ ... }),
|
|
977
|
-
database: (c) => createDb(c.env.DATABASE_URL),
|
|
978
|
-
schema, // Schema is shared with all effectRoutes
|
|
979
|
-
},
|
|
980
|
-
}))
|
|
981
|
-
```
|
|
982
|
-
|
|
983
|
-
**Basic Usage:**
|
|
984
|
-
|
|
985
|
-
```typescript
|
|
986
|
-
import { bound } from 'honertia/effect'
|
|
987
|
-
|
|
988
|
-
// Route: /projects/{project}
|
|
989
|
-
// Automatically queries: SELECT * FROM projects WHERE id = :project
|
|
990
|
-
|
|
991
|
-
effectRoutes(app).get('/projects/{project}', showProject)
|
|
992
|
-
|
|
993
|
-
const showProject = Effect.gen(function* () {
|
|
994
|
-
const project = yield* bound('project') // Already fetched, guaranteed to exist
|
|
995
|
-
return yield* render('Projects/Show', { project })
|
|
996
|
-
})
|
|
997
|
-
```
|
|
998
|
-
|
|
999
|
-
**Custom Column Binding:**
|
|
1000
|
-
|
|
1001
|
-
By default, bindings query the `id` column. Use `{param:column}` syntax to bind by a different column:
|
|
1002
|
-
|
|
1003
|
-
```typescript
|
|
1004
|
-
// Bind by slug instead of id
|
|
1005
|
-
effectRoutes(app).get('/projects/{project:slug}', showProject)
|
|
1006
|
-
// Queries: SELECT * FROM projects WHERE slug = :project
|
|
1007
|
-
```
|
|
1008
|
-
|
|
1009
|
-
**Nested Route Scoping:**
|
|
1010
|
-
|
|
1011
|
-
For nested routes, Honertia automatically scopes child models to their parents using Drizzle relations:
|
|
1012
|
-
|
|
1013
|
-
```typescript
|
|
1014
|
-
// Route: /users/{user}/posts/{post}
|
|
1015
|
-
effectRoutes(app).get('/users/{user}/posts/{post}', showUserPost)
|
|
1016
|
-
|
|
1017
|
-
// Queries:
|
|
1018
|
-
// 1. SELECT * FROM users WHERE id = :user
|
|
1019
|
-
// 2. SELECT * FROM posts WHERE id = :post AND userId = :user.id
|
|
1020
|
-
```
|
|
1021
|
-
|
|
1022
|
-
This uses your Drizzle relations to discover the foreign key:
|
|
1023
|
-
|
|
1024
|
-
```typescript
|
|
1025
|
-
// db/schema.ts
|
|
1026
|
-
export const postsRelations = relations(posts, ({ one }) => ({
|
|
1027
|
-
user: one(users, {
|
|
1028
|
-
fields: [posts.userId],
|
|
1029
|
-
references: [users.id],
|
|
1030
|
-
}),
|
|
1031
|
-
}))
|
|
1032
|
-
```
|
|
1033
|
-
|
|
1034
|
-
If no relation is found, the child is resolved without scoping (useful for unrelated resources in the same route).
|
|
1035
|
-
|
|
1036
|
-
**How Binding Works:**
|
|
1037
|
-
|
|
1038
|
-
1. `{project}` is converted to `:project` for Hono's router
|
|
1039
|
-
2. At request time, the param value is extracted
|
|
1040
|
-
3. The table name is derived by pluralizing the param (`project` → `projects`)
|
|
1041
|
-
4. A database query is executed against that table
|
|
1042
|
-
5. If not found, a 404 is returned before your handler runs
|
|
1043
|
-
6. If found, the model is available via `bound('project')`
|
|
1044
|
-
|
|
1045
|
-
**Combining with Param Validation:**
|
|
1046
|
-
|
|
1047
|
-
Route model binding and param validation work together. Validation runs first:
|
|
1048
|
-
|
|
1049
|
-
```typescript
|
|
1050
|
-
effectRoutes(app).get(
|
|
1051
|
-
'/projects/{project}',
|
|
1052
|
-
showProject,
|
|
1053
|
-
{ params: S.Struct({ project: uuid }) } // Validates UUID format first
|
|
1054
|
-
)
|
|
1055
|
-
```
|
|
1056
|
-
|
|
1057
|
-
Order of execution:
|
|
1058
|
-
1. Param validation (returns 404 if schema fails)
|
|
1059
|
-
2. Model binding (returns 404 if not found in database)
|
|
1060
|
-
3. Your handler (model guaranteed to exist)
|
|
1061
|
-
|
|
1062
|
-
**Mixed Notation:**
|
|
1063
|
-
|
|
1064
|
-
You can mix Laravel-style `{binding}` with Hono-style `:param` in the same route. Only `{binding}` params are resolved from the database:
|
|
1065
|
-
|
|
1066
|
-
```typescript
|
|
1067
|
-
// :version is a regular Hono param (not bound)
|
|
1068
|
-
// {project} is resolved from the database
|
|
1069
|
-
effectRoutes(app).get(
|
|
1070
|
-
'/api/:version/projects/{project}',
|
|
1071
|
-
showProject
|
|
1072
|
-
)
|
|
1073
|
-
|
|
1074
|
-
const showProject = Effect.gen(function* () {
|
|
1075
|
-
const request = yield* RequestService
|
|
1076
|
-
const version = request.param('version') // 'v1', 'v2', etc.
|
|
1077
|
-
const project = yield* bound('project') // Database model
|
|
1078
|
-
// ...
|
|
1079
|
-
})
|
|
1080
|
-
```
|
|
1081
|
-
|
|
1082
|
-
**Performance:**
|
|
1083
|
-
|
|
1084
|
-
Routes without `{bindings}` have zero overhead—binding resolution only runs when Laravel-style params are detected. The binding check is a simple regex test at route registration time.
|
|
1085
|
-
|
|
1086
|
-
## Validation
|
|
1087
|
-
|
|
1088
|
-
Honertia uses Effect Schema with Laravel-inspired validators:
|
|
1089
|
-
|
|
1090
|
-
```typescript
|
|
1091
|
-
import { Effect, Schema as S } from 'effect'
|
|
1092
|
-
import {
|
|
1093
|
-
validateRequest,
|
|
1094
|
-
requiredString,
|
|
1095
|
-
nullableString,
|
|
1096
|
-
email,
|
|
1097
|
-
password,
|
|
1098
|
-
redirect,
|
|
1099
|
-
} from 'honertia'
|
|
1100
|
-
|
|
1101
|
-
// Define schema
|
|
1102
|
-
const CreateProjectSchema = S.Struct({
|
|
1103
|
-
name: requiredString.pipe(
|
|
1104
|
-
S.minLength(3, { message: () => 'Name must be at least 3 characters' }),
|
|
1105
|
-
S.maxLength(100)
|
|
1106
|
-
),
|
|
1107
|
-
description: nullableString,
|
|
1108
|
-
})
|
|
1109
|
-
|
|
1110
|
-
// Use in handler
|
|
1111
|
-
export const createProject = Effect.gen(function* () {
|
|
1112
|
-
const input = yield* validateRequest(CreateProjectSchema, {
|
|
1113
|
-
errorComponent: 'Projects/Create', // Re-render with errors on validation failure
|
|
1114
|
-
})
|
|
1115
|
-
|
|
1116
|
-
// input is Validated<{ name: string, description: string | null }>
|
|
1117
|
-
yield* insertProject(input)
|
|
1118
|
-
|
|
1119
|
-
return yield* redirect('/projects')
|
|
1120
|
-
})
|
|
1121
|
-
```
|
|
1122
|
-
|
|
1123
|
-
### Validation Options
|
|
1124
|
-
|
|
1125
|
-
`validateRequest` accepts an options object with:
|
|
1126
|
-
|
|
1127
|
-
```typescript
|
|
1128
|
-
const input = yield* validateRequest(schema, {
|
|
1129
|
-
// Re-render this component with errors on validation failure
|
|
1130
|
-
// If not set, redirects back to the previous page
|
|
1131
|
-
errorComponent: 'Projects/Create',
|
|
1132
|
-
|
|
1133
|
-
// Override default error messages per field
|
|
1134
|
-
messages: {
|
|
1135
|
-
name: 'Please enter a project name',
|
|
1136
|
-
email: 'That email address is not valid',
|
|
1137
|
-
},
|
|
1138
|
-
|
|
1139
|
-
// Human-readable field names for the :attribute placeholder
|
|
1140
|
-
// Use with messages like 'The :attribute field is required'
|
|
1141
|
-
attributes: {
|
|
1142
|
-
name: 'project name',
|
|
1143
|
-
email: 'email address',
|
|
1144
|
-
},
|
|
1145
|
-
})
|
|
1146
|
-
```
|
|
1147
|
-
|
|
1148
|
-
**Example with `:attribute` placeholder:**
|
|
1149
|
-
|
|
1150
|
-
```typescript
|
|
1151
|
-
const schema = S.Struct({
|
|
1152
|
-
email: S.String.pipe(S.minLength(1, { message: () => 'The :attribute field is required' })),
|
|
1153
|
-
})
|
|
1154
|
-
|
|
1155
|
-
const input = yield* validateRequest(schema, {
|
|
1156
|
-
attributes: { email: 'email address' },
|
|
1157
|
-
errorComponent: 'Auth/Register',
|
|
1158
|
-
})
|
|
1159
|
-
// Error: "The email address field is required"
|
|
1160
|
-
```
|
|
1161
|
-
|
|
1162
|
-
### Available Validators
|
|
1163
|
-
|
|
1164
|
-
#### Strings
|
|
1165
|
-
```typescript
|
|
1166
|
-
import {
|
|
1167
|
-
requiredString, // Trimmed, non-empty string
|
|
1168
|
-
nullableString, // Converts empty to null
|
|
1169
|
-
required, // Custom message: required('Name is required')
|
|
1170
|
-
alpha, // Letters only
|
|
1171
|
-
alphaDash, // Letters, numbers, dashes, underscores
|
|
1172
|
-
alphaNum, // Letters and numbers only
|
|
1173
|
-
email, // Validated email
|
|
1174
|
-
url, // Validated URL
|
|
1175
|
-
uuid, // UUID format
|
|
1176
|
-
min, // min(5) - at least 5 chars
|
|
1177
|
-
max, // max(100) - at most 100 chars
|
|
1178
|
-
size, // size(10) - exactly 10 chars
|
|
1179
|
-
} from 'honertia'
|
|
1180
|
-
```
|
|
1181
|
-
|
|
1182
|
-
#### Numbers
|
|
1183
|
-
```typescript
|
|
1184
|
-
import {
|
|
1185
|
-
coercedNumber, // Coerce string to number
|
|
1186
|
-
positiveInt, // Positive integer
|
|
1187
|
-
nonNegativeInt, // 0 or greater
|
|
1188
|
-
between, // between(1, 100)
|
|
1189
|
-
gt, gte, lt, lte, // Comparisons
|
|
1190
|
-
} from 'honertia'
|
|
1191
|
-
```
|
|
1192
|
-
|
|
1193
|
-
#### Booleans & Dates
|
|
1194
|
-
```typescript
|
|
1195
|
-
import {
|
|
1196
|
-
coercedBoolean, // Coerce "true", "1", etc.
|
|
1197
|
-
checkbox, // HTML checkbox (defaults to false)
|
|
1198
|
-
accepted, // Must be truthy
|
|
1199
|
-
coercedDate, // Coerce to Date
|
|
1200
|
-
nullableDate, // Empty string -> null
|
|
1201
|
-
after, // after(new Date())
|
|
1202
|
-
before, // before('2025-01-01')
|
|
1203
|
-
} from 'honertia'
|
|
1204
|
-
```
|
|
1205
|
-
|
|
1206
|
-
#### Password
|
|
1207
|
-
```typescript
|
|
1208
|
-
import { password } from 'honertia'
|
|
1209
|
-
|
|
1210
|
-
const PasswordSchema = password({
|
|
1211
|
-
min: 8,
|
|
1212
|
-
letters: true,
|
|
1213
|
-
mixedCase: true,
|
|
1214
|
-
numbers: true,
|
|
1215
|
-
symbols: true,
|
|
1216
|
-
})
|
|
1217
|
-
```
|
|
1218
|
-
|
|
1219
|
-
## Response Helpers
|
|
1220
|
-
|
|
1221
|
-
```typescript
|
|
1222
|
-
import {
|
|
1223
|
-
render,
|
|
1224
|
-
renderWithErrors,
|
|
1225
|
-
redirect,
|
|
1226
|
-
json,
|
|
1227
|
-
notFound,
|
|
1228
|
-
forbidden,
|
|
1229
|
-
} from 'honertia'
|
|
1230
|
-
|
|
1231
|
-
// Render a page
|
|
1232
|
-
return yield* render('Projects/Show', { project })
|
|
1233
|
-
|
|
1234
|
-
// Render with validation errors
|
|
1235
|
-
return yield* renderWithErrors('Projects/Create', {
|
|
1236
|
-
name: 'Name is required',
|
|
1237
|
-
})
|
|
1238
|
-
|
|
1239
|
-
// Redirect (303 by default for POST)
|
|
1240
|
-
return yield* redirect('/projects')
|
|
1241
|
-
return yield* redirect('/login', 302)
|
|
1242
|
-
|
|
1243
|
-
// JSON response
|
|
1244
|
-
return yield* json({ success: true })
|
|
1245
|
-
return yield* json({ error: 'Not found' }, 404)
|
|
1246
|
-
|
|
1247
|
-
// Error responses
|
|
1248
|
-
return yield* notFound('Project')
|
|
1249
|
-
return yield* forbidden('You cannot edit this project')
|
|
1250
|
-
```
|
|
1251
|
-
|
|
1252
|
-
## Error Handling
|
|
1253
|
-
|
|
1254
|
-
Honertia provides typed errors that integrate with Effect's error channel. Each error type has specific handling behavior designed for Inertia-style applications.
|
|
1255
|
-
|
|
1256
|
-
### Built-in Error Types
|
|
1257
|
-
|
|
1258
|
-
| Error Type | HTTP Status | Handling Behavior |
|
|
1259
|
-
|------------|-------------|-------------------|
|
|
1260
|
-
| `ValidationError` | 422 / redirect | Re-renders form with field errors, or redirects back |
|
|
1261
|
-
| `UnauthorizedError` | 302/303 | Redirects to login page |
|
|
1262
|
-
| `NotFoundError` | 404 | Uses Hono's `notFound()` handler → renders via Honertia |
|
|
1263
|
-
| `ForbiddenError` | 403 | Returns JSON response (for API compatibility) |
|
|
1264
|
-
| `HttpError` | Custom | Returns JSON with custom status (developer-controlled) |
|
|
1265
|
-
| `RouteConfigurationError` | 500 | Throws to Hono's `onError` → renders error page |
|
|
1266
|
-
| Unexpected errors | 500 | Throws to Hono's `onError` → renders error page |
|
|
1267
|
-
|
|
1268
|
-
### Error Type Details
|
|
1269
|
-
|
|
1270
|
-
#### `ValidationError`
|
|
1271
|
-
|
|
1272
|
-
Thrown when request validation fails. Automatically re-renders the form with field-level errors.
|
|
1273
|
-
|
|
1274
|
-
```typescript
|
|
1275
|
-
import { ValidationError, validateRequest } from 'honertia/effect'
|
|
1276
|
-
|
|
1277
|
-
// Automatic: validateRequest throws ValidationError on failure
|
|
1278
|
-
const input = yield* validateRequest(schema, {
|
|
1279
|
-
errorComponent: 'Projects/Create', // Re-renders this component with errors
|
|
1280
|
-
})
|
|
1281
|
-
|
|
1282
|
-
// Manual: throw ValidationError directly
|
|
1283
|
-
yield* Effect.fail(new ValidationError({
|
|
1284
|
-
errors: { email: 'Invalid email format' },
|
|
1285
|
-
component: 'Auth/Register', // Optional: component to re-render
|
|
1286
|
-
}))
|
|
1287
|
-
```
|
|
1288
|
-
|
|
1289
|
-
**Behavior:**
|
|
1290
|
-
- If request prefers JSON (API calls): returns `{ errors: {...} }` with 422 status
|
|
1291
|
-
- If `component` is set: re-renders that component with errors in props
|
|
1292
|
-
- Otherwise: redirects back to referer with errors in session
|
|
1293
|
-
|
|
1294
|
-
#### `UnauthorizedError`
|
|
1295
|
-
|
|
1296
|
-
Thrown when authentication is required but the user is not logged in.
|
|
1297
|
-
|
|
1298
|
-
```typescript
|
|
1299
|
-
import { UnauthorizedError, authorize } from 'honertia/effect'
|
|
1300
|
-
|
|
1301
|
-
// Automatic: authorize() throws UnauthorizedError if no user
|
|
1302
|
-
const auth = yield* authorize()
|
|
1303
|
-
|
|
1304
|
-
// Manual: throw with custom redirect
|
|
1305
|
-
yield* Effect.fail(new UnauthorizedError({
|
|
1306
|
-
message: 'Please log in to continue',
|
|
1307
|
-
redirectTo: '/login', // Defaults to '/login'
|
|
1308
|
-
}))
|
|
1309
|
-
```
|
|
1310
|
-
|
|
1311
|
-
**Behavior:** Redirects to the specified URL (302 for regular requests, 303 for Inertia requests).
|
|
1312
|
-
|
|
1313
|
-
#### `NotFoundError`
|
|
1314
|
-
|
|
1315
|
-
Thrown when a requested resource doesn't exist.
|
|
1316
|
-
|
|
1317
|
-
```typescript
|
|
1318
|
-
import { NotFoundError, notFound } from 'honertia/effect'
|
|
1319
|
-
|
|
1320
|
-
// Helper function
|
|
1321
|
-
return yield* notFound('Project', projectId)
|
|
1322
|
-
|
|
1323
|
-
// Manual
|
|
1324
|
-
yield* Effect.fail(new NotFoundError({
|
|
1325
|
-
resource: 'Project',
|
|
1326
|
-
id: projectId,
|
|
1327
|
-
}))
|
|
1328
|
-
```
|
|
1329
|
-
|
|
1330
|
-
**Behavior:** Triggers Hono's `notFound()` handler. If you've set up `registerErrorHandlers()`, this renders your error component with status 404.
|
|
1331
|
-
|
|
1332
|
-
#### `ForbiddenError`
|
|
1333
|
-
|
|
1334
|
-
Thrown when the user is authenticated but not authorized to perform an action.
|
|
1335
|
-
|
|
1336
|
-
```typescript
|
|
1337
|
-
import { ForbiddenError, forbidden, authorize } from 'honertia/effect'
|
|
1338
|
-
|
|
1339
|
-
// Automatic: authorize() throws ForbiddenError if check fails
|
|
1340
|
-
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1341
|
-
|
|
1342
|
-
// Helper function
|
|
1343
|
-
return yield* forbidden('You cannot edit this project')
|
|
1344
|
-
|
|
1345
|
-
// Manual
|
|
1346
|
-
yield* Effect.fail(new ForbiddenError({
|
|
1347
|
-
message: 'Admin access required',
|
|
1348
|
-
}))
|
|
1349
|
-
```
|
|
1350
|
-
|
|
1351
|
-
**Behavior:** Returns JSON `{ message: "..." }` with 403 status. This is intentionally JSON for API compatibility.
|
|
1352
|
-
|
|
1353
|
-
#### `HttpError`
|
|
1354
|
-
|
|
1355
|
-
A generic error for custom HTTP responses. Use when you need precise control over the response.
|
|
1356
|
-
|
|
1357
|
-
```typescript
|
|
1358
|
-
import { HttpError, httpError } from 'honertia/effect'
|
|
1359
|
-
|
|
1360
|
-
// Helper function
|
|
1361
|
-
return yield* httpError(429, 'Rate limited', { retryAfter: 60 })
|
|
1362
|
-
|
|
1363
|
-
// Manual
|
|
1364
|
-
yield* Effect.fail(new HttpError({
|
|
1365
|
-
status: 429,
|
|
1366
|
-
message: 'Too many requests',
|
|
1367
|
-
body: { retryAfter: 60 }, // Optional additional data
|
|
1368
|
-
}))
|
|
1369
|
-
```
|
|
1370
|
-
|
|
1371
|
-
**Behavior:** Returns JSON `{ message: "...", ...body }` with the specified status code.
|
|
1372
|
-
|
|
1373
|
-
#### `RouteConfigurationError`
|
|
1374
|
-
|
|
1375
|
-
Thrown when there's a developer configuration error, such as using route model binding without providing a schema.
|
|
1376
|
-
|
|
1377
|
-
```typescript
|
|
1378
|
-
import { RouteConfigurationError } from 'honertia/effect'
|
|
1379
|
-
|
|
1380
|
-
// This error is thrown automatically when:
|
|
1381
|
-
// - You use bound('project') but didn't pass schema to effectRoutes()
|
|
1382
|
-
// - Other route configuration mistakes
|
|
1383
|
-
|
|
1384
|
-
// You typically don't throw this manually
|
|
1385
|
-
```
|
|
1386
|
-
|
|
1387
|
-
**Behavior:** Re-throws to Hono's `onError` handler, which renders your error component. The error message and hint are logged to the console for debugging.
|
|
1388
|
-
|
|
1389
|
-
### Setting Up Error Pages
|
|
1390
|
-
|
|
1391
|
-
To render errors via Honertia instead of returning plain text/JSON, use `registerErrorHandlers`:
|
|
1392
|
-
|
|
1393
|
-
```typescript
|
|
1394
|
-
import { registerErrorHandlers } from 'honertia'
|
|
1395
|
-
|
|
1396
|
-
// In your app setup
|
|
1397
|
-
registerErrorHandlers(app, {
|
|
1398
|
-
component: 'Error', // Your error page component
|
|
1399
|
-
showDevErrors: true, // Show detailed errors in development
|
|
1400
|
-
envKey: 'ENVIRONMENT', // Env var to check
|
|
1401
|
-
devValue: 'development', // Value that enables dev errors
|
|
1402
|
-
})
|
|
1403
|
-
```
|
|
1404
|
-
|
|
1405
|
-
Your `Error` component receives structured error props that vary by environment:
|
|
1406
|
-
|
|
1407
|
-
**In Development** (`ENVIRONMENT=development`):
|
|
1408
|
-
|
|
1409
|
-
```json
|
|
1410
|
-
{
|
|
1411
|
-
"status": 500,
|
|
1412
|
-
"code": "HON_CFG_100_DATABASE_NOT_CONFIGURED",
|
|
1413
|
-
"title": "Database Not Configured",
|
|
1414
|
-
"message": "DatabaseService is not configured. Add it to setupHonertia.",
|
|
1415
|
-
"hint": "Add database to setupHonertia config",
|
|
1416
|
-
"fixes": [{ "description": "Add database config", "confidence": "high" }],
|
|
1417
|
-
"source": { "file": "src/routes/projects.ts", "line": 42 },
|
|
1418
|
-
"docsUrl": "https://..."
|
|
1419
|
-
}
|
|
1420
|
-
```
|
|
1421
|
-
|
|
1422
|
-
**In Production** (`ENVIRONMENT=production` or unset):
|
|
1423
|
-
|
|
1424
|
-
```json
|
|
1425
|
-
{
|
|
1426
|
-
"status": 500,
|
|
1427
|
-
"code": "HON_CFG_100_DATABASE_NOT_CONFIGURED",
|
|
1428
|
-
"title": "Database Not Configured",
|
|
1429
|
-
"message": "An error occurred. Please try again later."
|
|
1430
|
-
}
|
|
1431
|
-
```
|
|
1432
|
-
|
|
1433
|
-
In production, sensitive details (stack traces, source locations, hints, fixes) are automatically hidden while the error code and title remain visible for debugging reference.
|
|
1434
|
-
|
|
1435
|
-
**Example React Error Component:**
|
|
1436
|
-
|
|
1437
|
-
```tsx
|
|
1438
|
-
// src/pages/Error.tsx
|
|
1439
|
-
interface ErrorProps {
|
|
1440
|
-
status: number
|
|
1441
|
-
code: string
|
|
1442
|
-
title: string
|
|
1443
|
-
message: string
|
|
1444
|
-
hint?: string
|
|
1445
|
-
fixes?: Array<{ description: string; confidence: string }>
|
|
1446
|
-
source?: { file: string; line: number }
|
|
1447
|
-
docsUrl?: string
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
export default function Error(props: ErrorProps) {
|
|
1451
|
-
const { status, title, message, hint, fixes, source, docsUrl } = props
|
|
1452
|
-
|
|
1453
|
-
return (
|
|
1454
|
-
<div className="error-page">
|
|
1455
|
-
<h1>{status}</h1>
|
|
1456
|
-
<h2>{title}</h2>
|
|
1457
|
-
<p>{message}</p>
|
|
1033
|
+
export const showProject = action(
|
|
1034
|
+
Effect.gen(function* () {
|
|
1035
|
+
const auth = yield* authorize()
|
|
1036
|
+
const db = yield* DatabaseService
|
|
1037
|
+
const request = yield* RequestService
|
|
1038
|
+
const projectId = request.param('id')
|
|
1458
1039
|
|
|
1459
|
-
|
|
1460
|
-
{
|
|
1040
|
+
const project = yield* Effect.tryPromise(() =>
|
|
1041
|
+
db.query.projects.findFirst({
|
|
1042
|
+
where: eq(projects.id, projectId),
|
|
1043
|
+
})
|
|
1044
|
+
)
|
|
1461
1045
|
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1046
|
+
if (!project) {
|
|
1047
|
+
return yield* notFound('Project', projectId)
|
|
1048
|
+
}
|
|
1465
1049
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
</div>
|
|
1470
|
-
))}
|
|
1050
|
+
if (project.userId !== auth.user.id) {
|
|
1051
|
+
return yield* httpError(403, 'Access denied')
|
|
1052
|
+
}
|
|
1471
1053
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
}
|
|
1054
|
+
return yield* render('Projects/Show', { project })
|
|
1055
|
+
})
|
|
1056
|
+
)
|
|
1476
1057
|
```
|
|
1477
1058
|
|
|
1478
|
-
|
|
1059
|
+
---
|
|
1060
|
+
|
|
1061
|
+
## Validation Examples
|
|
1479
1062
|
|
|
1480
|
-
|
|
1063
|
+
### String Validators
|
|
1481
1064
|
|
|
1482
1065
|
```typescript
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1066
|
+
import { Schema as S } from 'effect'
|
|
1067
|
+
import {
|
|
1068
|
+
requiredString, // Trimmed, non-empty
|
|
1069
|
+
nullableString, // Empty string -> null
|
|
1070
|
+
email, // Email format
|
|
1071
|
+
url, // URL format
|
|
1072
|
+
uuid, // UUID format
|
|
1073
|
+
alpha, // Letters only
|
|
1074
|
+
alphaDash, // Letters, numbers, dashes, underscores
|
|
1075
|
+
alphaNum, // Letters and numbers
|
|
1076
|
+
min, // min(5) - at least 5 chars
|
|
1077
|
+
max, // max(100) - at most 100 chars
|
|
1078
|
+
size, // size(10) - exactly 10 chars
|
|
1079
|
+
} from 'honertia/effect'
|
|
1080
|
+
|
|
1081
|
+
const UserSchema = S.Struct({
|
|
1082
|
+
name: requiredString,
|
|
1083
|
+
bio: nullableString,
|
|
1084
|
+
email: email,
|
|
1085
|
+
website: S.optional(url),
|
|
1086
|
+
username: alphaDash.pipe(min(3), max(20)),
|
|
1087
|
+
})
|
|
1487
1088
|
```
|
|
1488
1089
|
|
|
1489
|
-
|
|
1090
|
+
### Number Validators
|
|
1490
1091
|
|
|
1491
|
-
```
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1092
|
+
```typescript
|
|
1093
|
+
import { Schema as S } from 'effect'
|
|
1094
|
+
import {
|
|
1095
|
+
coercedNumber, // String -> number
|
|
1096
|
+
positiveInt, // > 0
|
|
1097
|
+
nonNegativeInt, // >= 0
|
|
1098
|
+
between, // between(1, 100)
|
|
1099
|
+
gt, // gt(0) - greater than
|
|
1100
|
+
gte, // gte(0) - greater than or equal
|
|
1101
|
+
lt, // lt(100)
|
|
1102
|
+
lte, // lte(100)
|
|
1103
|
+
} from 'honertia/effect'
|
|
1104
|
+
|
|
1105
|
+
const ProductSchema = S.Struct({
|
|
1106
|
+
price: coercedNumber.pipe(gte(0)),
|
|
1107
|
+
quantity: positiveInt,
|
|
1108
|
+
discount: S.optional(coercedNumber.pipe(between(0, 100))),
|
|
1109
|
+
})
|
|
1495
1110
|
```
|
|
1496
1111
|
|
|
1497
|
-
|
|
1112
|
+
### Boolean and Date Validators
|
|
1498
1113
|
|
|
1499
1114
|
```typescript
|
|
1500
|
-
|
|
1501
|
-
|
|
1115
|
+
import { Schema as S } from 'effect'
|
|
1116
|
+
import {
|
|
1117
|
+
coercedBoolean, // "true", "1", "on" -> true
|
|
1118
|
+
checkbox, // HTML checkbox (defaults to false)
|
|
1119
|
+
accepted, // Must be truthy (for terms acceptance)
|
|
1120
|
+
coercedDate, // String -> Date
|
|
1121
|
+
nullableDate, // Empty string -> null
|
|
1122
|
+
after, // after(new Date()) - must be in future
|
|
1123
|
+
before, // before('2025-12-31')
|
|
1124
|
+
} from 'honertia/effect'
|
|
1125
|
+
|
|
1126
|
+
const EventSchema = S.Struct({
|
|
1127
|
+
isPublic: checkbox,
|
|
1128
|
+
termsAccepted: accepted,
|
|
1129
|
+
startDate: coercedDate.pipe(after(new Date())),
|
|
1130
|
+
endDate: S.optional(nullableDate),
|
|
1131
|
+
})
|
|
1502
1132
|
```
|
|
1503
1133
|
|
|
1504
|
-
###
|
|
1134
|
+
### Password Validator
|
|
1505
1135
|
|
|
1506
|
-
|
|
1136
|
+
```typescript
|
|
1137
|
+
import { password } from 'honertia/effect'
|
|
1507
1138
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1139
|
+
const RegisterSchema = S.Struct({
|
|
1140
|
+
email: email,
|
|
1141
|
+
password: password({
|
|
1142
|
+
min: 8,
|
|
1143
|
+
letters: true,
|
|
1144
|
+
mixedCase: true,
|
|
1145
|
+
numbers: true,
|
|
1146
|
+
symbols: true,
|
|
1147
|
+
}),
|
|
1148
|
+
})
|
|
1149
|
+
```
|
|
1515
1150
|
|
|
1516
|
-
|
|
1151
|
+
### Full Form Example
|
|
1517
1152
|
|
|
1518
|
-
|
|
1153
|
+
```typescript
|
|
1154
|
+
import { Schema as S } from 'effect'
|
|
1155
|
+
import {
|
|
1156
|
+
requiredString,
|
|
1157
|
+
nullableString,
|
|
1158
|
+
email,
|
|
1159
|
+
coercedNumber,
|
|
1160
|
+
checkbox,
|
|
1161
|
+
coercedDate,
|
|
1162
|
+
between,
|
|
1163
|
+
} from 'honertia/effect'
|
|
1519
1164
|
|
|
1520
|
-
|
|
1165
|
+
const CreateEventSchema = S.Struct({
|
|
1166
|
+
title: requiredString.pipe(S.maxLength(200)),
|
|
1167
|
+
description: nullableString,
|
|
1168
|
+
organizerEmail: email,
|
|
1169
|
+
maxAttendees: coercedNumber.pipe(between(1, 10000)),
|
|
1170
|
+
isPublic: checkbox,
|
|
1171
|
+
startDate: coercedDate,
|
|
1172
|
+
endDate: S.optional(coercedDate),
|
|
1173
|
+
})
|
|
1521
1174
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1175
|
+
// In action
|
|
1176
|
+
const input = yield* validateRequest(CreateEventSchema, {
|
|
1177
|
+
errorComponent: 'Events/Create',
|
|
1178
|
+
messages: {
|
|
1179
|
+
title: 'Please enter an event title',
|
|
1180
|
+
organizerEmail: 'Please enter a valid email address',
|
|
1181
|
+
},
|
|
1182
|
+
attributes: {
|
|
1183
|
+
maxAttendees: 'maximum attendees',
|
|
1184
|
+
},
|
|
1528
1185
|
})
|
|
1529
1186
|
```
|
|
1530
1187
|
|
|
1531
|
-
|
|
1188
|
+
---
|
|
1532
1189
|
|
|
1533
|
-
|
|
1534
|
-
Effect Handler
|
|
1535
|
-
│
|
|
1536
|
-
▼
|
|
1537
|
-
┌─────────────────────────────────────────────────────────┐
|
|
1538
|
-
│ errorToResponse() │
|
|
1539
|
-
├─────────────────────────────────────────────────────────┤
|
|
1540
|
-
│ ValidationError → Re-render form / redirect back │
|
|
1541
|
-
│ UnauthorizedError → Redirect to login │
|
|
1542
|
-
│ NotFoundError → c.notFound() → Hono notFound handler │
|
|
1543
|
-
│ ForbiddenError → JSON 403 │
|
|
1544
|
-
│ HttpError → JSON with custom status │
|
|
1545
|
-
│ Other errors → throw → Hono onError handler │
|
|
1546
|
-
└─────────────────────────────────────────────────────────┘
|
|
1547
|
-
│
|
|
1548
|
-
▼ (for thrown errors)
|
|
1549
|
-
┌─────────────────────────────────────────────────────────┐
|
|
1550
|
-
│ Hono onError handler │
|
|
1551
|
-
│ (from registerErrorHandlers) │
|
|
1552
|
-
├─────────────────────────────────────────────────────────┤
|
|
1553
|
-
│ Renders error component via Honertia │
|
|
1554
|
-
│ Shows detailed message in dev, generic in prod │
|
|
1555
|
-
└─────────────────────────────────────────────────────────┘
|
|
1556
|
-
```
|
|
1190
|
+
## Route Model Binding Examples
|
|
1557
1191
|
|
|
1558
|
-
###
|
|
1192
|
+
### Basic Binding (by ID)
|
|
1559
1193
|
|
|
1560
1194
|
```typescript
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
UnauthorizedError,
|
|
1564
|
-
NotFoundError,
|
|
1565
|
-
ForbiddenError,
|
|
1566
|
-
HttpError,
|
|
1567
|
-
} from 'honertia/effect'
|
|
1195
|
+
// Route: /projects/{project}
|
|
1196
|
+
// Queries: SELECT * FROM projects WHERE id = :project
|
|
1568
1197
|
|
|
1569
|
-
|
|
1570
|
-
const input = yield* validateRequest(schema, {
|
|
1571
|
-
errorComponent: 'Projects/Create',
|
|
1572
|
-
})
|
|
1198
|
+
effectRoutes(app).get('/projects/{project}', showProject)
|
|
1573
1199
|
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1200
|
+
const showProject = action(
|
|
1201
|
+
Effect.gen(function* () {
|
|
1202
|
+
const project = yield* bound('project')
|
|
1203
|
+
return yield* render('Projects/Show', { project })
|
|
1204
|
+
})
|
|
1577
1205
|
)
|
|
1206
|
+
```
|
|
1578
1207
|
|
|
1579
|
-
|
|
1580
|
-
return yield* notFound('Project', projectId)
|
|
1581
|
-
}
|
|
1208
|
+
### Binding by Slug
|
|
1582
1209
|
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
```
|
|
1210
|
+
```typescript
|
|
1211
|
+
// Route: /projects/{project:slug}
|
|
1212
|
+
// Queries: SELECT * FROM projects WHERE slug = :project
|
|
1587
1213
|
|
|
1588
|
-
|
|
1214
|
+
effectRoutes(app).get('/projects/{project:slug}', showProject)
|
|
1215
|
+
```
|
|
1589
1216
|
|
|
1590
|
-
###
|
|
1217
|
+
### Nested Binding (Scoped)
|
|
1591
1218
|
|
|
1592
1219
|
```typescript
|
|
1593
|
-
|
|
1220
|
+
// Route: /users/{user}/posts/{post}
|
|
1221
|
+
// Queries:
|
|
1222
|
+
// 1. SELECT * FROM users WHERE id = :user
|
|
1223
|
+
// 2. SELECT * FROM posts WHERE id = :post AND userId = :user.id
|
|
1594
1224
|
|
|
1595
|
-
|
|
1596
|
-
effectRoutes(app)
|
|
1597
|
-
.provide(RequireAuthLayer)
|
|
1598
|
-
.get('/dashboard', showDashboard)
|
|
1225
|
+
effectRoutes(app).get('/users/{user}/posts/{post}', showUserPost)
|
|
1599
1226
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1227
|
+
const showUserPost = action(
|
|
1228
|
+
Effect.gen(function* () {
|
|
1229
|
+
const user = yield* bound('user')
|
|
1230
|
+
const post = yield* bound('post') // Already scoped to user
|
|
1231
|
+
return yield* render('Users/Posts/Show', { user, post })
|
|
1232
|
+
})
|
|
1233
|
+
)
|
|
1604
1234
|
```
|
|
1605
1235
|
|
|
1606
|
-
###
|
|
1236
|
+
### Mixed Notation
|
|
1607
1237
|
|
|
1608
1238
|
```typescript
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
requireGuest,
|
|
1612
|
-
isAuthenticated,
|
|
1613
|
-
currentUser,
|
|
1614
|
-
} from 'honertia'
|
|
1615
|
-
|
|
1616
|
-
// In a handler
|
|
1617
|
-
export const showProfile = Effect.gen(function* () {
|
|
1618
|
-
const user = yield* requireAuth('/login') // Redirect to /login if not auth'd
|
|
1619
|
-
return yield* render('Profile', { user: user.user })
|
|
1620
|
-
})
|
|
1239
|
+
// :version is a regular param, {project} is bound
|
|
1240
|
+
effectRoutes(app).get('/api/:version/projects/{project}', showProject)
|
|
1621
1241
|
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
const
|
|
1242
|
+
const showProject = action(
|
|
1243
|
+
Effect.gen(function* () {
|
|
1244
|
+
const request = yield* RequestService
|
|
1245
|
+
const version = request.param('version') // Regular param
|
|
1246
|
+
const project = yield* bound('project') // Database model
|
|
1247
|
+
return yield* json({ version, project })
|
|
1248
|
+
})
|
|
1249
|
+
)
|
|
1250
|
+
```
|
|
1251
|
+
|
|
1252
|
+
### With Param Validation
|
|
1253
|
+
|
|
1254
|
+
```typescript
|
|
1255
|
+
// Validate UUID format before database lookup
|
|
1256
|
+
effectRoutes(app).get(
|
|
1257
|
+
'/projects/{project}',
|
|
1258
|
+
showProject,
|
|
1259
|
+
{ params: S.Struct({ project: uuid }) }
|
|
1260
|
+
)
|
|
1625
1261
|
```
|
|
1626
1262
|
|
|
1627
|
-
|
|
1263
|
+
---
|
|
1264
|
+
|
|
1265
|
+
## Auth Examples
|
|
1266
|
+
|
|
1267
|
+
### Auth Routes Setup
|
|
1628
1268
|
|
|
1629
1269
|
```typescript
|
|
1630
1270
|
import { effectAuthRoutes } from 'honertia/auth'
|
|
1631
|
-
import { loginUser, registerUser, logoutUser, verify2FA, forgotPassword } from './actions/auth'
|
|
1632
1271
|
|
|
1633
1272
|
effectAuthRoutes(app, {
|
|
1634
|
-
// Page
|
|
1635
|
-
loginPath: '/login', // GET: show login page
|
|
1636
|
-
registerPath: '/register', // GET: show register page
|
|
1637
|
-
logoutPath: '/logout', // POST: logout and redirect
|
|
1638
|
-
apiPath: '/api/auth', // Better-auth API handler
|
|
1639
|
-
logoutRedirect: '/login',
|
|
1640
|
-
loginRedirect: '/',
|
|
1273
|
+
// Page components
|
|
1641
1274
|
loginComponent: 'Auth/Login',
|
|
1642
1275
|
registerComponent: 'Auth/Register',
|
|
1643
1276
|
|
|
1644
|
-
// Form actions
|
|
1645
|
-
loginAction: loginUser,
|
|
1646
|
-
registerAction: registerUser,
|
|
1647
|
-
logoutAction: logoutUser,
|
|
1277
|
+
// Form actions
|
|
1278
|
+
loginAction: loginUser,
|
|
1279
|
+
registerAction: registerUser,
|
|
1280
|
+
logoutAction: logoutUser,
|
|
1281
|
+
|
|
1282
|
+
// Paths (defaults shown)
|
|
1283
|
+
loginPath: '/login',
|
|
1284
|
+
registerPath: '/register',
|
|
1285
|
+
logoutPath: '/logout',
|
|
1286
|
+
apiPath: '/api/auth',
|
|
1287
|
+
|
|
1288
|
+
// Redirects
|
|
1289
|
+
loginRedirect: '/',
|
|
1290
|
+
logoutRedirect: '/login',
|
|
1648
1291
|
|
|
1649
|
-
// Extended
|
|
1292
|
+
// Extended flows
|
|
1650
1293
|
guestActions: {
|
|
1651
1294
|
'/login/2fa': verify2FA,
|
|
1652
1295
|
'/forgot-password': forgotPassword,
|
|
1653
1296
|
},
|
|
1654
|
-
})
|
|
1655
|
-
```
|
|
1656
|
-
|
|
1657
|
-
All `loginAction`, `registerAction`, and `guestActions` are automatically wrapped with
|
|
1658
|
-
`RequireGuestLayer`, so authenticated users will be redirected. The `logoutAction` is
|
|
1659
|
-
not wrapped (logout should work regardless of auth state).
|
|
1660
|
-
|
|
1661
|
-
To enable CORS for the auth API handler (`/api/auth/*`), pass a `cors` config.
|
|
1662
|
-
By default, no CORS headers are added (recommended when your UI and API share the same origin).
|
|
1663
|
-
Use this when your frontend is on a different origin (local dev, separate domain, mobile app, etc.).
|
|
1664
1297
|
|
|
1665
|
-
|
|
1666
|
-
effectAuthRoutes(app, {
|
|
1667
|
-
apiPath: '/api/auth',
|
|
1298
|
+
// CORS for API (if frontend on different origin)
|
|
1668
1299
|
cors: {
|
|
1669
|
-
origin: ['http://localhost:5173'
|
|
1300
|
+
origin: ['http://localhost:5173'],
|
|
1670
1301
|
credentials: true,
|
|
1671
1302
|
},
|
|
1672
1303
|
})
|
|
1673
1304
|
```
|
|
1674
1305
|
|
|
1675
|
-
|
|
1676
|
-
Always keep the `origin` list tight; avoid `'*'` for auth endpoints, especially with `credentials: true`.
|
|
1677
|
-
|
|
1678
|
-
### Better-auth Form Actions
|
|
1679
|
-
|
|
1680
|
-
Honertia provides `betterAuthFormAction` to handle the common pattern of form-based
|
|
1681
|
-
authentication: validate input, call better-auth, map errors to field-level messages,
|
|
1682
|
-
and redirect on success. This bridges better-auth's JSON responses with Inertia's
|
|
1683
|
-
form handling conventions.
|
|
1306
|
+
### Login Action
|
|
1684
1307
|
|
|
1685
1308
|
```typescript
|
|
1686
|
-
// src/actions/auth/login.ts
|
|
1687
1309
|
import { betterAuthFormAction } from 'honertia/auth'
|
|
1688
1310
|
import { Schema as S } from 'effect'
|
|
1689
|
-
import {
|
|
1690
|
-
import type { Auth } from './lib/auth' // your better-auth instance type
|
|
1311
|
+
import { email, requiredString } from 'honertia/effect'
|
|
1691
1312
|
|
|
1692
1313
|
const LoginSchema = S.Struct({
|
|
1693
|
-
email,
|
|
1314
|
+
email: email,
|
|
1694
1315
|
password: requiredString,
|
|
1695
1316
|
})
|
|
1696
1317
|
|
|
1697
|
-
|
|
1698
|
-
const mapLoginError = (error: { code?: string; message?: string }) => {
|
|
1318
|
+
const mapLoginError = (error: { code?: string }) => {
|
|
1699
1319
|
switch (error.code) {
|
|
1700
1320
|
case 'INVALID_EMAIL_OR_PASSWORD':
|
|
1701
1321
|
return { email: 'Invalid email or password' }
|
|
1702
1322
|
case 'USER_NOT_FOUND':
|
|
1703
1323
|
return { email: 'No account found with this email' }
|
|
1704
|
-
case 'INVALID_PASSWORD':
|
|
1705
|
-
return { password: 'Incorrect password' }
|
|
1706
1324
|
default:
|
|
1707
|
-
return { email:
|
|
1325
|
+
return { email: 'Login failed' }
|
|
1708
1326
|
}
|
|
1709
1327
|
}
|
|
1710
1328
|
|
|
@@ -1713,10 +1331,7 @@ export const loginUser = betterAuthFormAction({
|
|
|
1713
1331
|
errorComponent: 'Auth/Login',
|
|
1714
1332
|
redirectTo: '/',
|
|
1715
1333
|
errorMapper: mapLoginError,
|
|
1716
|
-
|
|
1717
|
-
// `input` is the validated form data
|
|
1718
|
-
// `request` is the original Request (needed for session cookies)
|
|
1719
|
-
call: (auth: Auth, input, request) =>
|
|
1334
|
+
call: (auth, input, request) =>
|
|
1720
1335
|
auth.api.signInEmail({
|
|
1721
1336
|
body: { email: input.email, password: input.password },
|
|
1722
1337
|
request,
|
|
@@ -1725,27 +1340,25 @@ export const loginUser = betterAuthFormAction({
|
|
|
1725
1340
|
})
|
|
1726
1341
|
```
|
|
1727
1342
|
|
|
1343
|
+
### Register Action
|
|
1344
|
+
|
|
1728
1345
|
```typescript
|
|
1729
|
-
// src/actions/auth/register.ts
|
|
1730
1346
|
import { betterAuthFormAction } from 'honertia/auth'
|
|
1731
1347
|
import { Schema as S } from 'effect'
|
|
1732
|
-
import {
|
|
1733
|
-
import type { Auth } from './lib/auth'
|
|
1348
|
+
import { email, requiredString, password } from 'honertia/effect'
|
|
1734
1349
|
|
|
1735
1350
|
const RegisterSchema = S.Struct({
|
|
1736
1351
|
name: requiredString,
|
|
1737
|
-
email,
|
|
1352
|
+
email: email,
|
|
1738
1353
|
password: password({ min: 8, letters: true, numbers: true }),
|
|
1739
1354
|
})
|
|
1740
1355
|
|
|
1741
|
-
const mapRegisterError = (error: { code?: string
|
|
1356
|
+
const mapRegisterError = (error: { code?: string }) => {
|
|
1742
1357
|
switch (error.code) {
|
|
1743
1358
|
case 'USER_ALREADY_EXISTS':
|
|
1744
1359
|
return { email: 'An account with this email already exists' }
|
|
1745
|
-
case 'PASSWORD_TOO_SHORT':
|
|
1746
|
-
return { password: 'Password must be at least 8 characters' }
|
|
1747
1360
|
default:
|
|
1748
|
-
return { email:
|
|
1361
|
+
return { email: 'Registration failed' }
|
|
1749
1362
|
}
|
|
1750
1363
|
}
|
|
1751
1364
|
|
|
@@ -1754,7 +1367,7 @@ export const registerUser = betterAuthFormAction({
|
|
|
1754
1367
|
errorComponent: 'Auth/Register',
|
|
1755
1368
|
redirectTo: '/',
|
|
1756
1369
|
errorMapper: mapRegisterError,
|
|
1757
|
-
call: (auth
|
|
1370
|
+
call: (auth, input, request) =>
|
|
1758
1371
|
auth.api.signUpEmail({
|
|
1759
1372
|
body: { name: input.name, email: input.email, password: input.password },
|
|
1760
1373
|
request,
|
|
@@ -1763,10 +1376,9 @@ export const registerUser = betterAuthFormAction({
|
|
|
1763
1376
|
})
|
|
1764
1377
|
```
|
|
1765
1378
|
|
|
1766
|
-
|
|
1379
|
+
### Logout Action
|
|
1767
1380
|
|
|
1768
1381
|
```typescript
|
|
1769
|
-
// src/actions/auth/logout.ts
|
|
1770
1382
|
import { betterAuthLogoutAction } from 'honertia/auth'
|
|
1771
1383
|
|
|
1772
1384
|
export const logoutUser = betterAuthLogoutAction({
|
|
@@ -1774,179 +1386,213 @@ export const logoutUser = betterAuthLogoutAction({
|
|
|
1774
1386
|
})
|
|
1775
1387
|
```
|
|
1776
1388
|
|
|
1777
|
-
|
|
1389
|
+
### Auth Layers
|
|
1778
1390
|
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1391
|
+
```typescript
|
|
1392
|
+
import { RequireAuthLayer, RequireGuestLayer } from 'honertia/auth'
|
|
1393
|
+
|
|
1394
|
+
// Require logged-in user (redirects to /login if not)
|
|
1395
|
+
effectRoutes(app)
|
|
1396
|
+
.provide(RequireAuthLayer)
|
|
1397
|
+
.group((route) => {
|
|
1398
|
+
route.get('/dashboard', showDashboard)
|
|
1399
|
+
route.get('/settings', showSettings)
|
|
1400
|
+
})
|
|
1782
1401
|
|
|
1783
|
-
|
|
1402
|
+
// Require guest (redirects to / if logged in)
|
|
1403
|
+
effectRoutes(app)
|
|
1404
|
+
.provide(RequireGuestLayer)
|
|
1405
|
+
.group((route) => {
|
|
1406
|
+
route.get('/login', showLogin)
|
|
1407
|
+
route.get('/register', showRegister)
|
|
1408
|
+
})
|
|
1409
|
+
```
|
|
1784
1410
|
|
|
1785
|
-
###
|
|
1411
|
+
### Manual Auth Check in Action
|
|
1786
1412
|
|
|
1787
1413
|
```typescript
|
|
1788
|
-
import
|
|
1414
|
+
import { authorize, isAuthenticated, currentUser } from 'honertia/effect'
|
|
1789
1415
|
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
}
|
|
1416
|
+
// Require auth (fails if not logged in)
|
|
1417
|
+
const auth = yield* authorize()
|
|
1793
1418
|
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
<div>
|
|
1797
|
-
{errors?.name && <span className="error">{errors.name}</span>}
|
|
1798
|
-
{projects.map(p => <ProjectCard key={p.id} project={p} />)}
|
|
1799
|
-
</div>
|
|
1800
|
-
)
|
|
1801
|
-
}
|
|
1419
|
+
// Require specific role
|
|
1420
|
+
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1802
1421
|
|
|
1803
|
-
|
|
1422
|
+
// Check without failing
|
|
1423
|
+
const isLoggedIn = yield* isAuthenticated // boolean
|
|
1424
|
+
const user = yield* currentUser // AuthUser | null
|
|
1804
1425
|
```
|
|
1805
1426
|
|
|
1806
|
-
|
|
1427
|
+
---
|
|
1428
|
+
|
|
1429
|
+
## Error Handling Examples
|
|
1807
1430
|
|
|
1808
|
-
|
|
1431
|
+
### Throwing Errors
|
|
1809
1432
|
|
|
1810
1433
|
```typescript
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
) : (
|
|
1819
|
-
<a href="/login">Login</a>
|
|
1820
|
-
)}
|
|
1821
|
-
{children}
|
|
1822
|
-
</div>
|
|
1823
|
-
)
|
|
1824
|
-
}
|
|
1825
|
-
```
|
|
1434
|
+
import {
|
|
1435
|
+
notFound,
|
|
1436
|
+
forbidden,
|
|
1437
|
+
httpError,
|
|
1438
|
+
ValidationError,
|
|
1439
|
+
UnauthorizedError,
|
|
1440
|
+
} from 'honertia/effect'
|
|
1826
1441
|
|
|
1827
|
-
|
|
1442
|
+
// 404 Not Found
|
|
1443
|
+
return yield* notFound('Project', projectId)
|
|
1828
1444
|
|
|
1829
|
-
|
|
1445
|
+
// 403 Forbidden
|
|
1446
|
+
return yield* forbidden('You cannot edit this project')
|
|
1830
1447
|
|
|
1831
|
-
|
|
1448
|
+
// Custom HTTP error
|
|
1449
|
+
return yield* httpError(429, 'Rate limit exceeded', { retryAfter: 60 })
|
|
1832
1450
|
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
import * as schema from '~/db/schema'
|
|
1451
|
+
// Manual validation error
|
|
1452
|
+
yield* Effect.fail(new ValidationError({
|
|
1453
|
+
errors: { email: 'This email is already taken' },
|
|
1454
|
+
component: 'Auth/Register',
|
|
1455
|
+
}))
|
|
1839
1456
|
|
|
1840
|
-
//
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1457
|
+
// Manual unauthorized
|
|
1458
|
+
yield* Effect.fail(new UnauthorizedError({
|
|
1459
|
+
message: 'Session expired',
|
|
1460
|
+
redirectTo: '/login',
|
|
1461
|
+
}))
|
|
1462
|
+
```
|
|
1846
1463
|
|
|
1847
|
-
|
|
1848
|
-
db: Database
|
|
1849
|
-
auth: Auth
|
|
1850
|
-
}
|
|
1464
|
+
### Error Handler Setup
|
|
1851
1465
|
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
Bindings: Bindings
|
|
1855
|
-
Variables: Variables
|
|
1856
|
-
}
|
|
1466
|
+
```typescript
|
|
1467
|
+
import { registerErrorHandlers } from 'honertia'
|
|
1857
1468
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1469
|
+
registerErrorHandlers(app, {
|
|
1470
|
+
component: 'Error', // Error page component
|
|
1471
|
+
showDevErrors: true, // Show details in dev
|
|
1472
|
+
envKey: 'ENVIRONMENT',
|
|
1473
|
+
devValue: 'development',
|
|
1474
|
+
})
|
|
1475
|
+
```
|
|
1864
1476
|
|
|
1865
|
-
|
|
1866
|
-
type: Auth
|
|
1867
|
-
}
|
|
1477
|
+
### Error Page Component
|
|
1868
1478
|
|
|
1869
|
-
|
|
1870
|
-
|
|
1479
|
+
```tsx
|
|
1480
|
+
// src/pages/Error.tsx
|
|
1481
|
+
interface ErrorProps {
|
|
1482
|
+
status: number
|
|
1483
|
+
code: string
|
|
1484
|
+
title: string
|
|
1485
|
+
message: string
|
|
1486
|
+
hint?: string // Only in dev
|
|
1487
|
+
fixes?: Array<{ description: string }> // Only in dev
|
|
1488
|
+
source?: { file: string; line: number } // Only in dev
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
export default function Error({ status, title, message, hint, fixes }: ErrorProps) {
|
|
1492
|
+
return (
|
|
1493
|
+
<div className="error-page">
|
|
1494
|
+
<h1>{status}</h1>
|
|
1495
|
+
<h2>{title}</h2>
|
|
1496
|
+
<p>{message}</p>
|
|
1497
|
+
{hint && <p className="hint">{hint}</p>}
|
|
1498
|
+
{fixes?.map((fix, i) => <div key={i}>{fix.description}</div>)}
|
|
1499
|
+
</div>
|
|
1500
|
+
)
|
|
1871
1501
|
}
|
|
1872
1502
|
```
|
|
1873
1503
|
|
|
1874
|
-
|
|
1504
|
+
---
|
|
1505
|
+
|
|
1506
|
+
## Response Helpers
|
|
1875
1507
|
|
|
1876
1508
|
```typescript
|
|
1877
|
-
|
|
1878
|
-
import type { Env } from './types'
|
|
1509
|
+
import { render, redirect, json, notFound, forbidden, httpError } from 'honertia/effect'
|
|
1879
1510
|
|
|
1880
|
-
|
|
1511
|
+
// Render page with props
|
|
1512
|
+
return yield* render('Projects/Index', { projects })
|
|
1881
1513
|
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
db: c.var.db, // ✅ c.var.db is typed
|
|
1887
|
-
secret: c.env.BETTER_AUTH_SECRET,
|
|
1888
|
-
}),
|
|
1889
|
-
// ...
|
|
1890
|
-
},
|
|
1891
|
-
}))
|
|
1892
|
-
```
|
|
1514
|
+
// Render with validation errors
|
|
1515
|
+
return yield* renderWithErrors('Projects/Create', {
|
|
1516
|
+
name: 'Name is required',
|
|
1517
|
+
})
|
|
1893
1518
|
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1519
|
+
// Redirect (303 for POST, 302 otherwise)
|
|
1520
|
+
return yield* redirect('/projects')
|
|
1521
|
+
return yield* redirect('/login', 302)
|
|
1897
1522
|
|
|
1898
|
-
|
|
1899
|
-
|
|
1523
|
+
// JSON response
|
|
1524
|
+
return yield* json({ success: true })
|
|
1525
|
+
return yield* json({ error: 'Not found' }, 404)
|
|
1900
1526
|
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
)
|
|
1527
|
+
// Error responses
|
|
1528
|
+
return yield* notFound('Project')
|
|
1529
|
+
return yield* forbidden('Access denied')
|
|
1530
|
+
return yield* httpError(429, 'Rate limited')
|
|
1904
1531
|
```
|
|
1905
1532
|
|
|
1906
|
-
|
|
1533
|
+
---
|
|
1907
1534
|
|
|
1908
|
-
##
|
|
1535
|
+
## Services Reference
|
|
1909
1536
|
|
|
1910
|
-
|
|
1537
|
+
| Service | Description | Usage |
|
|
1538
|
+
|---------|-------------|-------|
|
|
1539
|
+
| `DatabaseService` | Drizzle database client | `const db = yield* DatabaseService` |
|
|
1540
|
+
| `AuthService` | Better-auth instance | `const auth = yield* AuthService` |
|
|
1541
|
+
| `AuthUserService` | Current user session | `const user = yield* AuthUserService` |
|
|
1542
|
+
| `BindingsService` | Cloudflare bindings | `const { KV } = yield* BindingsService` |
|
|
1543
|
+
| `RequestService` | Request context | `const req = yield* RequestService` |
|
|
1911
1544
|
|
|
1912
|
-
|
|
1545
|
+
### Using BindingsService
|
|
1913
1546
|
|
|
1914
1547
|
```typescript
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
const
|
|
1918
|
-
|
|
1919
|
-
|
|
1548
|
+
import { BindingsService } from 'honertia/effect'
|
|
1549
|
+
|
|
1550
|
+
const handler = action(
|
|
1551
|
+
Effect.gen(function* () {
|
|
1552
|
+
const { KV, R2, QUEUE } = yield* BindingsService
|
|
1553
|
+
|
|
1554
|
+
const cached = yield* Effect.tryPromise(() => KV.get('key'))
|
|
1555
|
+
yield* Effect.tryPromise(() => QUEUE.send({ type: 'event' }))
|
|
1556
|
+
|
|
1557
|
+
return yield* json({ cached })
|
|
1558
|
+
})
|
|
1559
|
+
)
|
|
1920
1560
|
```
|
|
1921
1561
|
|
|
1922
|
-
|
|
1562
|
+
---
|
|
1923
1563
|
|
|
1924
|
-
|
|
1564
|
+
## Environment
|
|
1925
1565
|
|
|
1926
|
-
|
|
1566
|
+
```toml
|
|
1567
|
+
# wrangler.toml
|
|
1568
|
+
[vars]
|
|
1569
|
+
ENVIRONMENT = "production"
|
|
1570
|
+
```
|
|
1927
1571
|
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1572
|
+
```bash
|
|
1573
|
+
# Secrets (not in source control)
|
|
1574
|
+
wrangler secret put DATABASE_URL
|
|
1575
|
+
wrangler secret put BETTER_AUTH_SECRET
|
|
1576
|
+
```
|
|
1931
1577
|
|
|
1932
|
-
|
|
1578
|
+
---
|
|
1933
1579
|
|
|
1934
|
-
##
|
|
1580
|
+
## Testing
|
|
1935
1581
|
|
|
1936
|
-
|
|
1937
|
-
- Laravel by Taylor Otwell and the Laravel community
|
|
1582
|
+
Actions generated with CLI include inline tests:
|
|
1938
1583
|
|
|
1939
|
-
|
|
1584
|
+
```bash
|
|
1585
|
+
# Test single action
|
|
1586
|
+
bun test src/actions/projects/create.ts
|
|
1940
1587
|
|
|
1941
|
-
|
|
1588
|
+
# Test all actions in a resource
|
|
1589
|
+
bun test src/actions/projects/
|
|
1942
1590
|
|
|
1943
|
-
|
|
1591
|
+
# Run project checks
|
|
1592
|
+
honertia check --verbose
|
|
1593
|
+
```
|
|
1944
1594
|
|
|
1945
|
-
|
|
1946
|
-
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
1947
|
-
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
1948
|
-
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
1949
|
-
5. Open a Pull Request
|
|
1595
|
+
---
|
|
1950
1596
|
|
|
1951
1597
|
## License
|
|
1952
1598
|
|