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 CHANGED
@@ -11,11 +11,11 @@
11
11
 
12
12
  ## Overview
13
13
 
14
- An Inertia.js-style adapter for Hono with Effect.js 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.
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've found myself wanting to use Cloudflare Workers for everything, but having come from a Laravel background nothing quite matched the DX and simplicity of Laravel x Inertia.js. When building Laravel projects I would always use Loris Leiva's laravel-actions package among other opinionated architecture decisions such as Vite, Tailwind, Bun, React etc., all of which have or will be incorporated into this project. With Cloudflare Workers the obvious choice is Hono and so we've adapted the Inertia.js protocol to run on workers+hono to mimic a Laravel-style app. Ever since learning of Effect.ts I've known that I wanted to use it for something bigger, and so we've utilised it here. Ultimately this is a workers+hono+vite+bun+laravel+inertia+effect+betterauth+planetscale mashup.
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
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](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 (login, register, logout, API handler) wired to better-auth.
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
- const projects = yield* Effect.tryPromise(() =>
447
- db.query.projects.findMany({
448
- where: eq(schema.projects.userId, user.user.id),
449
- limit: 5,
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
- return yield* render('Dashboard/Index', { projects })
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
- // Form submission with redirect
457
- export const createProject = Effect.gen(function* () {
458
- const db = yield* DatabaseService
459
- const user = yield* AuthUserService
460
- const input = yield* validateRequest(CreateProjectSchema)
462
+ return yield* render('Dashboard/Index', { projects })
463
+ })
464
+ )
461
465
 
462
- yield* Effect.tryPromise(() =>
463
- db.insert(schema.projects).values({
464
- ...input,
465
- userId: user.user.id,
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
- return yield* redirect('/projects')
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
- ## Action Factories
870
+ ### Better-auth Form Actions
839
871
 
840
- For common patterns, use action factories:
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
- import { effectAction, dbAction, authAction } from 'honertia'
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
- // effectAction: validation + custom handler
846
- export const updateSettings = effectAction(
847
- SettingsSchema,
848
- (input) => Effect.gen(function* () {
849
- yield* saveSettings(input)
850
- return yield* redirect('/settings')
851
- }),
852
- { errorComponent: 'Settings/Edit' }
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
- // dbAction: validation + db + auth
856
- export const createProject = dbAction(
857
- CreateProjectSchema,
858
- (input, { db, user }) => Effect.gen(function* () {
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({ ...input, userId: user.user.id })
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
- // authAction: just requires auth
868
- export const showDashboard = authAction((user) =>
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 data = yield* fetchDashboardData(user)
871
- return yield* render('Dashboard', data)
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.