honertia 0.1.2 → 0.1.3

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:
@@ -805,19 +811,37 @@ const user = yield* currentUser // AuthUser | null
805
811
  ### Built-in Auth Routes
806
812
 
807
813
  ```typescript
808
- import { effectAuthRoutes } from 'honertia'
814
+ import { effectAuthRoutes } from 'honertia/auth'
815
+ import { loginUser, registerUser, logoutUser, verify2FA, forgotPassword } from './actions/auth'
809
816
 
810
817
  effectAuthRoutes(app, {
818
+ // Page routes
811
819
  loginPath: '/login', // GET: show login page
812
820
  registerPath: '/register', // GET: show register page
813
821
  logoutPath: '/logout', // POST: logout and redirect
814
822
  apiPath: '/api/auth', // Better-auth API handler
815
823
  logoutRedirect: '/login',
824
+ loginRedirect: '/',
816
825
  loginComponent: 'Auth/Login',
817
826
  registerComponent: 'Auth/Register',
827
+
828
+ // Form actions (automatically wrapped with RequireGuestLayer)
829
+ loginAction: loginUser, // POST /login
830
+ registerAction: registerUser, // POST /register
831
+ logoutAction: logoutUser, // POST /logout (overrides default)
832
+
833
+ // Extended auth flows (all guest-only POST routes)
834
+ guestActions: {
835
+ '/login/2fa': verify2FA,
836
+ '/forgot-password': forgotPassword,
837
+ },
818
838
  })
819
839
  ```
820
840
 
841
+ All `loginAction`, `registerAction`, and `guestActions` are automatically wrapped with
842
+ `RequireGuestLayer`, so authenticated users will be redirected. The `logoutAction` is
843
+ not wrapped (logout should work regardless of auth state).
844
+
821
845
  To enable CORS for the auth API handler (`/api/auth/*`), pass a `cors` config.
822
846
  By default, no CORS headers are added (recommended when your UI and API share the same origin).
823
847
  Use this when your frontend is on a different origin (local dev, separate domain, mobile app, etc.).
