honertia 0.1.2 → 0.1.4
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 +526 -205
- package/dist/auth.d.ts +1 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +1 -1
- package/dist/effect/action.d.ts +43 -83
- package/dist/effect/action.d.ts.map +1 -1
- package/dist/effect/action.js +57 -116
- package/dist/effect/auth.d.ts +79 -3
- package/dist/effect/auth.d.ts.map +1 -1
- package/dist/effect/auth.js +191 -21
- package/dist/effect/index.d.ts +2 -2
- package/dist/effect/index.d.ts.map +1 -1
- package/dist/effect/index.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
|
|
12
12
|
## Overview
|
|
13
13
|
|
|
14
|
-
An Inertia.js-style adapter for Hono with Effect.
|
|
14
|
+
An Inertia.js-style adapter for Hono with Effect.ts integration. Inertia keeps a server-driven app but behaves like an SPA: link clicks and form posts are intercepted, a fetch/XHR request returns a JSON page object (component + props), and the client swaps the page without a full reload. Honertia layers Laravel-style route patterns and Effect actions on top of that so handlers stay clean, readable, and composable.
|
|
15
15
|
|
|
16
16
|
## Raison d'être
|
|
17
17
|
|
|
18
|
-
I
|
|
18
|
+
I wanted to build on Cloudflare Workers whilst retaining the ergonomics of the Laravel+Inertia combination. There are certain patterns that I always use with Laravel (such as the laravel-actions package) and so we have incorporated those ideas into honertia. Now, we can't have Laravel in javascript - but we can create it in the aggregate. For auth we have used better-auth, for validation we have leant on Effect's Schema, and for the database we are using Drizzle. To make it testable and hardy we wrapped everything in Effect.ts
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
21
21
|
|
|
@@ -25,6 +25,37 @@ bun add honertia
|
|
|
25
25
|
|
|
26
26
|
## Quick Start
|
|
27
27
|
|
|
28
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/PatrickOgilvie/honertia-worker-demo)
|
|
29
|
+
|
|
30
|
+
### Recommended File Structure
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
.
|
|
34
|
+
├── src/
|
|
35
|
+
│ ├── index.ts # Hono app setup (setupHonertia)
|
|
36
|
+
│ ├── routes.ts # effectRoutes / effectAuthRoutes
|
|
37
|
+
│ ├── main.tsx # Inertia + React client entry
|
|
38
|
+
│ ├── styles.css # Tailwind CSS entry
|
|
39
|
+
│ ├── actions/
|
|
40
|
+
│ │ └── projects/
|
|
41
|
+
│ │ └── list.ts # listProjects action
|
|
42
|
+
│ ├── pages/
|
|
43
|
+
│ │ └── Projects/
|
|
44
|
+
│ │ └── Index.tsx # render('Projects/Index')
|
|
45
|
+
│ ├── db/
|
|
46
|
+
│ │ └── db.ts
|
|
47
|
+
│ │ └── schema.ts
|
|
48
|
+
│ ├── lib/
|
|
49
|
+
│ │ └── auth.ts
|
|
50
|
+
│ └── types.ts
|
|
51
|
+
├── dist/
|
|
52
|
+
│ └── manifest.json # generated by Vite build
|
|
53
|
+
├── vite.config.ts
|
|
54
|
+
├── wrangler.toml # or wrangler.jsonc
|
|
55
|
+
├── package.json
|
|
56
|
+
└── tsconfig.json
|
|
57
|
+
```
|
|
58
|
+
|
|
28
59
|
```typescript
|
|
29
60
|
// src/index.ts
|
|
30
61
|
import { Hono } from 'hono'
|
|
@@ -33,8 +64,8 @@ import { setupHonertia, createTemplate, createVersion, registerErrorHandlers, vi
|
|
|
33
64
|
import { Context, Layer } from 'effect'
|
|
34
65
|
import manifest from '../dist/manifest.json'
|
|
35
66
|
|
|
36
|
-
import { createDb } from './db'
|
|
37
67
|
import type { Env } from './types'
|
|
68
|
+
import { createDb } from './db/db'
|
|
38
69
|
import { createAuth } from './lib/auth'
|
|
39
70
|
import { registerRoutes } from './routes'
|
|
40
71
|
|
|
@@ -100,13 +131,17 @@ import type { Env } from './types'
|
|
|
100
131
|
import { effectRoutes } from 'honertia/effect'
|
|
101
132
|
import { effectAuthRoutes, RequireAuthLayer } from 'honertia/auth'
|
|
102
133
|
import { showDashboard, listProjects, createProject, showProject, deleteProject } from './actions'
|
|
134
|
+
import { loginUser, registerUser, logoutUser } from './actions/auth'
|
|
103
135
|
|
|
104
136
|
export function registerRoutes(app: Hono<Env>) {
|
|
105
|
-
// Auth routes
|
|
106
|
-
// CORS for /api/auth/* can be enabled via the `cors` option (see below).
|
|
137
|
+
// Auth routes: pages, form actions, logout, and API handler in one place.
|
|
107
138
|
effectAuthRoutes(app, {
|
|
108
139
|
loginComponent: 'Auth/Login',
|
|
109
140
|
registerComponent: 'Auth/Register',
|
|
141
|
+
// Form actions (automatically wrapped with RequireGuestLayer)
|
|
142
|
+
loginAction: loginUser,
|
|
143
|
+
registerAction: registerUser,
|
|
144
|
+
logoutAction: logoutUser,
|
|
110
145
|
})
|
|
111
146
|
|
|
112
147
|
// Effect routes give you typed, DI-friendly handlers (no direct Hono ctx).
|
|
@@ -126,6 +161,126 @@ export function registerRoutes(app: Hono<Env>) {
|
|
|
126
161
|
}
|
|
127
162
|
```
|
|
128
163
|
|
|
164
|
+
### Example Action
|
|
165
|
+
|
|
166
|
+
Here's the `listProjects` action referenced above:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// src/actions/projects/list.ts
|
|
170
|
+
import { Effect } from 'effect'
|
|
171
|
+
import { eq } from 'drizzle-orm'
|
|
172
|
+
import { DatabaseService, AuthUserService, render, type AuthUser } from 'honertia/effect'
|
|
173
|
+
import { schema, type Database, type Project } from '../../db'
|
|
174
|
+
|
|
175
|
+
interface ProjectsIndexProps {
|
|
176
|
+
projects: Project[]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const fetchProjects = (
|
|
180
|
+
db: Database,
|
|
181
|
+
user: AuthUser
|
|
182
|
+
): Effect.Effect<ProjectsIndexProps, Error, never> =>
|
|
183
|
+
Effect.tryPromise({
|
|
184
|
+
try: async () => {
|
|
185
|
+
const projects = await db.query.projects.findMany({
|
|
186
|
+
where: eq(schema.projects.userId, user.user.id),
|
|
187
|
+
orderBy: (projects, { desc }) => [desc(projects.createdAt)],
|
|
188
|
+
})
|
|
189
|
+
return { projects }
|
|
190
|
+
},
|
|
191
|
+
catch: (error) => error instanceof Error ? error : new Error(String(error)),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
export const listProjects = Effect.gen(function* () {
|
|
195
|
+
const db = yield* DatabaseService
|
|
196
|
+
const user = yield* AuthUserService
|
|
197
|
+
const props = yield* fetchProjects(db as Database, user)
|
|
198
|
+
return yield* render('Projects/Index', props)
|
|
199
|
+
})
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The component name `Projects/Index` maps to a file on disk. A common
|
|
203
|
+
Vite + React layout is:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
src/pages/Projects/Index.tsx
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
That means the folders mirror the component path, and `Index.tsx` is the file
|
|
210
|
+
that exports the page component. In the example below, `Link` comes from
|
|
211
|
+
`@inertiajs/react` because it performs Inertia client-side visits (preserving
|
|
212
|
+
page state and avoiding full reloads), whereas a plain `<a>` would do a full
|
|
213
|
+
navigation.
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
// src/pages/Projects/Index.tsx
|
|
217
|
+
/**
|
|
218
|
+
* Projects Index Page
|
|
219
|
+
*/
|
|
220
|
+
|
|
221
|
+
import { Link } from '@inertiajs/react'
|
|
222
|
+
import Layout from '~/components/Layout'
|
|
223
|
+
import type { PageProps, Project } from '~/types'
|
|
224
|
+
|
|
225
|
+
interface Props {
|
|
226
|
+
projects: Project[]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default function ProjectsIndex({ projects }: PageProps<Props>) {
|
|
230
|
+
return (
|
|
231
|
+
<Layout>
|
|
232
|
+
<div className="flex justify-between items-center mb-6">
|
|
233
|
+
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
|
|
234
|
+
<Link
|
|
235
|
+
href="/projects/create"
|
|
236
|
+
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"
|
|
237
|
+
>
|
|
238
|
+
New Project
|
|
239
|
+
</Link>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div className="bg-white rounded-lg shadow">
|
|
243
|
+
{projects.length === 0 ? (
|
|
244
|
+
<div className="p-6 text-center text-gray-500">
|
|
245
|
+
No projects yet.{' '}
|
|
246
|
+
<Link href="/projects/create" className="text-indigo-600 hover:underline">
|
|
247
|
+
Create your first project
|
|
248
|
+
</Link>
|
|
249
|
+
</div>
|
|
250
|
+
) : (
|
|
251
|
+
<ul className="divide-y divide-gray-200">
|
|
252
|
+
{projects.map((project) => (
|
|
253
|
+
<li key={project.id}>
|
|
254
|
+
<Link
|
|
255
|
+
href={`/projects/${project.id}`}
|
|
256
|
+
className="block px-6 py-4 hover:bg-gray-50"
|
|
257
|
+
>
|
|
258
|
+
<div className="flex justify-between items-start">
|
|
259
|
+
<div>
|
|
260
|
+
<h3 className="text-sm font-medium text-gray-900">
|
|
261
|
+
{project.name}
|
|
262
|
+
</h3>
|
|
263
|
+
{project.description && (
|
|
264
|
+
<p className="text-sm text-gray-500 mt-1">
|
|
265
|
+
{project.description}
|
|
266
|
+
</p>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
<span className="text-sm text-gray-400">
|
|
270
|
+
{new Date(project.createdAt).toLocaleDateString()}
|
|
271
|
+
</span>
|
|
272
|
+
</div>
|
|
273
|
+
</Link>
|
|
274
|
+
</li>
|
|
275
|
+
))}
|
|
276
|
+
</ul>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
</Layout>
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
129
284
|
### Environment Variables
|
|
130
285
|
|
|
131
286
|
Honertia reads these from `c.env` (Cloudflare Workers bindings):
|
|
@@ -254,155 +409,6 @@ Optional dev convenience: if you want to run the worker without building the
|
|
|
254
409
|
client, you can keep a stub `dist/manifest.json` (ignored by git) and replace it
|
|
255
410
|
once you run `vite build`.
|
|
256
411
|
|
|
257
|
-
### Recommended File Structure
|
|
258
|
-
|
|
259
|
-
Here's a minimal project layout that matches the Quick Start:
|
|
260
|
-
|
|
261
|
-
```
|
|
262
|
-
.
|
|
263
|
-
├── src/
|
|
264
|
-
│ ├── index.ts # Hono app setup (setupHonertia)
|
|
265
|
-
│ ├── routes.ts # effectRoutes / effectAuthRoutes
|
|
266
|
-
│ ├── main.tsx # Inertia + React client entry
|
|
267
|
-
│ ├── styles.css # Tailwind CSS entry
|
|
268
|
-
│ ├── actions/
|
|
269
|
-
│ │ └── projects/
|
|
270
|
-
│ │ └── list.ts # listProjects action
|
|
271
|
-
│ ├── pages/
|
|
272
|
-
│ │ └── Projects/
|
|
273
|
-
│ │ └── Index.tsx # render('Projects/Index')
|
|
274
|
-
│ ├── db.ts
|
|
275
|
-
│ ├── lib/
|
|
276
|
-
│ │ └── auth.ts
|
|
277
|
-
│ └── types.ts
|
|
278
|
-
├── dist/
|
|
279
|
-
│ └── manifest.json # generated by Vite build
|
|
280
|
-
├── vite.config.ts
|
|
281
|
-
├── wrangler.toml # or wrangler.jsonc
|
|
282
|
-
├── package.json
|
|
283
|
-
└── tsconfig.json
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
### Example Action
|
|
287
|
-
|
|
288
|
-
Here's the `listProjects` action referenced above:
|
|
289
|
-
|
|
290
|
-
```typescript
|
|
291
|
-
// src/actions/projects/list.ts
|
|
292
|
-
import { Effect } from 'effect'
|
|
293
|
-
import { eq } from 'drizzle-orm'
|
|
294
|
-
import { DatabaseService, AuthUserService, render, type AuthUser } from 'honertia/effect'
|
|
295
|
-
import { schema, type Database, type Project } from '../../db'
|
|
296
|
-
|
|
297
|
-
interface ProjectsIndexProps {
|
|
298
|
-
projects: Project[]
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const fetchProjects = (
|
|
302
|
-
db: Database,
|
|
303
|
-
user: AuthUser
|
|
304
|
-
): Effect.Effect<ProjectsIndexProps, Error, never> =>
|
|
305
|
-
Effect.tryPromise({
|
|
306
|
-
try: async () => {
|
|
307
|
-
const projects = await db.query.projects.findMany({
|
|
308
|
-
where: eq(schema.projects.userId, user.user.id),
|
|
309
|
-
orderBy: (projects, { desc }) => [desc(projects.createdAt)],
|
|
310
|
-
})
|
|
311
|
-
return { projects }
|
|
312
|
-
},
|
|
313
|
-
catch: (error) => error instanceof Error ? error : new Error(String(error)),
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
export const listProjects = Effect.gen(function* () {
|
|
317
|
-
const db = yield* DatabaseService
|
|
318
|
-
const user = yield* AuthUserService
|
|
319
|
-
const props = yield* fetchProjects(db as Database, user)
|
|
320
|
-
return yield* render('Projects/Index', props)
|
|
321
|
-
})
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
The component name `Projects/Index` maps to a file on disk. A common
|
|
325
|
-
Vite + React layout is:
|
|
326
|
-
|
|
327
|
-
```
|
|
328
|
-
src/pages/Projects/Index.tsx
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
That means the folders mirror the component path, and `Index.tsx` is the file
|
|
332
|
-
that exports the page component. In the example below, `Link` comes from
|
|
333
|
-
`@inertiajs/react` because it performs Inertia client-side visits (preserving
|
|
334
|
-
page state and avoiding full reloads), whereas a plain `<a>` would do a full
|
|
335
|
-
navigation.
|
|
336
|
-
|
|
337
|
-
```tsx
|
|
338
|
-
// src/pages/Projects/Index.tsx
|
|
339
|
-
/**
|
|
340
|
-
* Projects Index Page
|
|
341
|
-
*/
|
|
342
|
-
|
|
343
|
-
import { Link } from '@inertiajs/react'
|
|
344
|
-
import Layout from '~/components/Layout'
|
|
345
|
-
import type { PageProps, Project } from '~/types'
|
|
346
|
-
|
|
347
|
-
interface Props {
|
|
348
|
-
projects: Project[]
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
export default function ProjectsIndex({ projects }: PageProps<Props>) {
|
|
352
|
-
return (
|
|
353
|
-
<Layout>
|
|
354
|
-
<div className="flex justify-between items-center mb-6">
|
|
355
|
-
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
|
|
356
|
-
<Link
|
|
357
|
-
href="/projects/create"
|
|
358
|
-
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"
|
|
359
|
-
>
|
|
360
|
-
New Project
|
|
361
|
-
</Link>
|
|
362
|
-
</div>
|
|
363
|
-
|
|
364
|
-
<div className="bg-white rounded-lg shadow">
|
|
365
|
-
{projects.length === 0 ? (
|
|
366
|
-
<div className="p-6 text-center text-gray-500">
|
|
367
|
-
No projects yet.{' '}
|
|
368
|
-
<Link href="/projects/create" className="text-indigo-600 hover:underline">
|
|
369
|
-
Create your first project
|
|
370
|
-
</Link>
|
|
371
|
-
</div>
|
|
372
|
-
) : (
|
|
373
|
-
<ul className="divide-y divide-gray-200">
|
|
374
|
-
{projects.map((project) => (
|
|
375
|
-
<li key={project.id}>
|
|
376
|
-
<Link
|
|
377
|
-
href={`/projects/${project.id}`}
|
|
378
|
-
className="block px-6 py-4 hover:bg-gray-50"
|
|
379
|
-
>
|
|
380
|
-
<div className="flex justify-between items-start">
|
|
381
|
-
<div>
|
|
382
|
-
<h3 className="text-sm font-medium text-gray-900">
|
|
383
|
-
{project.name}
|
|
384
|
-
</h3>
|
|
385
|
-
{project.description && (
|
|
386
|
-
<p className="text-sm text-gray-500 mt-1">
|
|
387
|
-
{project.description}
|
|
388
|
-
</p>
|
|
389
|
-
)}
|
|
390
|
-
</div>
|
|
391
|
-
<span className="text-sm text-gray-400">
|
|
392
|
-
{new Date(project.createdAt).toLocaleDateString()}
|
|
393
|
-
</span>
|
|
394
|
-
</div>
|
|
395
|
-
</Link>
|
|
396
|
-
</li>
|
|
397
|
-
))}
|
|
398
|
-
</ul>
|
|
399
|
-
)}
|
|
400
|
-
</div>
|
|
401
|
-
</Layout>
|
|
402
|
-
)
|
|
403
|
-
}
|
|
404
|
-
```
|
|
405
|
-
|
|
406
412
|
### Vite Helpers
|
|
407
413
|
|
|
408
414
|
The `vite` helper provides dev/prod asset management:
|
|
@@ -427,47 +433,55 @@ vite.hmrHead() // HMR preamble script tags for React Fast Refresh
|
|
|
427
433
|
|
|
428
434
|
### Effect-Based Handlers
|
|
429
435
|
|
|
430
|
-
Route handlers are Effect computations that return `Response | Redirect
|
|
436
|
+
Route handlers are Effect computations that return `Response | Redirect`. Actions are fully composable - you opt-in to features by yielding services:
|
|
431
437
|
|
|
432
438
|
```typescript
|
|
433
439
|
import { Effect } from 'effect'
|
|
434
440
|
import {
|
|
441
|
+
action,
|
|
442
|
+
authorize,
|
|
443
|
+
validateRequest,
|
|
435
444
|
DatabaseService,
|
|
436
|
-
AuthUserService,
|
|
437
445
|
render,
|
|
438
446
|
redirect,
|
|
439
|
-
} from 'honertia'
|
|
440
|
-
|
|
441
|
-
// Simple page render
|
|
442
|
-
export const showDashboard = Effect.gen(function* () {
|
|
443
|
-
const db = yield* DatabaseService
|
|
444
|
-
const user = yield* AuthUserService
|
|
447
|
+
} from 'honertia/effect'
|
|
445
448
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
)
|
|
449
|
+
// Simple page render with auth
|
|
450
|
+
export const showDashboard = action(
|
|
451
|
+
Effect.gen(function* () {
|
|
452
|
+
const auth = yield* authorize()
|
|
453
|
+
const db = yield* DatabaseService
|
|
452
454
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
+
const projects = yield* Effect.tryPromise(() =>
|
|
456
|
+
db.query.projects.findMany({
|
|
457
|
+
where: eq(schema.projects.userId, auth.user.id),
|
|
458
|
+
limit: 5,
|
|
459
|
+
})
|
|
460
|
+
)
|
|
455
461
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const user = yield* AuthUserService
|
|
460
|
-
const input = yield* validateRequest(CreateProjectSchema)
|
|
462
|
+
return yield* render('Dashboard/Index', { projects })
|
|
463
|
+
})
|
|
464
|
+
)
|
|
461
465
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
+
// Form submission with permissions, validation, and redirect
|
|
467
|
+
export const createProject = action(
|
|
468
|
+
Effect.gen(function* () {
|
|
469
|
+
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
470
|
+
const input = yield* validateRequest(CreateProjectSchema, {
|
|
471
|
+
errorComponent: 'Projects/Create',
|
|
466
472
|
})
|
|
467
|
-
|
|
473
|
+
const db = yield* DatabaseService
|
|
468
474
|
|
|
469
|
-
|
|
470
|
-
|
|
475
|
+
yield* Effect.tryPromise(() =>
|
|
476
|
+
db.insert(schema.projects).values({
|
|
477
|
+
...input,
|
|
478
|
+
userId: auth.user.id,
|
|
479
|
+
})
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return yield* redirect('/projects')
|
|
483
|
+
})
|
|
484
|
+
)
|
|
471
485
|
```
|
|
472
486
|
|
|
473
487
|
### Services
|
|
@@ -805,19 +819,37 @@ const user = yield* currentUser // AuthUser | null
|
|
|
805
819
|
### Built-in Auth Routes
|
|
806
820
|
|
|
807
821
|
```typescript
|
|
808
|
-
import { effectAuthRoutes } from 'honertia'
|
|
822
|
+
import { effectAuthRoutes } from 'honertia/auth'
|
|
823
|
+
import { loginUser, registerUser, logoutUser, verify2FA, forgotPassword } from './actions/auth'
|
|
809
824
|
|
|
810
825
|
effectAuthRoutes(app, {
|
|
826
|
+
// Page routes
|
|
811
827
|
loginPath: '/login', // GET: show login page
|
|
812
828
|
registerPath: '/register', // GET: show register page
|
|
813
829
|
logoutPath: '/logout', // POST: logout and redirect
|
|
814
830
|
apiPath: '/api/auth', // Better-auth API handler
|
|
815
831
|
logoutRedirect: '/login',
|
|
832
|
+
loginRedirect: '/',
|
|
816
833
|
loginComponent: 'Auth/Login',
|
|
817
834
|
registerComponent: 'Auth/Register',
|
|
835
|
+
|
|
836
|
+
// Form actions (automatically wrapped with RequireGuestLayer)
|
|
837
|
+
loginAction: loginUser, // POST /login
|
|
838
|
+
registerAction: registerUser, // POST /register
|
|
839
|
+
logoutAction: logoutUser, // POST /logout (overrides default)
|
|
840
|
+
|
|
841
|
+
// Extended auth flows (all guest-only POST routes)
|
|
842
|
+
guestActions: {
|
|
843
|
+
'/login/2fa': verify2FA,
|
|
844
|
+
'/forgot-password': forgotPassword,
|
|
845
|
+
},
|
|
818
846
|
})
|
|
819
847
|
```
|
|
820
848
|
|
|
849
|
+
All `loginAction`, `registerAction`, and `guestActions` are automatically wrapped with
|
|
850
|
+
`RequireGuestLayer`, so authenticated users will be redirected. The `logoutAction` is
|
|
851
|
+
not wrapped (logout should work regardless of auth state).
|
|
852
|
+
|
|
821
853
|
To enable CORS for the auth API handler (`/api/auth/*`), pass a `cors` config.
|
|
822
854
|
By default, no CORS headers are added (recommended when your UI and API share the same origin).
|
|
823
855
|
Use this when your frontend is on a different origin (local dev, separate domain, mobile app, etc.).
|
|
@@ -835,44 +867,326 @@ effectAuthRoutes(app, {
|
|
|
835
867
|
This sets the appropriate `Access-Control-*` headers and handles `OPTIONS` preflight for the auth API routes.
|
|
836
868
|
Always keep the `origin` list tight; avoid `'*'` for auth endpoints, especially with `credentials: true`.
|
|
837
869
|
|
|
838
|
-
|
|
870
|
+
### Better-auth Form Actions
|
|
839
871
|
|
|
840
|
-
|
|
872
|
+
Honertia provides `betterAuthFormAction` to handle the common pattern of form-based
|
|
873
|
+
authentication: validate input, call better-auth, map errors to field-level messages,
|
|
874
|
+
and redirect on success. This bridges better-auth's JSON responses with Inertia's
|
|
875
|
+
form handling conventions.
|
|
841
876
|
|
|
842
877
|
```typescript
|
|
843
|
-
|
|
878
|
+
// src/actions/auth/login.ts
|
|
879
|
+
import { betterAuthFormAction } from 'honertia/auth'
|
|
880
|
+
import { Schema as S } from 'effect'
|
|
881
|
+
import { requiredString, email } from 'honertia'
|
|
882
|
+
import type { Auth } from './lib/auth' // your better-auth instance type
|
|
844
883
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
884
|
+
const LoginSchema = S.Struct({
|
|
885
|
+
email,
|
|
886
|
+
password: requiredString,
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
// Map better-auth error codes to user-friendly field errors
|
|
890
|
+
const mapLoginError = (error: { code?: string; message?: string }) => {
|
|
891
|
+
switch (error.code) {
|
|
892
|
+
case 'INVALID_EMAIL_OR_PASSWORD':
|
|
893
|
+
return { email: 'Invalid email or password' }
|
|
894
|
+
case 'USER_NOT_FOUND':
|
|
895
|
+
return { email: 'No account found with this email' }
|
|
896
|
+
case 'INVALID_PASSWORD':
|
|
897
|
+
return { password: 'Incorrect password' }
|
|
898
|
+
default:
|
|
899
|
+
return { email: error.message ?? 'Login failed' }
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export const loginUser = betterAuthFormAction({
|
|
904
|
+
schema: LoginSchema,
|
|
905
|
+
errorComponent: 'Auth/Login',
|
|
906
|
+
redirectTo: '/',
|
|
907
|
+
errorMapper: mapLoginError,
|
|
908
|
+
// `auth` is the better-auth instance from AuthService
|
|
909
|
+
// `input` is the validated form data
|
|
910
|
+
// `request` is the original Request (needed for session cookies)
|
|
911
|
+
call: (auth: Auth, input, request) =>
|
|
912
|
+
auth.api.signInEmail({
|
|
913
|
+
body: { email: input.email, password: input.password },
|
|
914
|
+
request,
|
|
915
|
+
returnHeaders: true,
|
|
916
|
+
}),
|
|
917
|
+
})
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
```typescript
|
|
921
|
+
// src/actions/auth/register.ts
|
|
922
|
+
import { betterAuthFormAction } from 'honertia/auth'
|
|
923
|
+
import { Schema as S } from 'effect'
|
|
924
|
+
import { requiredString, email, password } from 'honertia'
|
|
925
|
+
import type { Auth } from './lib/auth'
|
|
926
|
+
|
|
927
|
+
const RegisterSchema = S.Struct({
|
|
928
|
+
name: requiredString,
|
|
929
|
+
email,
|
|
930
|
+
password: password({ min: 8, letters: true, numbers: true }),
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
const mapRegisterError = (error: { code?: string; message?: string }) => {
|
|
934
|
+
switch (error.code) {
|
|
935
|
+
case 'USER_ALREADY_EXISTS':
|
|
936
|
+
return { email: 'An account with this email already exists' }
|
|
937
|
+
case 'PASSWORD_TOO_SHORT':
|
|
938
|
+
return { password: 'Password must be at least 8 characters' }
|
|
939
|
+
default:
|
|
940
|
+
return { email: error.message ?? 'Registration failed' }
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
export const registerUser = betterAuthFormAction({
|
|
945
|
+
schema: RegisterSchema,
|
|
946
|
+
errorComponent: 'Auth/Register',
|
|
947
|
+
redirectTo: '/',
|
|
948
|
+
errorMapper: mapRegisterError,
|
|
949
|
+
call: (auth: Auth, input, request) =>
|
|
950
|
+
auth.api.signUpEmail({
|
|
951
|
+
body: { name: input.name, email: input.email, password: input.password },
|
|
952
|
+
request,
|
|
953
|
+
returnHeaders: true,
|
|
954
|
+
}),
|
|
955
|
+
})
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
For logout, use the simpler `betterAuthLogoutAction`:
|
|
959
|
+
|
|
960
|
+
```typescript
|
|
961
|
+
// src/actions/auth/logout.ts
|
|
962
|
+
import { betterAuthLogoutAction } from 'honertia/auth'
|
|
963
|
+
|
|
964
|
+
export const logoutUser = betterAuthLogoutAction({
|
|
965
|
+
redirectTo: '/login',
|
|
966
|
+
})
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
**How errors are handled:**
|
|
970
|
+
|
|
971
|
+
1. **Schema validation fails** → Re-renders `errorComponent` with field errors from Effect Schema
|
|
972
|
+
2. **better-auth returns an error** → Calls your `errorMapper`, then re-renders `errorComponent` with those errors
|
|
973
|
+
3. **Success** → Sets session cookies from better-auth's response headers, then 303 redirects to `redirectTo`
|
|
974
|
+
|
|
975
|
+
## Anatomy of an Action
|
|
976
|
+
|
|
977
|
+
Actions in Honertia are fully composable Effect computations. Instead of using different action factories for different combinations of features, you opt-in to exactly what you need by yielding services and helpers inside your action.
|
|
978
|
+
|
|
979
|
+
This design is inspired by Laravel's [laravel-actions](https://laravelactions.com/) package, where you opt-in to capabilities by adding methods to your action class. In Honertia, you opt-in by yielding services - the order of your `yield*` statements determines the execution order.
|
|
980
|
+
|
|
981
|
+
### The `action` Wrapper
|
|
982
|
+
|
|
983
|
+
The `action` function is a semantic wrapper that marks an Effect as an action:
|
|
984
|
+
|
|
985
|
+
```typescript
|
|
986
|
+
import { Effect } from 'effect'
|
|
987
|
+
import { action } from 'honertia/effect'
|
|
988
|
+
|
|
989
|
+
export const myAction = action(
|
|
990
|
+
Effect.gen(function* () {
|
|
991
|
+
// Your action logic here
|
|
992
|
+
return new Response('OK')
|
|
993
|
+
})
|
|
853
994
|
)
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
It's intentionally minimal - all the power comes from what you yield inside.
|
|
998
|
+
|
|
999
|
+
### Composable Helpers
|
|
1000
|
+
|
|
1001
|
+
#### `authorize` - Authentication & Authorization
|
|
1002
|
+
|
|
1003
|
+
Opt-in to authentication and authorization checks. Returns the authenticated user, fails with `UnauthorizedError` if no user is present, and fails with `ForbiddenError` if the check returns `false`.
|
|
1004
|
+
|
|
1005
|
+
```typescript
|
|
1006
|
+
import { authorize } from 'honertia/effect'
|
|
1007
|
+
|
|
1008
|
+
// Just require authentication (any logged-in user)
|
|
1009
|
+
const auth = yield* authorize()
|
|
1010
|
+
|
|
1011
|
+
// Require a specific role
|
|
1012
|
+
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1013
|
+
|
|
1014
|
+
// Require resource ownership
|
|
1015
|
+
const auth = yield* authorize((a) => a.user.id === project.userId)
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
If the check function returns `false`, the action fails immediately with a `ForbiddenError`.
|
|
1019
|
+
|
|
1020
|
+
#### `validateRequest` - Schema Validation
|
|
1021
|
+
|
|
1022
|
+
Opt-in to request validation using Effect Schema:
|
|
1023
|
+
|
|
1024
|
+
```typescript
|
|
1025
|
+
import { Schema as S } from 'effect'
|
|
1026
|
+
import { validateRequest, requiredString } from 'honertia/effect'
|
|
1027
|
+
|
|
1028
|
+
const input = yield* validateRequest(
|
|
1029
|
+
S.Struct({ name: requiredString, description: S.optional(S.String) }),
|
|
1030
|
+
{ errorComponent: 'Projects/Create' }
|
|
1031
|
+
)
|
|
1032
|
+
// input is fully typed: { name: string, description?: string }
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
On validation failure, re-renders `errorComponent` with field-level errors.
|
|
1036
|
+
|
|
1037
|
+
#### `DatabaseService` - Database Access
|
|
1038
|
+
|
|
1039
|
+
Opt-in to database access:
|
|
1040
|
+
|
|
1041
|
+
```typescript
|
|
1042
|
+
import { DatabaseService } from 'honertia/effect'
|
|
1043
|
+
|
|
1044
|
+
const db = yield* DatabaseService
|
|
1045
|
+
const projects = yield* Effect.tryPromise(() =>
|
|
1046
|
+
db.query.projects.findMany()
|
|
1047
|
+
)
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
#### `render` / `redirect` - Responses
|
|
1051
|
+
|
|
1052
|
+
Return responses from your action:
|
|
1053
|
+
|
|
1054
|
+
```typescript
|
|
1055
|
+
import { render, redirect } from 'honertia/effect'
|
|
1056
|
+
|
|
1057
|
+
// Render a page
|
|
1058
|
+
return yield* render('Projects/Index', { projects })
|
|
1059
|
+
|
|
1060
|
+
// Redirect after mutation
|
|
1061
|
+
return yield* redirect('/projects')
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
### Building an Action
|
|
854
1065
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1066
|
+
Here's how these composables work together:
|
|
1067
|
+
|
|
1068
|
+
```typescript
|
|
1069
|
+
import { Effect, Schema as S } from 'effect'
|
|
1070
|
+
import {
|
|
1071
|
+
action,
|
|
1072
|
+
authorize,
|
|
1073
|
+
validateRequest,
|
|
1074
|
+
DatabaseService,
|
|
1075
|
+
redirect,
|
|
1076
|
+
requiredString,
|
|
1077
|
+
} from 'honertia/effect'
|
|
1078
|
+
|
|
1079
|
+
const CreateProjectSchema = S.Struct({
|
|
1080
|
+
name: requiredString,
|
|
1081
|
+
description: S.optional(S.String),
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
export const createProject = action(
|
|
1085
|
+
Effect.gen(function* () {
|
|
1086
|
+
// 1. Authorization - fail fast if not allowed
|
|
1087
|
+
const auth = yield* authorize((a) => a.user.role === 'author')
|
|
1088
|
+
|
|
1089
|
+
// 2. Validation - parse and validate request body
|
|
1090
|
+
const input = yield* validateRequest(CreateProjectSchema, {
|
|
1091
|
+
errorComponent: 'Projects/Create',
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
// 3. Database - perform the mutation
|
|
1095
|
+
const db = yield* DatabaseService
|
|
859
1096
|
yield* Effect.tryPromise(() =>
|
|
860
|
-
db.insert(projects).values({
|
|
1097
|
+
db.insert(projects).values({
|
|
1098
|
+
...input,
|
|
1099
|
+
userId: auth.user.id,
|
|
1100
|
+
})
|
|
861
1101
|
)
|
|
1102
|
+
|
|
1103
|
+
// 4. Response - redirect on success
|
|
862
1104
|
return yield* redirect('/projects')
|
|
863
|
-
})
|
|
864
|
-
{ errorComponent: 'Projects/Create' }
|
|
1105
|
+
})
|
|
865
1106
|
)
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
### Execution Order Matters
|
|
1110
|
+
|
|
1111
|
+
The order you yield services determines when they execute:
|
|
1112
|
+
|
|
1113
|
+
```typescript
|
|
1114
|
+
// Authorization BEFORE validation (recommended for mutations)
|
|
1115
|
+
// Don't waste cycles validating if user can't perform the action
|
|
1116
|
+
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1117
|
+
const input = yield* validateRequest(schema)
|
|
1118
|
+
|
|
1119
|
+
// Validation BEFORE authorization (when you need input for auth check)
|
|
1120
|
+
const input = yield* validateRequest(schema)
|
|
1121
|
+
const auth = yield* authorize((a) => a.user.id === input.ownerId)
|
|
1122
|
+
```
|
|
866
1123
|
|
|
867
|
-
|
|
868
|
-
|
|
1124
|
+
### Type Safety
|
|
1125
|
+
|
|
1126
|
+
Effect tracks all service requirements at the type level. Your action's type signature shows exactly what it needs:
|
|
1127
|
+
|
|
1128
|
+
```typescript
|
|
1129
|
+
// This action requires: RequestService, DatabaseService
|
|
1130
|
+
export const createProject: Effect.Effect<
|
|
1131
|
+
Response | Redirect,
|
|
1132
|
+
ValidationError | UnauthorizedError | ForbiddenError | Error,
|
|
1133
|
+
RequestService | DatabaseService
|
|
1134
|
+
>
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
The compiler ensures all required services are provided when the action runs.
|
|
1138
|
+
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.
|
|
1139
|
+
|
|
1140
|
+
### Minimal Actions
|
|
1141
|
+
|
|
1142
|
+
Not every action needs all features. Use only what you need:
|
|
1143
|
+
|
|
1144
|
+
```typescript
|
|
1145
|
+
// Public page - no auth, no validation
|
|
1146
|
+
export const showAbout = action(
|
|
1147
|
+
Effect.gen(function* () {
|
|
1148
|
+
return yield* render('About', {})
|
|
1149
|
+
})
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
// Read-only authenticated page
|
|
1153
|
+
export const showDashboard = action(
|
|
1154
|
+
Effect.gen(function* () {
|
|
1155
|
+
const auth = yield* authorize()
|
|
1156
|
+
const db = yield* DatabaseService
|
|
1157
|
+
const stats = yield* fetchStats(db, auth)
|
|
1158
|
+
return yield* render('Dashboard', { stats })
|
|
1159
|
+
})
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
// API endpoint with just validation
|
|
1163
|
+
export const searchProjects = action(
|
|
869
1164
|
Effect.gen(function* () {
|
|
870
|
-
const
|
|
871
|
-
|
|
1165
|
+
const { query } = yield* validateRequest(S.Struct({ query: S.String }))
|
|
1166
|
+
const db = yield* DatabaseService
|
|
1167
|
+
const results = yield* search(db, query)
|
|
1168
|
+
return yield* json({ results })
|
|
872
1169
|
})
|
|
873
1170
|
)
|
|
874
1171
|
```
|
|
875
1172
|
|
|
1173
|
+
### Helper Utilities
|
|
1174
|
+
|
|
1175
|
+
#### `dbTransaction` - Database Transactions
|
|
1176
|
+
|
|
1177
|
+
Run multiple database operations in a transaction with automatic rollback on failure:
|
|
1178
|
+
|
|
1179
|
+
```typescript
|
|
1180
|
+
import { dbTransaction } from 'honertia/effect'
|
|
1181
|
+
|
|
1182
|
+
yield* dbTransaction(async (tx) => {
|
|
1183
|
+
await tx.insert(users).values({ name: 'Alice', email: 'alice@example.com' })
|
|
1184
|
+
await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
|
|
1185
|
+
// If any operation fails, the entire transaction rolls back
|
|
1186
|
+
return { success: true }
|
|
1187
|
+
})
|
|
1188
|
+
```
|
|
1189
|
+
|
|
876
1190
|
## React Integration
|
|
877
1191
|
|
|
878
1192
|
### Page Component Type
|
|
@@ -943,6 +1257,13 @@ On Cloudflare Workers, database connections and other I/O objects are isolated p
|
|
|
943
1257
|
|
|
944
1258
|
If you're using PlanetScale with Hyperdrive, the "connection" you create per request is lightweight - it's just a client pointing at Hyperdrive's persistent connection pool.
|
|
945
1259
|
|
|
1260
|
+
## Acknowledgements
|
|
1261
|
+
|
|
1262
|
+
- Inertia.js by Jonathan Reinink and its contributors
|
|
1263
|
+
- Laravel by Taylor Otwell and the Laravel community
|
|
1264
|
+
|
|
1265
|
+
🐐
|
|
1266
|
+
|
|
946
1267
|
## Contributing
|
|
947
1268
|
|
|
948
1269
|
Contributions are welcome! Please feel free to submit a Pull Request.
|