honertia 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1125 -1372
- 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/binding.d.ts.map +1 -1
- package/dist/effect/binding.js +1 -4
- package/dist/effect/bridge.d.ts +1 -0
- package/dist/effect/bridge.d.ts.map +1 -1
- package/dist/effect/bridge.js +51 -6
- package/dist/effect/error-catalog.d.ts +100 -0
- package/dist/effect/error-catalog.d.ts.map +1 -0
- package/dist/effect/error-catalog.js +700 -0
- package/dist/effect/error-context.d.ts +99 -0
- package/dist/effect/error-context.d.ts.map +1 -0
- package/dist/effect/error-context.js +230 -0
- package/dist/effect/error-formatter.d.ts +143 -0
- package/dist/effect/error-formatter.d.ts.map +1 -0
- package/dist/effect/error-formatter.js +355 -0
- package/dist/effect/error-types.d.ts +275 -0
- package/dist/effect/error-types.d.ts.map +1 -0
- package/dist/effect/error-types.js +7 -0
- package/dist/effect/errors.d.ts +183 -15
- package/dist/effect/errors.d.ts.map +1 -1
- package/dist/effect/errors.js +333 -8
- package/dist/effect/handler.d.ts +6 -0
- package/dist/effect/handler.d.ts.map +1 -1
- package/dist/effect/handler.js +124 -12
- package/dist/effect/index.d.ts +13 -5
- package/dist/effect/index.d.ts.map +1 -1
- package/dist/effect/index.js +18 -4
- 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 +26 -11
- package/dist/effect/validation.d.ts.map +1 -1
- package/dist/effect/validation.js +80 -4
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +5 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +77 -12
- 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
404
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const fetchProjects = (
|
|
172
|
-
db: Database,
|
|
173
|
-
user: AuthUser
|
|
174
|
-
): Effect.Effect<ProjectsIndexProps, Error, never> =>
|
|
175
|
-
Effect.tryPromise({
|
|
176
|
-
try: async () => {
|
|
177
|
-
const projects = await db.query.projects.findMany({
|
|
178
|
-
where: eq(schema.projects.userId, user.user.id),
|
|
179
|
-
orderBy: (projects, { desc }) => [desc(projects.createdAt)],
|
|
180
|
-
})
|
|
181
|
-
return { projects }
|
|
182
|
-
},
|
|
183
|
-
catch: (error) => error instanceof Error ? error : new Error(String(error)),
|
|
405
|
+
// Public routes (no auth required)
|
|
406
|
+
effectRoutes(app).group((route) => {
|
|
407
|
+
// route.get('/about', showAbout)
|
|
184
408
|
})
|
|
185
|
-
|
|
186
|
-
export const listProjects = Effect.gen(function* () {
|
|
187
|
-
const db = yield* DatabaseService
|
|
188
|
-
const user = yield* AuthUserService
|
|
189
|
-
const props = yield* fetchProjects(db as Database, user)
|
|
190
|
-
return yield* render('Projects/Index', props)
|
|
191
|
-
})
|
|
409
|
+
}
|
|
192
410
|
```
|
|
193
411
|
|
|
194
|
-
|
|
195
|
-
Vite + React layout is:
|
|
196
|
-
|
|
197
|
-
```
|
|
198
|
-
src/pages/Projects/Index.tsx
|
|
199
|
-
```
|
|
412
|
+
### 7. src/main.tsx (REQUIRED)
|
|
200
413
|
|
|
201
|
-
|
|
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
|
-
```
|
|
441
|
+
name = "my-app"
|
|
442
|
+
compatibility_date = "2024-01-01"
|
|
443
|
+
main = "src/index.ts"
|
|
301
444
|
|
|
302
|
-
|
|
445
|
+
[vars]
|
|
446
|
+
ENVIRONMENT = "development"
|
|
303
447
|
|
|
304
|
-
|
|
305
|
-
|
|
448
|
+
[[d1_databases]]
|
|
449
|
+
binding = "DB"
|
|
450
|
+
database_name = "my-app-db"
|
|
451
|
+
database_id = "your-database-id"
|
|
306
452
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
```bash
|
|
310
|
-
bun add react react-dom @inertiajs/react
|
|
311
|
-
bun add -d @vitejs/plugin-react tailwindcss @tailwindcss/vite
|
|
453
|
+
[site]
|
|
454
|
+
bucket = "./dist"
|
|
312
455
|
```
|
|
313
456
|
|
|
314
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
```css
|
|
343
|
-
/* src/styles.css */
|
|
344
|
-
@import "tailwindcss";
|
|
482
|
+
### 10. tsconfig.json (REQUIRED)
|
|
345
483
|
|
|
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
|
+
---
|
|
506
|
+
|
|
507
|
+
## Project Structure
|
|
508
|
+
|
|
509
|
+
```
|
|
510
|
+
src/
|
|
511
|
+
index.ts # App entry, setupHonertia() - REQUIRED
|
|
512
|
+
routes.ts # Route definitions - REQUIRED
|
|
513
|
+
types.ts # Type definitions - REQUIRED
|
|
514
|
+
main.tsx # Client entry - REQUIRED
|
|
515
|
+
styles.css # Global styles
|
|
516
|
+
db/
|
|
517
|
+
db.ts # Database factory - REQUIRED
|
|
518
|
+
schema.ts # Drizzle schema - REQUIRED
|
|
519
|
+
lib/
|
|
520
|
+
auth.ts # Auth config - REQUIRED
|
|
521
|
+
actions/
|
|
522
|
+
auth/
|
|
523
|
+
login.ts
|
|
524
|
+
register.ts
|
|
525
|
+
logout.ts
|
|
526
|
+
projects/
|
|
527
|
+
index.ts
|
|
528
|
+
show.ts
|
|
529
|
+
create.ts
|
|
530
|
+
pages/
|
|
531
|
+
Auth/
|
|
532
|
+
Login.tsx
|
|
533
|
+
Register.tsx
|
|
534
|
+
Projects/
|
|
535
|
+
Index.tsx
|
|
536
|
+
Show.tsx
|
|
537
|
+
Create.tsx
|
|
538
|
+
Error.tsx # Error page component
|
|
539
|
+
wrangler.toml # Cloudflare config - REQUIRED
|
|
540
|
+
vite.config.ts # Vite config - REQUIRED
|
|
541
|
+
tsconfig.json # TypeScript config - REQUIRED
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
---
|
|
545
|
+
|
|
546
|
+
## Auth Actions (REQUIRED)
|
|
547
|
+
|
|
548
|
+
These three actions are required for `effectAuthRoutes` to work.
|
|
549
|
+
|
|
550
|
+
### src/actions/auth/login.ts
|
|
357
551
|
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
import '
|
|
361
|
-
|
|
362
|
-
import { createInertiaApp } from '@inertiajs/react'
|
|
363
|
-
import { createRoot } from 'react-dom/client'
|
|
552
|
+
```typescript
|
|
553
|
+
import { betterAuthFormAction } from 'honertia/auth'
|
|
554
|
+
import { Schema as S } from 'effect'
|
|
555
|
+
import { email, requiredString } from 'honertia/effect'
|
|
364
556
|
|
|
365
|
-
const
|
|
557
|
+
const LoginSchema = S.Struct({
|
|
558
|
+
email: email,
|
|
559
|
+
password: requiredString,
|
|
560
|
+
})
|
|
366
561
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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:
|
|
878
|
+
### PUT with Route Binding and Validation
|
|
584
879
|
|
|
585
880
|
```typescript
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
```typescript
|
|
603
|
-
// This action requires: RequestService, DatabaseService
|
|
604
|
-
export const createProject: Effect.Effect<
|
|
605
|
-
Response | Redirect,
|
|
606
|
-
ValidationError | UnauthorizedError | ForbiddenError | Error,
|
|
607
|
-
RequestService | DatabaseService
|
|
608
|
-
>
|
|
609
|
-
```
|
|
610
|
-
|
|
611
|
-
The compiler ensures all required services are provided when the action runs.
|
|
612
|
-
Note: `authorize` uses an optional `AuthUserService`, so it won't appear in the required service list unless you `yield* AuthUserService` directly or provide `RequireAuthLayer` explicitly.
|
|
613
|
-
|
|
614
|
-
### Minimal Actions
|
|
615
|
-
|
|
616
|
-
Not every action needs all features. Use only what you need:
|
|
881
|
+
import { Effect, Schema as S } from 'effect'
|
|
882
|
+
import {
|
|
883
|
+
action,
|
|
884
|
+
authorize,
|
|
885
|
+
bound,
|
|
886
|
+
validateRequest,
|
|
887
|
+
DatabaseService,
|
|
888
|
+
redirect,
|
|
889
|
+
asTrusted,
|
|
890
|
+
dbMutation,
|
|
891
|
+
requiredString,
|
|
892
|
+
forbidden,
|
|
893
|
+
} from 'honertia/effect'
|
|
894
|
+
import { eq } from 'drizzle-orm'
|
|
895
|
+
import { projects } from '~/db/schema'
|
|
617
896
|
|
|
618
|
-
|
|
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
|
|
905
|
+
const project = yield* bound('project')
|
|
648
906
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
```typescript
|
|
654
|
-
import { DatabaseService, dbMutation, validateRequest, asTrusted, authorize } from 'honertia/effect'
|
|
655
|
-
|
|
656
|
-
const auth = yield* authorize()
|
|
657
|
-
const input = yield* validateRequest(CreateProjectSchema)
|
|
658
|
-
const values = asTrusted({ userId: auth.user.id, ...input })
|
|
659
|
-
const db = yield* DatabaseService
|
|
660
|
-
|
|
661
|
-
yield* dbMutation(db, async (db) => {
|
|
662
|
-
await db.insert(projects).values(values)
|
|
663
|
-
})
|
|
664
|
-
```
|
|
665
|
-
|
|
666
|
-
Use `asTrusted` for server-derived values like audit logs or usage meters, or when combining validated input with server-only fields.
|
|
667
|
-
`dbMutation` also wraps `execute`/`run` params to require validated or trusted inputs.
|
|
668
|
-
|
|
669
|
-
Why this design: we intentionally use nominal brands that do not survive spreads or merges. That means any modified or combined object must be explicitly re-branded with `asTrusted`, which makes trust boundaries visible and prevents accidental writes of unvalidated data.
|
|
907
|
+
// Authorization check
|
|
908
|
+
if (project.userId !== auth.user.id) {
|
|
909
|
+
return yield* forbidden('You cannot edit this project')
|
|
910
|
+
}
|
|
670
911
|
|
|
671
|
-
|
|
672
|
-
|
|
912
|
+
const input = yield* validateRequest(UpdateProjectSchema, {
|
|
913
|
+
errorComponent: 'Projects/Edit',
|
|
914
|
+
})
|
|
915
|
+
const db = yield* DatabaseService
|
|
673
916
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
917
|
+
yield* dbMutation(db, async (db) => {
|
|
918
|
+
await db.update(projects)
|
|
919
|
+
.set(asTrusted({
|
|
920
|
+
name: input.name,
|
|
921
|
+
description: input.description ?? null,
|
|
922
|
+
}))
|
|
923
|
+
.where(eq(projects.id, project.id))
|
|
924
|
+
})
|
|
677
925
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
926
|
+
return yield* redirect(`/projects/${project.id}`)
|
|
927
|
+
})
|
|
928
|
+
)
|
|
681
929
|
```
|
|
682
930
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
#### `dbTransaction` - Database Transactions
|
|
686
|
-
|
|
687
|
-
Run multiple database operations in a transaction with automatic rollback on failure. The database instance is passed explicitly to keep the dependency visible and consistent with other service patterns:
|
|
688
|
-
|
|
689
|
-
```typescript
|
|
690
|
-
import { DatabaseService, dbTransaction, asTrusted } from 'honertia/effect'
|
|
691
|
-
|
|
692
|
-
const db = yield* DatabaseService
|
|
693
|
-
const user = asTrusted({ name: 'Alice', email: 'alice@example.com' })
|
|
694
|
-
const balanceUpdate = asTrusted({ balance: 100 })
|
|
695
|
-
|
|
696
|
-
yield* dbTransaction(db, async (tx) => {
|
|
697
|
-
await tx.insert(users).values(user)
|
|
698
|
-
await tx.update(accounts).set(balanceUpdate).where(eq(accounts.userId, id))
|
|
699
|
-
// If any operation fails, the entire transaction rolls back
|
|
700
|
-
return { success: true }
|
|
701
|
-
})
|
|
702
|
-
```
|
|
703
|
-
|
|
704
|
-
`dbTransaction` wraps `insert`, `update`, and `execute`/`run` params; `delete` has no payload, so build its conditions from validated or trusted values.
|
|
705
|
-
|
|
706
|
-
## Core Concepts
|
|
707
|
-
|
|
708
|
-
### Effect-Based Handlers
|
|
709
|
-
|
|
710
|
-
Route handlers are Effect computations that return `Response | Redirect`. Actions are fully composable - you opt-in to features by yielding services:
|
|
931
|
+
### DELETE with Route Binding
|
|
711
932
|
|
|
712
933
|
```typescript
|
|
713
934
|
import { Effect } from 'effect'
|
|
714
935
|
import {
|
|
715
936
|
action,
|
|
716
937
|
authorize,
|
|
717
|
-
|
|
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,844 +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
|
-
>() {}
|
|
816
|
-
|
|
817
|
-
// Create the service with initialization logic
|
|
818
|
-
const createRateLimiter = (kv: KVNamespace) => ({
|
|
819
|
-
check: async (key: string, limit: number, windowSeconds: number) => {
|
|
820
|
-
const count = parseInt(await kv.get(key) ?? '0')
|
|
821
|
-
return count < limit
|
|
822
|
-
},
|
|
823
|
-
increment: async (key: string) => {
|
|
824
|
-
const count = parseInt(await kv.get(key) ?? '0')
|
|
825
|
-
await kv.put(key, String(count + 1), { expirationTtl: 60 })
|
|
826
|
-
},
|
|
827
|
-
})
|
|
828
|
-
|
|
829
|
-
app.use('*', setupHonertia<Env, RateLimiterService>({
|
|
830
|
-
honertia: { version, render },
|
|
831
|
-
effect: {
|
|
832
|
-
services: (c) => Layer.succeed(
|
|
833
|
-
RateLimiterService,
|
|
834
|
-
createRateLimiter(c.env.RATE_LIMIT_KV)
|
|
835
|
-
),
|
|
836
|
-
},
|
|
837
|
-
}))
|
|
838
|
-
|
|
839
|
-
// Use in your action - clean, testable, typed
|
|
840
|
-
const createProject = Effect.gen(function* () {
|
|
841
|
-
const rateLimiter = yield* RateLimiterService
|
|
842
|
-
const auth = yield* authorize()
|
|
843
|
-
|
|
844
|
-
const allowed = yield* Effect.tryPromise(() =>
|
|
845
|
-
rateLimiter.check(`create:${auth.user.id}`, 10, 60)
|
|
846
|
-
)
|
|
847
|
-
if (!allowed) {
|
|
848
|
-
return yield* httpError(429, 'Rate limit exceeded')
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// ... create project ...
|
|
970
|
+
import { Effect, Schema as S } from 'effect'
|
|
971
|
+
import { action, validateRequest, DatabaseService, json } from 'honertia/effect'
|
|
972
|
+
import { like } from 'drizzle-orm'
|
|
973
|
+
import { projects } from '~/db/schema'
|
|
852
974
|
|
|
853
|
-
|
|
854
|
-
|
|
975
|
+
const SearchSchema = S.Struct({
|
|
976
|
+
q: S.String,
|
|
977
|
+
limit: S.optional(S.NumberFromString).pipe(S.withDefault(() => 10)),
|
|
855
978
|
})
|
|
856
|
-
```
|
|
857
|
-
|
|
858
|
-
**Multiple services with `Layer.mergeAll`:**
|
|
859
979
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
services: (c) => Layer.mergeAll(
|
|
865
|
-
Layer.succeed(RateLimiterService, createRateLimiter(c.env.RATE_LIMIT_KV)),
|
|
866
|
-
Layer.succeed(QueueService, { queue: c.env.MY_QUEUE }),
|
|
867
|
-
),
|
|
868
|
-
},
|
|
869
|
-
}))
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
**Summary:**
|
|
873
|
-
- **Simple binding access**: Use `BindingsService` (automatically provided, typed via module augmentation)
|
|
874
|
-
- **Complex services**: Use `effect.services` when you need initialization, testability, or abstraction
|
|
875
|
-
|
|
876
|
-
### Routing
|
|
877
|
-
|
|
878
|
-
Use `effectRoutes` for Laravel-style route definitions:
|
|
980
|
+
export const searchProjects = action(
|
|
981
|
+
Effect.gen(function* () {
|
|
982
|
+
const { q, limit } = yield* validateRequest(SearchSchema)
|
|
983
|
+
const db = yield* DatabaseService
|
|
879
984
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
985
|
+
const results = yield* Effect.tryPromise(() =>
|
|
986
|
+
db.query.projects.findMany({
|
|
987
|
+
where: like(projects.name, `%${q}%`),
|
|
988
|
+
limit,
|
|
989
|
+
})
|
|
990
|
+
)
|
|
886
991
|
|
|
887
|
-
|
|
888
|
-
effectRoutes(app)
|
|
889
|
-
.provide(RequireAuthLayer)
|
|
890
|
-
.prefix('/dashboard')
|
|
891
|
-
.group((route) => {
|
|
892
|
-
route.get('/', showDashboard)
|
|
893
|
-
route.get('/settings', showSettings)
|
|
894
|
-
route.post('/settings', updateSettings)
|
|
992
|
+
return yield* json({ results, count: results.length })
|
|
895
993
|
})
|
|
896
|
-
|
|
897
|
-
// Guest-only routes
|
|
898
|
-
effectRoutes(app)
|
|
899
|
-
.provide(RequireGuestLayer)
|
|
900
|
-
.group((route) => {
|
|
901
|
-
route.get('/login', showLogin)
|
|
902
|
-
route.get('/register', showRegister)
|
|
903
|
-
})
|
|
904
|
-
|
|
905
|
-
// Public routes (no layer)
|
|
906
|
-
effectRoutes(app).group((route) => {
|
|
907
|
-
route.get('/about', showAbout)
|
|
908
|
-
route.get('/pricing', showPricing)
|
|
909
|
-
})
|
|
910
|
-
```
|
|
911
|
-
|
|
912
|
-
#### Route Parameter Validation
|
|
913
|
-
|
|
914
|
-
You can pass a `params` schema to validate route parameters before your handler runs. Invalid values automatically return a 404:
|
|
915
|
-
|
|
916
|
-
```typescript
|
|
917
|
-
import { Schema as S } from 'effect'
|
|
918
|
-
import { uuid } from 'honertia/effect'
|
|
919
|
-
|
|
920
|
-
effectRoutes(app).get(
|
|
921
|
-
'/projects/:id',
|
|
922
|
-
showProject,
|
|
923
|
-
{ params: S.Struct({ id: uuid }) }
|
|
924
994
|
)
|
|
925
995
|
```
|
|
926
996
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
```typescript
|
|
930
|
-
effectRoutes(app).get(
|
|
931
|
-
'/api/:version/projects/:id',
|
|
932
|
-
showProject,
|
|
933
|
-
{
|
|
934
|
-
params: S.Struct({
|
|
935
|
-
version: S.Literal('v1', 'v2'),
|
|
936
|
-
id: uuid,
|
|
937
|
-
}),
|
|
938
|
-
}
|
|
939
|
-
)
|
|
940
|
-
```
|
|
941
|
-
|
|
942
|
-
#### Laravel-Style Route Model Binding
|
|
943
|
-
|
|
944
|
-
Honertia supports Laravel-style route model binding with the `{param}` syntax. This automatically resolves route parameters to database models, returning 404 if the model isn't found.
|
|
945
|
-
|
|
946
|
-
**Setup:**
|
|
947
|
-
|
|
948
|
-
1. Add your Drizzle schema to the module augmentation:
|
|
997
|
+
### Action with Role Check
|
|
949
998
|
|
|
950
999
|
```typescript
|
|
951
|
-
|
|
952
|
-
import
|
|
953
|
-
import type { auth } from '~/lib/auth'
|
|
954
|
-
import * as schema from '~/db/schema'
|
|
955
|
-
|
|
956
|
-
declare module 'honertia/effect' {
|
|
957
|
-
interface HonertiaDatabaseType {
|
|
958
|
-
type: Database
|
|
959
|
-
schema: typeof schema // Add this for route model binding
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
interface HonertiaAuthType {
|
|
963
|
-
type: typeof auth
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
```
|
|
967
|
-
|
|
968
|
-
2. Pass your schema to `setupHonertia`:
|
|
969
|
-
|
|
970
|
-
```typescript
|
|
971
|
-
import * as schema from '~/db/schema'
|
|
972
|
-
|
|
973
|
-
app.use('*', setupHonertia({
|
|
974
|
-
honertia: {
|
|
975
|
-
version: '1.0.0',
|
|
976
|
-
render: createTemplate({ ... }),
|
|
977
|
-
database: (c) => createDb(c.env.DATABASE_URL),
|
|
978
|
-
schema, // Schema is shared with all effectRoutes
|
|
979
|
-
},
|
|
980
|
-
}))
|
|
981
|
-
```
|
|
982
|
-
|
|
983
|
-
**Basic Usage:**
|
|
984
|
-
|
|
985
|
-
```typescript
|
|
986
|
-
import { bound } from 'honertia/effect'
|
|
987
|
-
|
|
988
|
-
// Route: /projects/{project}
|
|
989
|
-
// Automatically queries: SELECT * FROM projects WHERE id = :project
|
|
990
|
-
|
|
991
|
-
effectRoutes(app).get('/projects/{project}', showProject)
|
|
992
|
-
|
|
993
|
-
const showProject = Effect.gen(function* () {
|
|
994
|
-
const project = yield* bound('project') // Already fetched, guaranteed to exist
|
|
995
|
-
return yield* render('Projects/Show', { project })
|
|
996
|
-
})
|
|
997
|
-
```
|
|
998
|
-
|
|
999
|
-
**Custom Column Binding:**
|
|
1000
|
-
|
|
1001
|
-
By default, bindings query the `id` column. Use `{param:column}` syntax to bind by a different column:
|
|
1002
|
-
|
|
1003
|
-
```typescript
|
|
1004
|
-
// Bind by slug instead of id
|
|
1005
|
-
effectRoutes(app).get('/projects/{project:slug}', showProject)
|
|
1006
|
-
// Queries: SELECT * FROM projects WHERE slug = :project
|
|
1007
|
-
```
|
|
1008
|
-
|
|
1009
|
-
**Nested Route Scoping:**
|
|
1010
|
-
|
|
1011
|
-
For nested routes, Honertia automatically scopes child models to their parents using Drizzle relations:
|
|
1012
|
-
|
|
1013
|
-
```typescript
|
|
1014
|
-
// Route: /users/{user}/posts/{post}
|
|
1015
|
-
effectRoutes(app).get('/users/{user}/posts/{post}', showUserPost)
|
|
1016
|
-
|
|
1017
|
-
// Queries:
|
|
1018
|
-
// 1. SELECT * FROM users WHERE id = :user
|
|
1019
|
-
// 2. SELECT * FROM posts WHERE id = :post AND userId = :user.id
|
|
1020
|
-
```
|
|
1021
|
-
|
|
1022
|
-
This uses your Drizzle relations to discover the foreign key:
|
|
1023
|
-
|
|
1024
|
-
```typescript
|
|
1025
|
-
// db/schema.ts
|
|
1026
|
-
export const postsRelations = relations(posts, ({ one }) => ({
|
|
1027
|
-
user: one(users, {
|
|
1028
|
-
fields: [posts.userId],
|
|
1029
|
-
references: [users.id],
|
|
1030
|
-
}),
|
|
1031
|
-
}))
|
|
1032
|
-
```
|
|
1033
|
-
|
|
1034
|
-
If no relation is found, the child is resolved without scoping (useful for unrelated resources in the same route).
|
|
1035
|
-
|
|
1036
|
-
**How Binding Works:**
|
|
1037
|
-
|
|
1038
|
-
1. `{project}` is converted to `:project` for Hono's router
|
|
1039
|
-
2. At request time, the param value is extracted
|
|
1040
|
-
3. The table name is derived by pluralizing the param (`project` → `projects`)
|
|
1041
|
-
4. A database query is executed against that table
|
|
1042
|
-
5. If not found, a 404 is returned before your handler runs
|
|
1043
|
-
6. If found, the model is available via `bound('project')`
|
|
1044
|
-
|
|
1045
|
-
**Combining with Param Validation:**
|
|
1046
|
-
|
|
1047
|
-
Route model binding and param validation work together. Validation runs first:
|
|
1048
|
-
|
|
1049
|
-
```typescript
|
|
1050
|
-
effectRoutes(app).get(
|
|
1051
|
-
'/projects/{project}',
|
|
1052
|
-
showProject,
|
|
1053
|
-
{ params: S.Struct({ project: uuid }) } // Validates UUID format first
|
|
1054
|
-
)
|
|
1055
|
-
```
|
|
1056
|
-
|
|
1057
|
-
Order of execution:
|
|
1058
|
-
1. Param validation (returns 404 if schema fails)
|
|
1059
|
-
2. Model binding (returns 404 if not found in database)
|
|
1060
|
-
3. Your handler (model guaranteed to exist)
|
|
1000
|
+
import { Effect } from 'effect'
|
|
1001
|
+
import { action, authorize, DatabaseService, render } from 'honertia/effect'
|
|
1061
1002
|
|
|
1062
|
-
|
|
1003
|
+
export const adminDashboard = action(
|
|
1004
|
+
Effect.gen(function* () {
|
|
1005
|
+
// authorize() with callback checks role
|
|
1006
|
+
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1007
|
+
const db = yield* DatabaseService
|
|
1063
1008
|
|
|
1064
|
-
|
|
1009
|
+
const stats = yield* Effect.tryPromise(() =>
|
|
1010
|
+
db.query.users.findMany({ limit: 100 })
|
|
1011
|
+
)
|
|
1065
1012
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
// {project} is resolved from the database
|
|
1069
|
-
effectRoutes(app).get(
|
|
1070
|
-
'/api/:version/projects/{project}',
|
|
1071
|
-
showProject
|
|
1013
|
+
return yield* render('Admin/Dashboard', { stats })
|
|
1014
|
+
})
|
|
1072
1015
|
)
|
|
1073
|
-
|
|
1074
|
-
const showProject = Effect.gen(function* () {
|
|
1075
|
-
const request = yield* RequestService
|
|
1076
|
-
const version = request.param('version') // 'v1', 'v2', etc.
|
|
1077
|
-
const project = yield* bound('project') // Database model
|
|
1078
|
-
// ...
|
|
1079
|
-
})
|
|
1080
1016
|
```
|
|
1081
1017
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
Routes without `{bindings}` have zero overhead—binding resolution only runs when Laravel-style params are detected. The binding check is a simple regex test at route registration time.
|
|
1085
|
-
|
|
1086
|
-
## Validation
|
|
1087
|
-
|
|
1088
|
-
Honertia uses Effect Schema with Laravel-inspired validators:
|
|
1018
|
+
### Action with Custom Error Handling
|
|
1089
1019
|
|
|
1090
1020
|
```typescript
|
|
1091
|
-
import { Effect
|
|
1021
|
+
import { Effect } from 'effect'
|
|
1092
1022
|
import {
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
} from 'honertia'
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
const CreateProjectSchema = S.Struct({
|
|
1103
|
-
name: requiredString.pipe(
|
|
1104
|
-
S.minLength(3, { message: () => 'Name must be at least 3 characters' }),
|
|
1105
|
-
S.maxLength(100)
|
|
1106
|
-
),
|
|
1107
|
-
description: nullableString,
|
|
1108
|
-
})
|
|
1109
|
-
|
|
1110
|
-
// Use in handler
|
|
1111
|
-
export const createProject = Effect.gen(function* () {
|
|
1112
|
-
const input = yield* validateRequest(CreateProjectSchema, {
|
|
1113
|
-
errorComponent: 'Projects/Create', // Re-render with errors on validation failure
|
|
1114
|
-
})
|
|
1115
|
-
|
|
1116
|
-
// input is Validated<{ name: string, description: string | null }>
|
|
1117
|
-
yield* insertProject(input)
|
|
1118
|
-
|
|
1119
|
-
return yield* redirect('/projects')
|
|
1120
|
-
})
|
|
1121
|
-
```
|
|
1023
|
+
action,
|
|
1024
|
+
authorize,
|
|
1025
|
+
DatabaseService,
|
|
1026
|
+
render,
|
|
1027
|
+
notFound,
|
|
1028
|
+
httpError,
|
|
1029
|
+
} from 'honertia/effect'
|
|
1030
|
+
import { eq } from 'drizzle-orm'
|
|
1031
|
+
import { projects } from '~/db/schema'
|
|
1122
1032
|
|
|
1123
|
-
|
|
1033
|
+
export const showProject = action(
|
|
1034
|
+
Effect.gen(function* () {
|
|
1035
|
+
const auth = yield* authorize()
|
|
1036
|
+
const db = yield* DatabaseService
|
|
1037
|
+
const request = yield* RequestService
|
|
1038
|
+
const projectId = request.param('id')
|
|
1124
1039
|
|
|
1125
|
-
|
|
1040
|
+
const project = yield* Effect.tryPromise(() =>
|
|
1041
|
+
db.query.projects.findFirst({
|
|
1042
|
+
where: eq(projects.id, projectId),
|
|
1043
|
+
})
|
|
1044
|
+
)
|
|
1126
1045
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
// If not set, redirects back to the previous page
|
|
1131
|
-
errorComponent: 'Projects/Create',
|
|
1046
|
+
if (!project) {
|
|
1047
|
+
return yield* notFound('Project', projectId)
|
|
1048
|
+
}
|
|
1132
1049
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
email: 'That email address is not valid',
|
|
1137
|
-
},
|
|
1050
|
+
if (project.userId !== auth.user.id) {
|
|
1051
|
+
return yield* httpError(403, 'Access denied')
|
|
1052
|
+
}
|
|
1138
1053
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
name: 'project name',
|
|
1143
|
-
email: 'email address',
|
|
1144
|
-
},
|
|
1145
|
-
})
|
|
1054
|
+
return yield* render('Projects/Show', { project })
|
|
1055
|
+
})
|
|
1056
|
+
)
|
|
1146
1057
|
```
|
|
1147
1058
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
```typescript
|
|
1151
|
-
const schema = S.Struct({
|
|
1152
|
-
email: S.String.pipe(S.minLength(1, { message: () => 'The :attribute field is required' })),
|
|
1153
|
-
})
|
|
1059
|
+
---
|
|
1154
1060
|
|
|
1155
|
-
|
|
1156
|
-
attributes: { email: 'email address' },
|
|
1157
|
-
errorComponent: 'Auth/Register',
|
|
1158
|
-
})
|
|
1159
|
-
// Error: "The email address field is required"
|
|
1160
|
-
```
|
|
1061
|
+
## Validation Examples
|
|
1161
1062
|
|
|
1162
|
-
###
|
|
1063
|
+
### String Validators
|
|
1163
1064
|
|
|
1164
|
-
#### Strings
|
|
1165
1065
|
```typescript
|
|
1066
|
+
import { Schema as S } from 'effect'
|
|
1166
1067
|
import {
|
|
1167
|
-
requiredString, // Trimmed, non-empty
|
|
1168
|
-
nullableString, //
|
|
1169
|
-
|
|
1068
|
+
requiredString, // Trimmed, non-empty
|
|
1069
|
+
nullableString, // Empty string -> null
|
|
1070
|
+
email, // Email format
|
|
1071
|
+
url, // URL format
|
|
1072
|
+
uuid, // UUID format
|
|
1170
1073
|
alpha, // Letters only
|
|
1171
1074
|
alphaDash, // Letters, numbers, dashes, underscores
|
|
1172
|
-
alphaNum, // Letters and numbers
|
|
1173
|
-
email, // Validated email
|
|
1174
|
-
url, // Validated URL
|
|
1175
|
-
uuid, // UUID format
|
|
1075
|
+
alphaNum, // Letters and numbers
|
|
1176
1076
|
min, // min(5) - at least 5 chars
|
|
1177
1077
|
max, // max(100) - at most 100 chars
|
|
1178
1078
|
size, // size(10) - exactly 10 chars
|
|
1179
|
-
} from 'honertia'
|
|
1180
|
-
```
|
|
1181
|
-
|
|
1182
|
-
#### Numbers
|
|
1183
|
-
```typescript
|
|
1184
|
-
import {
|
|
1185
|
-
coercedNumber, // Coerce string to number
|
|
1186
|
-
positiveInt, // Positive integer
|
|
1187
|
-
nonNegativeInt, // 0 or greater
|
|
1188
|
-
between, // between(1, 100)
|
|
1189
|
-
gt, gte, lt, lte, // Comparisons
|
|
1190
|
-
} from 'honertia'
|
|
1191
|
-
```
|
|
1192
|
-
|
|
1193
|
-
#### Booleans & Dates
|
|
1194
|
-
```typescript
|
|
1195
|
-
import {
|
|
1196
|
-
coercedBoolean, // Coerce "true", "1", etc.
|
|
1197
|
-
checkbox, // HTML checkbox (defaults to false)
|
|
1198
|
-
accepted, // Must be truthy
|
|
1199
|
-
coercedDate, // Coerce to Date
|
|
1200
|
-
nullableDate, // Empty string -> null
|
|
1201
|
-
after, // after(new Date())
|
|
1202
|
-
before, // before('2025-01-01')
|
|
1203
|
-
} from 'honertia'
|
|
1204
|
-
```
|
|
1205
|
-
|
|
1206
|
-
#### Password
|
|
1207
|
-
```typescript
|
|
1208
|
-
import { password } from 'honertia'
|
|
1209
|
-
|
|
1210
|
-
const PasswordSchema = password({
|
|
1211
|
-
min: 8,
|
|
1212
|
-
letters: true,
|
|
1213
|
-
mixedCase: true,
|
|
1214
|
-
numbers: true,
|
|
1215
|
-
symbols: true,
|
|
1216
|
-
})
|
|
1217
|
-
```
|
|
1218
|
-
|
|
1219
|
-
## Response Helpers
|
|
1220
|
-
|
|
1221
|
-
```typescript
|
|
1222
|
-
import {
|
|
1223
|
-
render,
|
|
1224
|
-
renderWithErrors,
|
|
1225
|
-
redirect,
|
|
1226
|
-
json,
|
|
1227
|
-
notFound,
|
|
1228
|
-
forbidden,
|
|
1229
|
-
} from 'honertia'
|
|
1230
|
-
|
|
1231
|
-
// Render a page
|
|
1232
|
-
return yield* render('Projects/Show', { project })
|
|
1233
|
-
|
|
1234
|
-
// Render with validation errors
|
|
1235
|
-
return yield* renderWithErrors('Projects/Create', {
|
|
1236
|
-
name: 'Name is required',
|
|
1237
|
-
})
|
|
1238
|
-
|
|
1239
|
-
// Redirect (303 by default for POST)
|
|
1240
|
-
return yield* redirect('/projects')
|
|
1241
|
-
return yield* redirect('/login', 302)
|
|
1242
|
-
|
|
1243
|
-
// JSON response
|
|
1244
|
-
return yield* json({ success: true })
|
|
1245
|
-
return yield* json({ error: 'Not found' }, 404)
|
|
1246
|
-
|
|
1247
|
-
// Error responses
|
|
1248
|
-
return yield* notFound('Project')
|
|
1249
|
-
return yield* forbidden('You cannot edit this project')
|
|
1250
|
-
```
|
|
1251
|
-
|
|
1252
|
-
## Error Handling
|
|
1253
|
-
|
|
1254
|
-
Honertia provides typed errors that integrate with Effect's error channel. Each error type has specific handling behavior designed for Inertia-style applications.
|
|
1255
|
-
|
|
1256
|
-
### Built-in Error Types
|
|
1257
|
-
|
|
1258
|
-
| Error Type | HTTP Status | Handling Behavior |
|
|
1259
|
-
|------------|-------------|-------------------|
|
|
1260
|
-
| `ValidationError` | 422 / redirect | Re-renders form with field errors, or redirects back |
|
|
1261
|
-
| `UnauthorizedError` | 302/303 | Redirects to login page |
|
|
1262
|
-
| `NotFoundError` | 404 | Uses Hono's `notFound()` handler → renders via Honertia |
|
|
1263
|
-
| `ForbiddenError` | 403 | Returns JSON response (for API compatibility) |
|
|
1264
|
-
| `HttpError` | Custom | Returns JSON with custom status (developer-controlled) |
|
|
1265
|
-
| `RouteConfigurationError` | 500 | Throws to Hono's `onError` → renders error page |
|
|
1266
|
-
| Unexpected errors | 500 | Throws to Hono's `onError` → renders error page |
|
|
1267
|
-
|
|
1268
|
-
### Error Type Details
|
|
1269
|
-
|
|
1270
|
-
#### `ValidationError`
|
|
1271
|
-
|
|
1272
|
-
Thrown when request validation fails. Automatically re-renders the form with field-level errors.
|
|
1273
|
-
|
|
1274
|
-
```typescript
|
|
1275
|
-
import { ValidationError, validateRequest } from 'honertia/effect'
|
|
1276
|
-
|
|
1277
|
-
// Automatic: validateRequest throws ValidationError on failure
|
|
1278
|
-
const input = yield* validateRequest(schema, {
|
|
1279
|
-
errorComponent: 'Projects/Create', // Re-renders this component with errors
|
|
1280
|
-
})
|
|
1281
|
-
|
|
1282
|
-
// Manual: throw ValidationError directly
|
|
1283
|
-
yield* Effect.fail(new ValidationError({
|
|
1284
|
-
errors: { email: 'Invalid email format' },
|
|
1285
|
-
component: 'Auth/Register', // Optional: component to re-render
|
|
1286
|
-
}))
|
|
1287
|
-
```
|
|
1288
|
-
|
|
1289
|
-
**Behavior:**
|
|
1290
|
-
- If request prefers JSON (API calls): returns `{ errors: {...} }` with 422 status
|
|
1291
|
-
- If `component` is set: re-renders that component with errors in props
|
|
1292
|
-
- Otherwise: redirects back to referer with errors in session
|
|
1079
|
+
} from 'honertia/effect'
|
|
1293
1080
|
|
|
1294
|
-
|
|
1081
|
+
const UserSchema = S.Struct({
|
|
1082
|
+
name: requiredString,
|
|
1083
|
+
bio: nullableString,
|
|
1084
|
+
email: email,
|
|
1085
|
+
website: S.optional(url),
|
|
1086
|
+
username: alphaDash.pipe(min(3), max(20)),
|
|
1087
|
+
})
|
|
1088
|
+
```
|
|
1295
1089
|
|
|
1296
|
-
|
|
1090
|
+
### Number Validators
|
|
1297
1091
|
|
|
1298
1092
|
```typescript
|
|
1299
|
-
import {
|
|
1300
|
-
|
|
1301
|
-
//
|
|
1302
|
-
|
|
1093
|
+
import { Schema as S } from 'effect'
|
|
1094
|
+
import {
|
|
1095
|
+
coercedNumber, // String -> number
|
|
1096
|
+
positiveInt, // > 0
|
|
1097
|
+
nonNegativeInt, // >= 0
|
|
1098
|
+
between, // between(1, 100)
|
|
1099
|
+
gt, // gt(0) - greater than
|
|
1100
|
+
gte, // gte(0) - greater than or equal
|
|
1101
|
+
lt, // lt(100)
|
|
1102
|
+
lte, // lte(100)
|
|
1103
|
+
} from 'honertia/effect'
|
|
1303
1104
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
})
|
|
1105
|
+
const ProductSchema = S.Struct({
|
|
1106
|
+
price: coercedNumber.pipe(gte(0)),
|
|
1107
|
+
quantity: positiveInt,
|
|
1108
|
+
discount: S.optional(coercedNumber.pipe(between(0, 100))),
|
|
1109
|
+
})
|
|
1309
1110
|
```
|
|
1310
1111
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
#### `NotFoundError`
|
|
1314
|
-
|
|
1315
|
-
Thrown when a requested resource doesn't exist.
|
|
1112
|
+
### Boolean and Date Validators
|
|
1316
1113
|
|
|
1317
1114
|
```typescript
|
|
1318
|
-
import {
|
|
1319
|
-
|
|
1320
|
-
//
|
|
1321
|
-
|
|
1115
|
+
import { Schema as S } from 'effect'
|
|
1116
|
+
import {
|
|
1117
|
+
coercedBoolean, // "true", "1", "on" -> true
|
|
1118
|
+
checkbox, // HTML checkbox (defaults to false)
|
|
1119
|
+
accepted, // Must be truthy (for terms acceptance)
|
|
1120
|
+
coercedDate, // String -> Date
|
|
1121
|
+
nullableDate, // Empty string -> null
|
|
1122
|
+
after, // after(new Date()) - must be in future
|
|
1123
|
+
before, // before('2025-12-31')
|
|
1124
|
+
} from 'honertia/effect'
|
|
1322
1125
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1126
|
+
const EventSchema = S.Struct({
|
|
1127
|
+
isPublic: checkbox,
|
|
1128
|
+
termsAccepted: accepted,
|
|
1129
|
+
startDate: coercedDate.pipe(after(new Date())),
|
|
1130
|
+
endDate: S.optional(nullableDate),
|
|
1131
|
+
})
|
|
1328
1132
|
```
|
|
1329
1133
|
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
#### `ForbiddenError`
|
|
1333
|
-
|
|
1334
|
-
Thrown when the user is authenticated but not authorized to perform an action.
|
|
1134
|
+
### Password Validator
|
|
1335
1135
|
|
|
1336
1136
|
```typescript
|
|
1337
|
-
import {
|
|
1338
|
-
|
|
1339
|
-
// Automatic: authorize() throws ForbiddenError if check fails
|
|
1340
|
-
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1137
|
+
import { password } from 'honertia/effect'
|
|
1341
1138
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1139
|
+
const RegisterSchema = S.Struct({
|
|
1140
|
+
email: email,
|
|
1141
|
+
password: password({
|
|
1142
|
+
min: 8,
|
|
1143
|
+
letters: true,
|
|
1144
|
+
mixedCase: true,
|
|
1145
|
+
numbers: true,
|
|
1146
|
+
symbols: true,
|
|
1147
|
+
}),
|
|
1148
|
+
})
|
|
1349
1149
|
```
|
|
1350
1150
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
#### `HttpError`
|
|
1354
|
-
|
|
1355
|
-
A generic error for custom HTTP responses. Use when you need precise control over the response.
|
|
1151
|
+
### Full Form Example
|
|
1356
1152
|
|
|
1357
1153
|
```typescript
|
|
1358
|
-
import {
|
|
1154
|
+
import { Schema as S } from 'effect'
|
|
1155
|
+
import {
|
|
1156
|
+
requiredString,
|
|
1157
|
+
nullableString,
|
|
1158
|
+
email,
|
|
1159
|
+
coercedNumber,
|
|
1160
|
+
checkbox,
|
|
1161
|
+
coercedDate,
|
|
1162
|
+
between,
|
|
1163
|
+
} from 'honertia/effect'
|
|
1359
1164
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1165
|
+
const CreateEventSchema = S.Struct({
|
|
1166
|
+
title: requiredString.pipe(S.maxLength(200)),
|
|
1167
|
+
description: nullableString,
|
|
1168
|
+
organizerEmail: email,
|
|
1169
|
+
maxAttendees: coercedNumber.pipe(between(1, 10000)),
|
|
1170
|
+
isPublic: checkbox,
|
|
1171
|
+
startDate: coercedDate,
|
|
1172
|
+
endDate: S.optional(coercedDate),
|
|
1173
|
+
})
|
|
1362
1174
|
|
|
1363
|
-
//
|
|
1364
|
-
yield*
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1175
|
+
// In action
|
|
1176
|
+
const input = yield* validateRequest(CreateEventSchema, {
|
|
1177
|
+
errorComponent: 'Events/Create',
|
|
1178
|
+
messages: {
|
|
1179
|
+
title: 'Please enter an event title',
|
|
1180
|
+
organizerEmail: 'Please enter a valid email address',
|
|
1181
|
+
},
|
|
1182
|
+
attributes: {
|
|
1183
|
+
maxAttendees: 'maximum attendees',
|
|
1184
|
+
},
|
|
1185
|
+
})
|
|
1369
1186
|
```
|
|
1370
1187
|
|
|
1371
|
-
|
|
1188
|
+
---
|
|
1372
1189
|
|
|
1373
|
-
|
|
1190
|
+
## Route Model Binding Examples
|
|
1374
1191
|
|
|
1375
|
-
|
|
1192
|
+
### Basic Binding (by ID)
|
|
1376
1193
|
|
|
1377
1194
|
```typescript
|
|
1378
|
-
|
|
1195
|
+
// Route: /projects/{project}
|
|
1196
|
+
// Queries: SELECT * FROM projects WHERE id = :project
|
|
1379
1197
|
|
|
1380
|
-
|
|
1381
|
-
// - You use bound('project') but didn't pass schema to effectRoutes()
|
|
1382
|
-
// - Other route configuration mistakes
|
|
1198
|
+
effectRoutes(app).get('/projects/{project}', showProject)
|
|
1383
1199
|
|
|
1384
|
-
|
|
1200
|
+
const showProject = action(
|
|
1201
|
+
Effect.gen(function* () {
|
|
1202
|
+
const project = yield* bound('project')
|
|
1203
|
+
return yield* render('Projects/Show', { project })
|
|
1204
|
+
})
|
|
1205
|
+
)
|
|
1385
1206
|
```
|
|
1386
1207
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
### Setting Up Error Pages
|
|
1390
|
-
|
|
1391
|
-
To render errors via Honertia instead of returning plain text/JSON, use `registerErrorHandlers`:
|
|
1208
|
+
### Binding by Slug
|
|
1392
1209
|
|
|
1393
1210
|
```typescript
|
|
1394
|
-
|
|
1211
|
+
// Route: /projects/{project:slug}
|
|
1212
|
+
// Queries: SELECT * FROM projects WHERE slug = :project
|
|
1395
1213
|
|
|
1396
|
-
|
|
1397
|
-
registerErrorHandlers(app, {
|
|
1398
|
-
component: 'Error', // Your error page component
|
|
1399
|
-
showDevErrors: true, // Show detailed errors in development
|
|
1400
|
-
envKey: 'ENVIRONMENT', // Env var to check
|
|
1401
|
-
devValue: 'development', // Value that enables dev errors
|
|
1402
|
-
})
|
|
1214
|
+
effectRoutes(app).get('/projects/{project:slug}', showProject)
|
|
1403
1215
|
```
|
|
1404
1216
|
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
```tsx
|
|
1408
|
-
// src/pages/Error.tsx
|
|
1409
|
-
interface ErrorProps {
|
|
1410
|
-
status: number // 404, 500, etc.
|
|
1411
|
-
message: string // Error message (detailed in dev, generic in prod)
|
|
1412
|
-
}
|
|
1217
|
+
### Nested Binding (Scoped)
|
|
1413
1218
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
</div>
|
|
1420
|
-
)
|
|
1421
|
-
}
|
|
1422
|
-
```
|
|
1219
|
+
```typescript
|
|
1220
|
+
// Route: /users/{user}/posts/{post}
|
|
1221
|
+
// Queries:
|
|
1222
|
+
// 1. SELECT * FROM users WHERE id = :user
|
|
1223
|
+
// 2. SELECT * FROM posts WHERE id = :post AND userId = :user.id
|
|
1423
1224
|
|
|
1424
|
-
|
|
1225
|
+
effectRoutes(app).get('/users/{user}/posts/{post}', showUserPost)
|
|
1425
1226
|
|
|
1426
|
-
|
|
1427
|
-
Effect
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
│ ValidationError → Re-render form / redirect back │
|
|
1434
|
-
│ UnauthorizedError → Redirect to login │
|
|
1435
|
-
│ NotFoundError → c.notFound() → Hono notFound handler │
|
|
1436
|
-
│ ForbiddenError → JSON 403 │
|
|
1437
|
-
│ HttpError → JSON with custom status │
|
|
1438
|
-
│ Other errors → throw → Hono onError handler │
|
|
1439
|
-
└─────────────────────────────────────────────────────────┘
|
|
1440
|
-
│
|
|
1441
|
-
▼ (for thrown errors)
|
|
1442
|
-
┌─────────────────────────────────────────────────────────┐
|
|
1443
|
-
│ Hono onError handler │
|
|
1444
|
-
│ (from registerErrorHandlers) │
|
|
1445
|
-
├─────────────────────────────────────────────────────────┤
|
|
1446
|
-
│ Renders error component via Honertia │
|
|
1447
|
-
│ Shows detailed message in dev, generic in prod │
|
|
1448
|
-
└─────────────────────────────────────────────────────────┘
|
|
1227
|
+
const showUserPost = action(
|
|
1228
|
+
Effect.gen(function* () {
|
|
1229
|
+
const user = yield* bound('user')
|
|
1230
|
+
const post = yield* bound('post') // Already scoped to user
|
|
1231
|
+
return yield* render('Users/Posts/Show', { user, post })
|
|
1232
|
+
})
|
|
1233
|
+
)
|
|
1449
1234
|
```
|
|
1450
1235
|
|
|
1451
|
-
###
|
|
1236
|
+
### Mixed Notation
|
|
1452
1237
|
|
|
1453
1238
|
```typescript
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
UnauthorizedError,
|
|
1457
|
-
NotFoundError,
|
|
1458
|
-
ForbiddenError,
|
|
1459
|
-
HttpError,
|
|
1460
|
-
} from 'honertia/effect'
|
|
1461
|
-
|
|
1462
|
-
// Validation errors automatically re-render with field errors
|
|
1463
|
-
const input = yield* validateRequest(schema, {
|
|
1464
|
-
errorComponent: 'Projects/Create',
|
|
1465
|
-
})
|
|
1239
|
+
// :version is a regular param, {project} is bound
|
|
1240
|
+
effectRoutes(app).get('/api/:version/projects/{project}', showProject)
|
|
1466
1241
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1242
|
+
const showProject = action(
|
|
1243
|
+
Effect.gen(function* () {
|
|
1244
|
+
const request = yield* RequestService
|
|
1245
|
+
const version = request.param('version') // Regular param
|
|
1246
|
+
const project = yield* bound('project') // Database model
|
|
1247
|
+
return yield* json({ version, project })
|
|
1248
|
+
})
|
|
1470
1249
|
)
|
|
1471
|
-
|
|
1472
|
-
if (!project) {
|
|
1473
|
-
return yield* notFound('Project', projectId)
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
if (project.userId !== user.user.id) {
|
|
1477
|
-
return yield* forbidden('You cannot view this project')
|
|
1478
|
-
}
|
|
1479
1250
|
```
|
|
1480
1251
|
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
### Layers
|
|
1252
|
+
### With Param Validation
|
|
1484
1253
|
|
|
1485
1254
|
```typescript
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
.
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
// Require guest - fails if user IS logged in
|
|
1494
|
-
effectRoutes(app)
|
|
1495
|
-
.provide(RequireGuestLayer)
|
|
1496
|
-
.get('/login', showLogin)
|
|
1255
|
+
// Validate UUID format before database lookup
|
|
1256
|
+
effectRoutes(app).get(
|
|
1257
|
+
'/projects/{project}',
|
|
1258
|
+
showProject,
|
|
1259
|
+
{ params: S.Struct({ project: uuid }) }
|
|
1260
|
+
)
|
|
1497
1261
|
```
|
|
1498
1262
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
```typescript
|
|
1502
|
-
import {
|
|
1503
|
-
requireAuth,
|
|
1504
|
-
requireGuest,
|
|
1505
|
-
isAuthenticated,
|
|
1506
|
-
currentUser,
|
|
1507
|
-
} from 'honertia'
|
|
1508
|
-
|
|
1509
|
-
// In a handler
|
|
1510
|
-
export const showProfile = Effect.gen(function* () {
|
|
1511
|
-
const user = yield* requireAuth('/login') // Redirect to /login if not auth'd
|
|
1512
|
-
return yield* render('Profile', { user: user.user })
|
|
1513
|
-
})
|
|
1263
|
+
---
|
|
1514
1264
|
|
|
1515
|
-
|
|
1516
|
-
const authed = yield* isAuthenticated // boolean
|
|
1517
|
-
const user = yield* currentUser // AuthUser | null
|
|
1518
|
-
```
|
|
1265
|
+
## Auth Examples
|
|
1519
1266
|
|
|
1520
|
-
###
|
|
1267
|
+
### Auth Routes Setup
|
|
1521
1268
|
|
|
1522
1269
|
```typescript
|
|
1523
1270
|
import { effectAuthRoutes } from 'honertia/auth'
|
|
1524
|
-
import { loginUser, registerUser, logoutUser, verify2FA, forgotPassword } from './actions/auth'
|
|
1525
1271
|
|
|
1526
1272
|
effectAuthRoutes(app, {
|
|
1527
|
-
// Page
|
|
1528
|
-
loginPath: '/login', // GET: show login page
|
|
1529
|
-
registerPath: '/register', // GET: show register page
|
|
1530
|
-
logoutPath: '/logout', // POST: logout and redirect
|
|
1531
|
-
apiPath: '/api/auth', // Better-auth API handler
|
|
1532
|
-
logoutRedirect: '/login',
|
|
1533
|
-
loginRedirect: '/',
|
|
1273
|
+
// Page components
|
|
1534
1274
|
loginComponent: 'Auth/Login',
|
|
1535
1275
|
registerComponent: 'Auth/Register',
|
|
1536
1276
|
|
|
1537
|
-
// Form actions
|
|
1538
|
-
loginAction: loginUser,
|
|
1539
|
-
registerAction: registerUser,
|
|
1540
|
-
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',
|
|
1541
1291
|
|
|
1542
|
-
// Extended
|
|
1292
|
+
// Extended flows
|
|
1543
1293
|
guestActions: {
|
|
1544
1294
|
'/login/2fa': verify2FA,
|
|
1545
1295
|
'/forgot-password': forgotPassword,
|
|
1546
1296
|
},
|
|
1547
|
-
})
|
|
1548
|
-
```
|
|
1549
|
-
|
|
1550
|
-
All `loginAction`, `registerAction`, and `guestActions` are automatically wrapped with
|
|
1551
|
-
`RequireGuestLayer`, so authenticated users will be redirected. The `logoutAction` is
|
|
1552
|
-
not wrapped (logout should work regardless of auth state).
|
|
1553
1297
|
|
|
1554
|
-
|
|
1555
|
-
By default, no CORS headers are added (recommended when your UI and API share the same origin).
|
|
1556
|
-
Use this when your frontend is on a different origin (local dev, separate domain, mobile app, etc.).
|
|
1557
|
-
|
|
1558
|
-
```typescript
|
|
1559
|
-
effectAuthRoutes(app, {
|
|
1560
|
-
apiPath: '/api/auth',
|
|
1298
|
+
// CORS for API (if frontend on different origin)
|
|
1561
1299
|
cors: {
|
|
1562
|
-
origin: ['http://localhost:5173'
|
|
1300
|
+
origin: ['http://localhost:5173'],
|
|
1563
1301
|
credentials: true,
|
|
1564
1302
|
},
|
|
1565
1303
|
})
|
|
1566
1304
|
```
|
|
1567
1305
|
|
|
1568
|
-
|
|
1569
|
-
Always keep the `origin` list tight; avoid `'*'` for auth endpoints, especially with `credentials: true`.
|
|
1570
|
-
|
|
1571
|
-
### Better-auth Form Actions
|
|
1572
|
-
|
|
1573
|
-
Honertia provides `betterAuthFormAction` to handle the common pattern of form-based
|
|
1574
|
-
authentication: validate input, call better-auth, map errors to field-level messages,
|
|
1575
|
-
and redirect on success. This bridges better-auth's JSON responses with Inertia's
|
|
1576
|
-
form handling conventions.
|
|
1306
|
+
### Login Action
|
|
1577
1307
|
|
|
1578
1308
|
```typescript
|
|
1579
|
-
// src/actions/auth/login.ts
|
|
1580
1309
|
import { betterAuthFormAction } from 'honertia/auth'
|
|
1581
1310
|
import { Schema as S } from 'effect'
|
|
1582
|
-
import {
|
|
1583
|
-
import type { Auth } from './lib/auth' // your better-auth instance type
|
|
1311
|
+
import { email, requiredString } from 'honertia/effect'
|
|
1584
1312
|
|
|
1585
1313
|
const LoginSchema = S.Struct({
|
|
1586
|
-
email,
|
|
1314
|
+
email: email,
|
|
1587
1315
|
password: requiredString,
|
|
1588
1316
|
})
|
|
1589
1317
|
|
|
1590
|
-
|
|
1591
|
-
const mapLoginError = (error: { code?: string; message?: string }) => {
|
|
1318
|
+
const mapLoginError = (error: { code?: string }) => {
|
|
1592
1319
|
switch (error.code) {
|
|
1593
1320
|
case 'INVALID_EMAIL_OR_PASSWORD':
|
|
1594
1321
|
return { email: 'Invalid email or password' }
|
|
1595
1322
|
case 'USER_NOT_FOUND':
|
|
1596
1323
|
return { email: 'No account found with this email' }
|
|
1597
|
-
case 'INVALID_PASSWORD':
|
|
1598
|
-
return { password: 'Incorrect password' }
|
|
1599
1324
|
default:
|
|
1600
|
-
return { email:
|
|
1325
|
+
return { email: 'Login failed' }
|
|
1601
1326
|
}
|
|
1602
1327
|
}
|
|
1603
1328
|
|
|
@@ -1606,10 +1331,7 @@ export const loginUser = betterAuthFormAction({
|
|
|
1606
1331
|
errorComponent: 'Auth/Login',
|
|
1607
1332
|
redirectTo: '/',
|
|
1608
1333
|
errorMapper: mapLoginError,
|
|
1609
|
-
|
|
1610
|
-
// `input` is the validated form data
|
|
1611
|
-
// `request` is the original Request (needed for session cookies)
|
|
1612
|
-
call: (auth: Auth, input, request) =>
|
|
1334
|
+
call: (auth, input, request) =>
|
|
1613
1335
|
auth.api.signInEmail({
|
|
1614
1336
|
body: { email: input.email, password: input.password },
|
|
1615
1337
|
request,
|
|
@@ -1618,27 +1340,25 @@ export const loginUser = betterAuthFormAction({
|
|
|
1618
1340
|
})
|
|
1619
1341
|
```
|
|
1620
1342
|
|
|
1343
|
+
### Register Action
|
|
1344
|
+
|
|
1621
1345
|
```typescript
|
|
1622
|
-
// src/actions/auth/register.ts
|
|
1623
1346
|
import { betterAuthFormAction } from 'honertia/auth'
|
|
1624
1347
|
import { Schema as S } from 'effect'
|
|
1625
|
-
import {
|
|
1626
|
-
import type { Auth } from './lib/auth'
|
|
1348
|
+
import { email, requiredString, password } from 'honertia/effect'
|
|
1627
1349
|
|
|
1628
1350
|
const RegisterSchema = S.Struct({
|
|
1629
1351
|
name: requiredString,
|
|
1630
|
-
email,
|
|
1352
|
+
email: email,
|
|
1631
1353
|
password: password({ min: 8, letters: true, numbers: true }),
|
|
1632
1354
|
})
|
|
1633
1355
|
|
|
1634
|
-
const mapRegisterError = (error: { code?: string
|
|
1356
|
+
const mapRegisterError = (error: { code?: string }) => {
|
|
1635
1357
|
switch (error.code) {
|
|
1636
1358
|
case 'USER_ALREADY_EXISTS':
|
|
1637
1359
|
return { email: 'An account with this email already exists' }
|
|
1638
|
-
case 'PASSWORD_TOO_SHORT':
|
|
1639
|
-
return { password: 'Password must be at least 8 characters' }
|
|
1640
1360
|
default:
|
|
1641
|
-
return { email:
|
|
1361
|
+
return { email: 'Registration failed' }
|
|
1642
1362
|
}
|
|
1643
1363
|
}
|
|
1644
1364
|
|
|
@@ -1647,7 +1367,7 @@ export const registerUser = betterAuthFormAction({
|
|
|
1647
1367
|
errorComponent: 'Auth/Register',
|
|
1648
1368
|
redirectTo: '/',
|
|
1649
1369
|
errorMapper: mapRegisterError,
|
|
1650
|
-
call: (auth
|
|
1370
|
+
call: (auth, input, request) =>
|
|
1651
1371
|
auth.api.signUpEmail({
|
|
1652
1372
|
body: { name: input.name, email: input.email, password: input.password },
|
|
1653
1373
|
request,
|
|
@@ -1656,10 +1376,9 @@ export const registerUser = betterAuthFormAction({
|
|
|
1656
1376
|
})
|
|
1657
1377
|
```
|
|
1658
1378
|
|
|
1659
|
-
|
|
1379
|
+
### Logout Action
|
|
1660
1380
|
|
|
1661
1381
|
```typescript
|
|
1662
|
-
// src/actions/auth/logout.ts
|
|
1663
1382
|
import { betterAuthLogoutAction } from 'honertia/auth'
|
|
1664
1383
|
|
|
1665
1384
|
export const logoutUser = betterAuthLogoutAction({
|
|
@@ -1667,179 +1386,213 @@ export const logoutUser = betterAuthLogoutAction({
|
|
|
1667
1386
|
})
|
|
1668
1387
|
```
|
|
1669
1388
|
|
|
1670
|
-
|
|
1389
|
+
### Auth Layers
|
|
1390
|
+
|
|
1391
|
+
```typescript
|
|
1392
|
+
import { RequireAuthLayer, RequireGuestLayer } from 'honertia/auth'
|
|
1671
1393
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1394
|
+
// Require logged-in user (redirects to /login if not)
|
|
1395
|
+
effectRoutes(app)
|
|
1396
|
+
.provide(RequireAuthLayer)
|
|
1397
|
+
.group((route) => {
|
|
1398
|
+
route.get('/dashboard', showDashboard)
|
|
1399
|
+
route.get('/settings', showSettings)
|
|
1400
|
+
})
|
|
1675
1401
|
|
|
1676
|
-
|
|
1402
|
+
// Require guest (redirects to / if logged in)
|
|
1403
|
+
effectRoutes(app)
|
|
1404
|
+
.provide(RequireGuestLayer)
|
|
1405
|
+
.group((route) => {
|
|
1406
|
+
route.get('/login', showLogin)
|
|
1407
|
+
route.get('/register', showRegister)
|
|
1408
|
+
})
|
|
1409
|
+
```
|
|
1677
1410
|
|
|
1678
|
-
###
|
|
1411
|
+
### Manual Auth Check in Action
|
|
1679
1412
|
|
|
1680
1413
|
```typescript
|
|
1681
|
-
import
|
|
1414
|
+
import { authorize, isAuthenticated, currentUser } from 'honertia/effect'
|
|
1682
1415
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
}
|
|
1416
|
+
// Require auth (fails if not logged in)
|
|
1417
|
+
const auth = yield* authorize()
|
|
1686
1418
|
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
<div>
|
|
1690
|
-
{errors?.name && <span className="error">{errors.name}</span>}
|
|
1691
|
-
{projects.map(p => <ProjectCard key={p.id} project={p} />)}
|
|
1692
|
-
</div>
|
|
1693
|
-
)
|
|
1694
|
-
}
|
|
1419
|
+
// Require specific role
|
|
1420
|
+
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1695
1421
|
|
|
1696
|
-
|
|
1422
|
+
// Check without failing
|
|
1423
|
+
const isLoggedIn = yield* isAuthenticated // boolean
|
|
1424
|
+
const user = yield* currentUser // AuthUser | null
|
|
1697
1425
|
```
|
|
1698
1426
|
|
|
1699
|
-
|
|
1427
|
+
---
|
|
1700
1428
|
|
|
1701
|
-
|
|
1429
|
+
## Error Handling Examples
|
|
1430
|
+
|
|
1431
|
+
### Throwing Errors
|
|
1702
1432
|
|
|
1703
1433
|
```typescript
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
) : (
|
|
1712
|
-
<a href="/login">Login</a>
|
|
1713
|
-
)}
|
|
1714
|
-
{children}
|
|
1715
|
-
</div>
|
|
1716
|
-
)
|
|
1717
|
-
}
|
|
1718
|
-
```
|
|
1434
|
+
import {
|
|
1435
|
+
notFound,
|
|
1436
|
+
forbidden,
|
|
1437
|
+
httpError,
|
|
1438
|
+
ValidationError,
|
|
1439
|
+
UnauthorizedError,
|
|
1440
|
+
} from 'honertia/effect'
|
|
1719
1441
|
|
|
1720
|
-
|
|
1442
|
+
// 404 Not Found
|
|
1443
|
+
return yield* notFound('Project', projectId)
|
|
1721
1444
|
|
|
1722
|
-
|
|
1445
|
+
// 403 Forbidden
|
|
1446
|
+
return yield* forbidden('You cannot edit this project')
|
|
1723
1447
|
|
|
1724
|
-
|
|
1448
|
+
// Custom HTTP error
|
|
1449
|
+
return yield* httpError(429, 'Rate limit exceeded', { retryAfter: 60 })
|
|
1725
1450
|
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
import * as schema from '~/db/schema'
|
|
1451
|
+
// Manual validation error
|
|
1452
|
+
yield* Effect.fail(new ValidationError({
|
|
1453
|
+
errors: { email: 'This email is already taken' },
|
|
1454
|
+
component: 'Auth/Register',
|
|
1455
|
+
}))
|
|
1732
1456
|
|
|
1733
|
-
//
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1457
|
+
// Manual unauthorized
|
|
1458
|
+
yield* Effect.fail(new UnauthorizedError({
|
|
1459
|
+
message: 'Session expired',
|
|
1460
|
+
redirectTo: '/login',
|
|
1461
|
+
}))
|
|
1462
|
+
```
|
|
1739
1463
|
|
|
1740
|
-
|
|
1741
|
-
db: Database
|
|
1742
|
-
auth: Auth
|
|
1743
|
-
}
|
|
1464
|
+
### Error Handler Setup
|
|
1744
1465
|
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
Bindings: Bindings
|
|
1748
|
-
Variables: Variables
|
|
1749
|
-
}
|
|
1466
|
+
```typescript
|
|
1467
|
+
import { registerErrorHandlers } from 'honertia'
|
|
1750
1468
|
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1469
|
+
registerErrorHandlers(app, {
|
|
1470
|
+
component: 'Error', // Error page component
|
|
1471
|
+
showDevErrors: true, // Show details in dev
|
|
1472
|
+
envKey: 'ENVIRONMENT',
|
|
1473
|
+
devValue: 'development',
|
|
1474
|
+
})
|
|
1475
|
+
```
|
|
1757
1476
|
|
|
1758
|
-
|
|
1759
|
-
type: Auth
|
|
1760
|
-
}
|
|
1477
|
+
### Error Page Component
|
|
1761
1478
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1479
|
+
```tsx
|
|
1480
|
+
// src/pages/Error.tsx
|
|
1481
|
+
interface ErrorProps {
|
|
1482
|
+
status: number
|
|
1483
|
+
code: string
|
|
1484
|
+
title: string
|
|
1485
|
+
message: string
|
|
1486
|
+
hint?: string // Only in dev
|
|
1487
|
+
fixes?: Array<{ description: string }> // Only in dev
|
|
1488
|
+
source?: { file: string; line: number } // Only in dev
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
export default function Error({ status, title, message, hint, fixes }: ErrorProps) {
|
|
1492
|
+
return (
|
|
1493
|
+
<div className="error-page">
|
|
1494
|
+
<h1>{status}</h1>
|
|
1495
|
+
<h2>{title}</h2>
|
|
1496
|
+
<p>{message}</p>
|
|
1497
|
+
{hint && <p className="hint">{hint}</p>}
|
|
1498
|
+
{fixes?.map((fix, i) => <div key={i}>{fix.description}</div>)}
|
|
1499
|
+
</div>
|
|
1500
|
+
)
|
|
1764
1501
|
}
|
|
1765
1502
|
```
|
|
1766
1503
|
|
|
1767
|
-
|
|
1504
|
+
---
|
|
1505
|
+
|
|
1506
|
+
## Response Helpers
|
|
1768
1507
|
|
|
1769
1508
|
```typescript
|
|
1770
|
-
|
|
1771
|
-
import type { Env } from './types'
|
|
1509
|
+
import { render, redirect, json, notFound, forbidden, httpError } from 'honertia/effect'
|
|
1772
1510
|
|
|
1773
|
-
|
|
1511
|
+
// Render page with props
|
|
1512
|
+
return yield* render('Projects/Index', { projects })
|
|
1774
1513
|
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
db: c.var.db, // ✅ c.var.db is typed
|
|
1780
|
-
secret: c.env.BETTER_AUTH_SECRET,
|
|
1781
|
-
}),
|
|
1782
|
-
// ...
|
|
1783
|
-
},
|
|
1784
|
-
}))
|
|
1785
|
-
```
|
|
1514
|
+
// Render with validation errors
|
|
1515
|
+
return yield* renderWithErrors('Projects/Create', {
|
|
1516
|
+
name: 'Name is required',
|
|
1517
|
+
})
|
|
1786
1518
|
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1519
|
+
// Redirect (303 for POST, 302 otherwise)
|
|
1520
|
+
return yield* redirect('/projects')
|
|
1521
|
+
return yield* redirect('/login', 302)
|
|
1790
1522
|
|
|
1791
|
-
|
|
1792
|
-
|
|
1523
|
+
// JSON response
|
|
1524
|
+
return yield* json({ success: true })
|
|
1525
|
+
return yield* json({ error: 'Not found' }, 404)
|
|
1793
1526
|
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
)
|
|
1527
|
+
// Error responses
|
|
1528
|
+
return yield* notFound('Project')
|
|
1529
|
+
return yield* forbidden('Access denied')
|
|
1530
|
+
return yield* httpError(429, 'Rate limited')
|
|
1797
1531
|
```
|
|
1798
1532
|
|
|
1799
|
-
|
|
1533
|
+
---
|
|
1800
1534
|
|
|
1801
|
-
##
|
|
1535
|
+
## Services Reference
|
|
1802
1536
|
|
|
1803
|
-
|
|
1537
|
+
| Service | Description | Usage |
|
|
1538
|
+
|---------|-------------|-------|
|
|
1539
|
+
| `DatabaseService` | Drizzle database client | `const db = yield* DatabaseService` |
|
|
1540
|
+
| `AuthService` | Better-auth instance | `const auth = yield* AuthService` |
|
|
1541
|
+
| `AuthUserService` | Current user session | `const user = yield* AuthUserService` |
|
|
1542
|
+
| `BindingsService` | Cloudflare bindings | `const { KV } = yield* BindingsService` |
|
|
1543
|
+
| `RequestService` | Request context | `const req = yield* RequestService` |
|
|
1804
1544
|
|
|
1805
|
-
|
|
1545
|
+
### Using BindingsService
|
|
1806
1546
|
|
|
1807
1547
|
```typescript
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
const
|
|
1811
|
-
|
|
1812
|
-
|
|
1548
|
+
import { BindingsService } from 'honertia/effect'
|
|
1549
|
+
|
|
1550
|
+
const handler = action(
|
|
1551
|
+
Effect.gen(function* () {
|
|
1552
|
+
const { KV, R2, QUEUE } = yield* BindingsService
|
|
1553
|
+
|
|
1554
|
+
const cached = yield* Effect.tryPromise(() => KV.get('key'))
|
|
1555
|
+
yield* Effect.tryPromise(() => QUEUE.send({ type: 'event' }))
|
|
1556
|
+
|
|
1557
|
+
return yield* json({ cached })
|
|
1558
|
+
})
|
|
1559
|
+
)
|
|
1813
1560
|
```
|
|
1814
1561
|
|
|
1815
|
-
|
|
1562
|
+
---
|
|
1816
1563
|
|
|
1817
|
-
|
|
1564
|
+
## Environment
|
|
1818
1565
|
|
|
1819
|
-
|
|
1566
|
+
```toml
|
|
1567
|
+
# wrangler.toml
|
|
1568
|
+
[vars]
|
|
1569
|
+
ENVIRONMENT = "production"
|
|
1570
|
+
```
|
|
1820
1571
|
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1572
|
+
```bash
|
|
1573
|
+
# Secrets (not in source control)
|
|
1574
|
+
wrangler secret put DATABASE_URL
|
|
1575
|
+
wrangler secret put BETTER_AUTH_SECRET
|
|
1576
|
+
```
|
|
1824
1577
|
|
|
1825
|
-
|
|
1578
|
+
---
|
|
1826
1579
|
|
|
1827
|
-
##
|
|
1580
|
+
## Testing
|
|
1828
1581
|
|
|
1829
|
-
|
|
1830
|
-
- Laravel by Taylor Otwell and the Laravel community
|
|
1582
|
+
Actions generated with CLI include inline tests:
|
|
1831
1583
|
|
|
1832
|
-
|
|
1584
|
+
```bash
|
|
1585
|
+
# Test single action
|
|
1586
|
+
bun test src/actions/projects/create.ts
|
|
1833
1587
|
|
|
1834
|
-
|
|
1588
|
+
# Test all actions in a resource
|
|
1589
|
+
bun test src/actions/projects/
|
|
1835
1590
|
|
|
1836
|
-
|
|
1591
|
+
# Run project checks
|
|
1592
|
+
honertia check --verbose
|
|
1593
|
+
```
|
|
1837
1594
|
|
|
1838
|
-
|
|
1839
|
-
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
1840
|
-
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
1841
|
-
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
1842
|
-
5. Open a Pull Request
|
|
1595
|
+
---
|
|
1843
1596
|
|
|
1844
1597
|
## License
|
|
1845
1598
|
|