@@ -835,6 +859,111 @@ effectAuthRoutes(app, {
835
859
  This sets the appropriate `Access-Control-*` headers and handles `OPTIONS` preflight for the auth API routes.
836
860
  Always keep the `origin` list tight; avoid `'*'` for auth endpoints, especially with `credentials: true`.
837
861
 
862
+ ### Better-auth Form Actions
863
+
864
+ Honertia provides `betterAuthFormAction` to handle the common pattern of form-based
865
+ authentication: validate input, call better-auth, map errors to field-level messages,
866
+ and redirect on success. This bridges better-auth's JSON responses with Inertia's
867
+ form handling conventions.
868
+
869
+ ```typescript
870
+ // src/actions/auth/login.ts
871
+ import { betterAuthFormAction } from 'honertia/auth'
872
+ import { Schema as S } from 'effect'
873
+ import { requiredString, email } from 'honertia'
874
+ import type { Auth } from './lib/auth' // your better-auth instance type
875
+
876
+ const LoginSchema = S.Struct({
877
+ email,
878
+ password: requiredString,
879
+ })
880
+
881
+ // Map better-auth error codes to user-friendly field errors
882
+ const mapLoginError = (error: { code?: string; message?: string }) => {
883
+ switch (error.code) {
884
+ case 'INVALID_EMAIL_OR_PASSWORD':
885
+ return { email: 'Invalid email or password' }
886
+ case 'USER_NOT_FOUND':
887
+ return { email: 'No account found with this email' }
888
+ case 'INVALID_PASSWORD':
889
+ return { password: 'Incorrect password' }
890
+ default:
891
+ return { email: error.message ?? 'Login failed' }
892
+ }
893
+ }
894
+
895
+ export const loginUser = betterAuthFormAction({
896
+ schema: LoginSchema,
897
+ errorComponent: 'Auth/Login',
898
+ redirectTo: '/',
899
+ errorMapper: mapLoginError,
900
+ // `auth` is the better-auth instance from AuthService
901
+ // `input` is the validated form data
902
+ // `request` is the original Request (needed for session cookies)
903
+ call: (auth: Auth, input, request) =>
904
+ auth.api.signInEmail({
905
+ body: { email: input.email, password: input.password },
906
+ request,
907
+ returnHeaders: true,
908
+ }),
909
+ })
910
+ ```
911
+
912
+ ```typescript
913
+ // src/actions/auth/register.ts
914
+ import { betterAuthFormAction } from 'honertia/auth'
915
+ import { Schema as S } from 'effect'
916
+ import { requiredString, email, password } from 'honertia'
917
+ import type { Auth } from './lib/auth'
918
+
919
+ const RegisterSchema = S.Struct({
920
+ name: requiredString,
921
+ email,
922
+ password: password({ min: 8, letters: true, numbers: true }),
923
+ })
924
+
925
+ const mapRegisterError = (error: { code?: string; message?: string }) => {
926
+ switch (error.code) {
927
+ case 'USER_ALREADY_EXISTS':
928
+ return { email: 'An account with this email already exists' }
929
+ case 'PASSWORD_TOO_SHORT':
930
+ return { password: 'Password must be at least 8 characters' }
931
+ default:
932
+ return { email: error.message ?? 'Registration failed' }
933
+ }
934
+ }
935
+
936
+ export const registerUser = betterAuthFormAction({
937
+ schema: RegisterSchema,
938
+ errorComponent: 'Auth/Register',
939
+ redirectTo: '/',
940
+ errorMapper: mapRegisterError,
941
+ call: (auth: Auth, input, request) =>
942
+ auth.api.signUpEmail({
943
+ body: { name: input.name, email: input.email, password: input.password },
944
+ request,
945
+ returnHeaders: true,
946
+ }),
947
+ })
948
+ ```
949
+
950
+ For logout, use the simpler `betterAuthLogoutAction`:
951
+
952
+ ```typescript
953
+ // src/actions/auth/logout.ts
954
+ import { betterAuthLogoutAction } from 'honertia/auth'
955
+
956
+ export const logoutUser = betterAuthLogoutAction({
957
+ redirectTo: '/login',
958
+ })
959
+ ```
960
+
961
+ **How errors are handled:**
962
+
963
+ 1. **Schema validation fails** → Re-renders `errorComponent` with field errors from Effect Schema
964
+ 2. **better-auth returns an error** → Calls your `errorMapper`, then re-renders `errorComponent` with those errors
965
+ 3. **Success** → Sets session cookies from better-auth's response headers, then 303 redirects to `redirectTo`
966
+
838
967
  ## Action Factories
839
968
 
840
969
  For common patterns, use action factories:
@@ -943,6 +1072,13 @@ On Cloudflare Workers, database connections and other I/O objects are isolated p
943
1072
 
944
1073
  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
1074
 
1075
+ ## Acknowledgements
1076
+
1077
+ - Inertia.js by Jonathan Reinink and its contributors
1078
+ - Laravel by Taylor Otwell and the Laravel community
1079
+
1080
+ 🐐
1081
+
946
1082
  ## Contributing
947
1083
 
948
1084
  Contributions are welcome! Please feel free to submit a Pull Request.
package/dist/auth.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Re-exports all authentication and authorization functionality.
5
5
  * Import from 'honertia/auth' for auth-related functionality.
6
6
  */
7
- export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, loadUser, type AuthRoutesConfig, } from './effect/auth.js';
7
+ export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, betterAuthFormAction, betterAuthLogoutAction, loadUser, type AuthRoutesConfig, type AuthActionEffect, type BetterAuthFormActionConfig, type BetterAuthLogoutConfig, type BetterAuthActionResult, } from './effect/auth.js';
8
8
  export { AuthService, AuthUserService, type AuthUser, } from './effect/services.js';
9
9
  export { UnauthorizedError, ForbiddenError, } from './effect/errors.js';
10
10
  //# sourceMappingURL=auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,QAAQ,EACR,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EACL,WAAW,EACX,eAAe,EACf,KAAK,QAAQ,GACd,MAAM,sBAAsB,CAAA;AAG7B,OAAO,EACL,iBAAiB,EACjB,cAAc,GACf,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,EACtB,QAAQ,EACR,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EACL,WAAW,EACX,eAAe,EACf,KAAK,QAAQ,GACd,MAAM,sBAAsB,CAAA;AAG7B,OAAO,EACL,iBAAiB,EACjB,cAAc,GACf,MAAM,oBAAoB,CAAA"}
package/dist/auth.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Re-exports all authentication and authorization functionality.
5
5
  * Import from 'honertia/auth' for auth-related functionality.
