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 +291 -155
- package/dist/auth.d.ts +1 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +1 -1
- 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 +1 -1
- package/dist/effect/index.d.ts.map +1 -1
- package/dist/effect/index.js +1 -1
- 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:
|
|
@@ -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
|
package/dist/auth.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
package/dist/effect/auth.d.ts
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/effect/auth.ts"],"names":[],"mappings":"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"}
|
package/dist/effect/auth.js
CHANGED
|
@@ -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(
|
|
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(
|
|
119
|
+
yield* requireGuest(loginRedirect);
|
|
117
120
|
return yield* render(registerComponent);
|
|
118
121
|
}));
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|
package/dist/effect/index.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/effect/index.js
CHANGED
|
@@ -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';
|