6
6
  */
7
- export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, loadUser, } from './effect/auth.js';
7
+ export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, betterAuthFormAction, betterAuthLogoutAction, loadUser, } from './effect/auth.js';
8
8
  // Re-export auth-related services
9
9
  export { AuthService, AuthUserService, } from './effect/services.js';
10
10
  // Re-export auth-related errors
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Authentication and authorization via Effect Layers.
5
5
  */
6
- import { Effect, Layer } from 'effect';
6
+ import { Effect, Layer, Schema as S } from 'effect';
7
7
  import type { Hono, MiddlewareHandler, Env } from 'hono';
8
- import { AuthUserService, HonertiaService, type AuthUser } from './services.js';
9
- import { UnauthorizedError } from './errors.js';
8
+ import { AuthUserService, AuthService, HonertiaService, RequestService, type AuthUser } from './services.js';
9
+ import { UnauthorizedError, ValidationError } from './errors.js';
10
+ import { type EffectHandler } from './routing.js';
10
11
  /**
11
12
  * Layer that requires an authenticated user.
12
13
  * Fails with UnauthorizedError if no user is present.
@@ -51,6 +52,11 @@ export declare const shareAuth: Effect.Effect<void, never, HonertiaService>;
51
52
  * Middleware version of shareAuth for use with app.use().
52
53
  */
53
54
  export declare function shareAuthMiddleware<E extends Env>(): MiddlewareHandler<E>;
55
+ /**
56
+ * An auth action effect that returns a Response.
57
+ * Used for loginAction, registerAction, logoutAction, and guestActions.
58
+ */
59
+ export type AuthActionEffect<R = never, E extends Error = Error> = EffectHandler<R, E>;
54
60
  /**
55
61
  * Configuration for auth routes.
56
62
  */
@@ -60,6 +66,9 @@ export interface AuthRoutesConfig<E extends Env> {
60
66
  logoutPath?: string;
61
67
  apiPath?: string;
62
68
  logoutRedirect?: string;
69
+ /**
70
+ * Redirect path for authenticated users hitting login/register pages.
71
+ */
63
72
  loginRedirect?: string;
64
73
  loginComponent?: string;
65
74
  registerComponent?: string;
@@ -72,6 +81,37 @@ export interface AuthRoutesConfig<E extends Env> {
72
81
  origin: string | string[] | ((origin: string) => string | undefined | null);
73
82
  credentials?: boolean;
74
83
  };
84
+ /**
85
+ * POST handler for login form submission.
86
+ * Automatically wrapped with RequireGuestLayer.
87
+ * Use betterAuthFormAction to create this.
88
+ */
89
+ loginAction?: AuthActionEffect;
90
+ /**
91
+ * POST handler for registration form submission.
92
+ * Automatically wrapped with RequireGuestLayer.
93
+ * Use betterAuthFormAction to create this.
94
+ */
95
+ registerAction?: AuthActionEffect;
96
+ /**
97
+ * POST handler for logout.
98
+ * If not provided, uses a default handler that calls auth.api.signOut.
99
+ * Use betterAuthLogoutAction to create this.
100
+ */
101
+ logoutAction?: AuthActionEffect;
102
+ /**
103
+ * Additional guest-only POST routes for extended auth flows.
104
+ * Keys are paths (e.g., '/forgot-password'), values are Effect handlers.
105
+ * All routes are wrapped with RequireGuestLayer.
106
+ *
107
+ * @example
108
+ * guestActions: {
109
+ * '/forgot-password': forgotPasswordAction,
110
+ * '/reset-password': resetPasswordAction,
111
+ * '/login/2fa': verify2FAAction,
112
+ * }
113
+ */
114
+ guestActions?: Record<string, AuthActionEffect>;
75
115
  }
76
116
  /**
77
117
  * Register standard auth routes.
@@ -80,6 +120,8 @@ export interface AuthRoutesConfig<E extends Env> {
80
120
  * effectAuthRoutes(app, {
81
121
  * loginComponent: 'Auth/Login',
82
122
  * registerComponent: 'Auth/Register',
123
+ * loginAction: loginUser,
124
+ * registerAction: registerUser,
83
125
  * })
84
126
  */
85
127
  export declare function effectAuthRoutes<E extends Env>(app: Hono<E>, config?: AuthRoutesConfig<E>): void;
@@ -91,4 +133,38 @@ export declare function loadUser<E extends Env>(config?: {
91
133
  userKey?: string;
92
134
  sessionCookie?: string;
93
135
  }): MiddlewareHandler<E>;
136
+ /**
137
+ * Result types from better-auth calls that expose headers.
138
+ */
139
+ export type BetterAuthActionResult = Response | Headers | {
140
+ headers?: Headers | HeadersInit;
141
+ };
142
+ /**
143
+ * Config for better-auth form actions (login/register).
144
+ */
145
+ export interface BetterAuthFormActionConfig<A, I, AuthClient = unknown> {
146
+ schema: S.Schema<A, I>;
147
+ errorComponent: string;
148
+ call: (auth: AuthClient, input: A, request: Request) => Promise<BetterAuthActionResult>;
149
+ errorMapper?: (error: unknown) => Record<string, string>;
150
+ redirectTo?: string | ((input: A, result: BetterAuthActionResult) => string);
151
+ }
152
+ /**
153
+ * Create a better-auth form action with Honertia-friendly responses.
154
+ *
155
+ * Copies Set-Cookie headers from better-auth and redirects with 303.
156
+ * Maps errors into ValidationError so the standard error handler can render.
157
+ */
158
+ export declare function betterAuthFormAction<A, I, AuthClient = unknown>(config: BetterAuthFormActionConfig<A, I, AuthClient>): Effect.Effect<Response, ValidationError, RequestService | AuthService>;
159
+ /**
160
+ * Config for better-auth logout actions.
161
+ */
162
+ export interface BetterAuthLogoutConfig {
163
+ redirectTo?: string;
164
+ cookieNames?: string[];
165
+ }
166
+ /**
167
+ * Create a better-auth logout action that clears cookies and redirects.
168
+ */
169
+ export declare function betterAuthLogoutAction(config?: BetterAuthLogoutConfig): Effect.Effect<Response, never, RequestService | AuthService>;
94
170
  //# sourceMappingURL=auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/effect/auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,KAAK,EAAU,MAAM,QAAQ,CAAA;AAC9C,OAAO,KAAK,EAAE,IAAI,EAAE,iBAAiB,EAAE,GAAG,EAAE,MAAM,MAAM,CAAA;AACxD,OAAO,EAAE,eAAe,EAAe,eAAe,EAAkB,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAA;AAC5G,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAI/C;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,wDAiB5B,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,8CAe7B,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,KAAK,EAAE,KAAK,CACM,CAAA;AAEvE;;GAEG;AACH,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,CAGlE,CAAA;AAEH;;GAEG;AACH,eAAO,MAAM,WAAW,GACtB,mBAAqB,KACpB,MAAM,CAAC,MAAM,CAAC,QAAQ,EAAE,iBAAiB,EAAE,KAAK,CAQhD,CAAA;AAEH;;GAEG;AACH,eAAO,MAAM,YAAY,GACvB,mBAAgB,KACf,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,iBAAiB,EAAE,KAAK,CAQ5C,CAAA;AAEH;;GAEG;AACH,eAAO,MAAM,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,eAAe,CAK9D,CAAA;AAEJ;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,GAAG,KAAK,iBAAiB,CAAC,CAAC,CAAC,CASzE;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,GAAG;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;OAGG;IACH,IAAI,CAAC,EAAE;QACL,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC,CAAA;QAC3E,WAAW,CAAC,EAAE,OAAO,CAAA;KACtB,CAAA;CACF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,GAAG,EAC5C,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,EACZ,MAAM,GAAE,gBAAgB,CAAC,CAAC,CAAM,GAC/B,IAAI,CAoGN;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,GAAG,EACpC,MAAM,GAAE;IACN,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,MAAM,CAAA;CAClB,GACL,iBAAiB,CAAC,CAAC,CAAC,CAwBtB"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/effect/auth.ts"],"names":[],"mappings":"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"}
@@ -5,9 +5,10 @@
5
5
  */
6
6
  import { Effect, Layer, Option } from 'effect';
7
7
  import { AuthUserService, AuthService, HonertiaService, RequestService } from './services.js';
8
- import { UnauthorizedError } from './errors.js';
8
+ import { UnauthorizedError, ValidationError } from './errors.js';
9
9
  import { effectRoutes } from './routing.js';
10
10
  import { render } from './responses.js';
11
+ import { validateRequest } from './validation.js';
11
12
  /**
12
13
  * Layer that requires an authenticated user.
13
14
  * Fails with UnauthorizedError if no user is present.
@@ -101,39 +102,65 @@ export function shareAuthMiddleware() {
101
102
  * effectAuthRoutes(app, {
102
103
  * loginComponent: 'Auth/Login',
103
104
  * registerComponent: 'Auth/Register',
105
+ * loginAction: loginUser,
106
+ * registerAction: registerUser,
104
107
  * })
105
108
  */
106
109
  export function effectAuthRoutes(app, config = {}) {
107
- const { loginPath = '/login', registerPath = '/register', logoutPath = '/logout', apiPath = '/api/auth', logoutRedirect = '/login', loginComponent = 'Auth/Login', registerComponent = 'Auth/Register', } = config;
110
+ const { loginPath = '/login', registerPath = '/register', logoutPath = '/logout', apiPath = '/api/auth', logoutRedirect = '/login', loginRedirect = '/', loginComponent = 'Auth/Login', registerComponent = 'Auth/Register', } = config;
108
111
  const routes = effectRoutes(app);
109
112
  // Login page (guest only)
110
113
  routes.get(loginPath, Effect.gen(function* () {
111
- yield* requireGuest(loginPath === '/login' ? '/' : loginPath);
114
+ yield* requireGuest(loginRedirect);
112
115
  return yield* render(loginComponent);
113
116
  }));
114
117
  // Register page (guest only)
115
118
  routes.get(registerPath, Effect.gen(function* () {
116
- yield* requireGuest(registerPath === '/register' ? '/' : registerPath);
119
+ yield* requireGuest(loginRedirect);
117
120
  return yield* render(registerComponent);
118
121
  }));
119
- // Logout (POST)
120
- routes.post(logoutPath, Effect.gen(function* () {
121
- const auth = yield* AuthService;
122
- const request = yield* RequestService;
123
- // Revoke session server-side
124
- yield* Effect.tryPromise(() => auth.api.signOut({
125
- headers: request.headers,
122
+ // Login action (POST) - guest only
123
+ if (config.loginAction) {
124
+ routes
125
+ .provide(RequireGuestLayer)
126
+ .post(loginPath, config.loginAction);
127
+ }
128
+ // Register action (POST) - guest only
129
+ if (config.registerAction) {
130
+ routes
131
+ .provide(RequireGuestLayer)
132
+ .post(registerPath, config.registerAction);
133
+ }
134
+ // Logout (POST) - use provided action or default
135
+ if (config.logoutAction) {
136
+ routes.post(logoutPath, config.logoutAction);
137
+ }
138
+ else {
139
+ routes.post(logoutPath, Effect.gen(function* () {
140
+ const auth = yield* AuthService;
141
+ const request = yield* RequestService;
142
+ // Revoke session server-side
143
+ yield* Effect.tryPromise(() => auth.api.signOut({
144
+ headers: request.headers,
145
+ }));
146
+ // Clear cookie and redirect
147
+ const sessionCookie = config.sessionCookie ?? 'better-auth.session_token';
148
+ return new Response(null, {
149
+ status: 303,
150
+ headers: {
151
+ 'Location': logoutRedirect,
152
+ 'Set-Cookie': `${sessionCookie}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax`,
153
+ },
154
+ });
126
155
  }));
127
- // Clear cookie and redirect
128
- const sessionCookie = config.sessionCookie ?? 'better-auth.session_token';
129
- return new Response(null, {
130
- status: 303,
131
- headers: {
132
- 'Location': logoutRedirect,
133
- 'Set-Cookie': `${sessionCookie}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax`,
134
- },
135
- });
136
- }));
156
+ }
157
+ // Additional guest-only actions (2FA, forgot password, etc.)
158
+ if (config.guestActions) {
159
+ const guestRoutes = routes.provide(RequireGuestLayer);
160
+ for (const [path, action] of Object.entries(config.guestActions)) {
161
+ guestRoutes.post(path, action);
162
+ }
163
+ }
137
164
  // Better-auth API handler (handles sign-in, sign-up, etc.)
138
165
  // Apply CORS if configured
139
166
  if (config.cors) {
@@ -202,3 +229,146 @@ export function loadUser(config = {}) {
202
229
  await next();
203
230
  };
204
231
  }
232
+ /**
233
+ * Create a better-auth form action with Honertia-friendly responses.
234
+ *
235
+ * Copies Set-Cookie headers from better-auth and redirects with 303.
236
+ * Maps errors into ValidationError so the standard error handler can render.
237
+ */
238
+ export function betterAuthFormAction(config) {
239
+ return Effect.gen(function* () {
240
+ const auth = yield* AuthService;
241
+ const request = yield* RequestService;
242
+ const input = yield* validateRequest(config.schema, {
243
+ errorComponent: config.errorComponent,
244
+ });
245
+ const result = yield* Effect.tryPromise({
246
+ try: () => config.call(auth, input, buildAuthRequest(request)),
247
+ catch: (error) => error,
248
+ }).pipe(Effect.mapError((error) => new ValidationError({
249
+ errors: (config.errorMapper ?? defaultAuthErrorMapper)(error),
250
+ component: config.errorComponent,
251
+ })));
252
+ const redirectTo = resolveRedirect(config.redirectTo, input, result);
253
+ const responseHeaders = new Headers({ Location: redirectTo });
254
+ const resultHeaders = getHeaders(result);
255
+ if (resultHeaders) {
256
+ appendSetCookies(responseHeaders, resultHeaders);
257
+ }
258
+ return new Response(null, {
259
+ status: 303,
260
+ headers: responseHeaders,
261
+ });
262
+ });
263
+ }
264
+ /**
265
+ * Create a better-auth logout action that clears cookies and redirects.
266
+ */
267
+ export function betterAuthLogoutAction(config = {}) {
268
+ return Effect.gen(function* () {
269
+ const auth = yield* AuthService;
270
+ const request = yield* RequestService;
271
+ const result = yield* Effect.tryPromise({
272
+ try: () => auth.api.signOut({
273
+ headers: request.headers,
274
+ request: buildAuthRequest(request),
275
+ returnHeaders: true,
276
+ }),
277
+ catch: () => undefined,
278
+ }).pipe(Effect.catchAll(() => Effect.succeed(undefined)));
279
+ const responseHeaders = new Headers({
280
+ Location: config.redirectTo ?? '/login',
281
+ });
282
+ const resultHeaders = getHeaders(result);
283
+ if (resultHeaders) {
284
+ appendSetCookies(responseHeaders, resultHeaders);
285
+ }
286
+ if (!responseHeaders.has('set-cookie')) {
287
+ appendLogoutCookies(responseHeaders, config.cookieNames);
288
+ }
289
+ return new Response(null, {
290
+ status: 303,
291
+ headers: responseHeaders,
292
+ });
293
+ });
294
+ }
295
+ function buildAuthRequest(request) {
296
+ return new Request(request.url, {
297
+ method: request.method,
298
+ headers: request.headers,
299
+ });
300
+ }
301
+ function resolveRedirect(target, input, result) {
302
+ if (typeof target === 'function') {
303
+ return target(input, result);
304
+ }
305
+ return target ?? '/';
306
+ }
307
+ function getHeaders(result) {
308
+ if (!result)
309
+ return undefined;
310
+ if (result instanceof Headers)
311
+ return result;
312
+ if (result instanceof Response)
313
+ return result.headers;
314
+ if (typeof result === 'object' && 'headers' in result && result.headers) {
315
+ return coerceHeaders(result.headers);
316
+ }
317
+ return undefined;
318
+ }
319
+ function coerceHeaders(value) {
320
+ return value instanceof Headers ? value : new Headers(value);
321
+ }
322
+ function defaultAuthErrorMapper(error) {
323
+ const message = getAuthErrorMessage(error) ?? 'Unable to complete request. Please try again.';
324
+ return { form: message };
325
+ }
326
+ function getAuthErrorMessage(error) {
327
+ if (!error || typeof error !== 'object')
328
+ return undefined;
329
+ const candidate = error;
330
+ if (typeof candidate.body?.message === 'string')
331
+ return candidate.body.message;
332
+ if (typeof candidate.message === 'string')
333
+ return candidate.message;
334
+ return undefined;
335
+ }
336
+ function appendSetCookies(target, source) {
337
+ const sourceWithSetCookie = source;
338
+ if (typeof sourceWithSetCookie.getSetCookie === 'function') {
339
+ for (const cookie of sourceWithSetCookie.getSetCookie()) {
340
+ target.append('set-cookie', cookie);
341
+ }
342
+ return;
343
+ }
344
+ const setCookie = source.get('set-cookie');
345
+ if (!setCookie) {
346
+ return;
347
+ }
348
+ // Split on cookie boundaries without breaking Expires attributes.
349
+ const parts = setCookie
350
+ .split(/,(?=[^;]+?=)/g)
351
+ .map((part) => part.trim())
352
+ .filter(Boolean);
353
+ for (const cookie of parts) {
354
+ target.append('set-cookie', cookie);
355
+ }
356
+ }
357
+ function appendExpiredCookie(target, name, options = {}) {
358
+ const base = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax`;
359
+ const value = options.secure ? `${base}; Secure` : base;
360
+ target.append('set-cookie', value);
361
+ }
362
+ function appendLogoutCookies(target, cookieNames) {
363
+ const defaults = [
364
+ 'better-auth.session_token',
365
+ 'better-auth.session_data',
366
+ 'better-auth.account_data',
367
+ 'better-auth.dont_remember',
368
+ ];
369
+ const names = cookieNames?.length ? cookieNames : defaults;
370
+ for (const name of names) {
371
+ appendExpiredCookie(target, name);
372
+ appendExpiredCookie(target, `__Secure-${name}`, { secure: true });
373
+ }
374
+ }
@@ -12,5 +12,5 @@ export { effectHandler, effect, handle, errorToResponse, } from './handler.js';
12
12
  export { effectAction, dbAction, authAction, simpleAction, injectUser, dbOperation, prepareData, preparedAction, } from './action.js';
13
13
  export { redirect, render, renderWithErrors, json, text, notFound, forbidden, httpError, prefersJson, jsonOrRender, share, } from './responses.js';
14
14
  export { EffectRouteBuilder, effectRoutes, type EffectHandler, type BaseServices, } from './routing.js';
15
- export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, loadUser, type AuthRoutesConfig, } from './auth.js';
15
+ export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, betterAuthFormAction, betterAuthLogoutAction, loadUser, type AuthRoutesConfig, type BetterAuthFormActionConfig, type BetterAuthLogoutConfig, type BetterAuthActionResult, } from './auth.js';
16
16
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/effect/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EACL,eAAe,EACf,WAAW,EACX,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,KAAK,QAAQ,EACb,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AAGtB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,SAAS,EACT,QAAQ,EACR,KAAK,QAAQ,GACd,MAAM,aAAa,CAAA;AAGpB,cAAc,aAAa,CAAA;AAG3B,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,QAAQ,EACR,eAAe,GAChB,MAAM,iBAAiB,CAAA;AAGxB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,aAAa,EACb,MAAM,EACN,MAAM,EACN,eAAe,GAChB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,SAAS,EACT,WAAW,EACX,YAAY,EACZ,KAAK,GACN,MAAM,gBAAgB,CAAA;AAGvB,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,QAAQ,EACR,KAAK,gBAAgB,GACtB,MAAM,WAAW,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/effect/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EACL,eAAe,EACf,WAAW,EACX,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,KAAK,QAAQ,EACb,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AAGtB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,SAAS,EACT,QAAQ,EACR,KAAK,QAAQ,GACd,MAAM,aAAa,CAAA;AAGpB,cAAc,aAAa,CAAA;AAG3B,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,QAAQ,EACR,eAAe,GAChB,MAAM,iBAAiB,CAAA;AAGxB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,aAAa,EACb,MAAM,EACN,MAAM,EACN,eAAe,GAChB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,SAAS,EACT,WAAW,EACX,YAAY,EACZ,KAAK,GACN,MAAM,gBAAgB,CAAA;AAGvB,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,EACtB,QAAQ,EACR,KAAK,gBAAgB,EACrB,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,WAAW,CAAA"}
@@ -22,4 +22,4 @@ export { redirect, render, renderWithErrors, json, text, notFound, forbidden, ht
22
22
  // Routing
23
23
  export { EffectRouteBuilder, effectRoutes, } from './routing.js';
24
24
  // Auth
25
- export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, loadUser, } from './auth.js';
25
+ export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, betterAuthFormAction, betterAuthLogoutAction, loadUser, } from './auth.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honertia",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Inertia.js-style server-driven SPA adapter for Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",