honertia 0.1.1 → 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 +426 -22
- 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/dist/helpers.d.ts +17 -4
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +30 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,20 +9,13 @@
|
|
|
9
9
|
[](https://workers.cloudflare.com/)
|
|
10
10
|
[](https://effect.website/)
|
|
11
11
|
|
|
12
|
-
##
|
|
13
|
-
|
|
14
|
-
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 that delivers clean, readable, and powerful web app scaffolding.
|
|
12
|
+
## Overview
|
|
15
13
|
|
|
16
|
-
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.
|
|
17
15
|
|
|
18
|
-
##
|
|
16
|
+
## Raison d'être
|
|
19
17
|
|
|
20
|
-
-
|
|
21
|
-
- **Peer Dependencies**:
|
|
22
|
-
- `hono` >= 4.0.0
|
|
23
|
-
- `better-auth` >= 1.0.0
|
|
24
|
-
- **Dependencies**:
|
|
25
|
-
- `effect` >= 3.12.0
|
|
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
|
|
26
19
|
|
|
27
20
|
## Installation
|
|
28
21
|
|
|
@@ -32,26 +25,61 @@ bun add honertia
|
|
|
32
25
|
|
|
33
26
|
## Quick Start
|
|
34
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
|
+
|
|
35
59
|
```typescript
|
|
36
60
|
// src/index.ts
|
|
37
61
|
import { Hono } from 'hono'
|
|
38
62
|
import { logger } from 'hono/logger'
|
|
39
|
-
import { setupHonertia, createTemplate, registerErrorHandlers, vite } from 'honertia'
|
|
63
|
+
import { setupHonertia, createTemplate, createVersion, registerErrorHandlers, vite } from 'honertia'
|
|
40
64
|
import { Context, Layer } from 'effect'
|
|
65
|
+
import manifest from '../dist/manifest.json'
|
|
41
66
|
|
|
42
|
-
import { createDb } from './db'
|
|
43
67
|
import type { Env } from './types'
|
|
68
|
+
import { createDb } from './db/db'
|
|
44
69
|
import { createAuth } from './lib/auth'
|
|
45
70
|
import { registerRoutes } from './routes'
|
|
46
71
|
|
|
47
72
|
const app = new Hono<Env>()
|
|
73
|
+
const assetVersion = createVersion(manifest)
|
|
74
|
+
const entry = manifest['src/main.tsx']
|
|
75
|
+
const assetPath = (path: string) => `/${path}`
|
|
48
76
|
|
|
49
77
|
class BindingsService extends Context.Tag('app/Bindings')<
|
|
50
78
|
BindingsService,
|
|
51
79
|
{ KV: KVNamespace }
|
|
52
80
|
>() {}
|
|
53
81
|
|
|
54
|
-
//
|
|
82
|
+
// Request-scoped setup: put db/auth on c.var so Honertia/Effect can read them.
|
|
55
83
|
app.use('*', async (c, next) => {
|
|
56
84
|
c.set('db', createDb(c.env.DATABASE_URL))
|
|
57
85
|
c.set('auth', createAuth({
|
|
@@ -62,24 +90,28 @@ app.use('*', async (c, next) => {
|
|
|
62
90
|
await next()
|
|
63
91
|
})
|
|
64
92
|
|
|
65
|
-
// Honertia
|
|
93
|
+
// Honertia bundles the core middleware + auth loading + Effect runtime setup.
|
|
66
94
|
app.use('*', setupHonertia<Env, BindingsService>({
|
|
67
95
|
honertia: {
|
|
68
|
-
|
|
96
|
+
// Use your asset manifest hash so Inertia reloads on deploy.
|
|
97
|
+
version: assetVersion,
|
|
69
98
|
render: createTemplate((ctx) => {
|
|
70
99
|
const isProd = ctx.env.ENVIRONMENT === 'production'
|
|
71
100
|
return {
|
|
72
|
-
title: '
|
|
73
|
-
scripts: isProd ? [
|
|
101
|
+
title: 'My Web App',
|
|
102
|
+
scripts: isProd ? [assetPath(entry.file)] : [vite.script()],
|
|
103
|
+
styles: isProd ? (entry.css ?? []).map(assetPath) : [],
|
|
74
104
|
head: isProd ? '' : vite.hmrHead(),
|
|
75
105
|
}
|
|
76
106
|
}),
|
|
77
107
|
},
|
|
78
108
|
effect: {
|
|
109
|
+
// Expose Cloudflare bindings to Effect handlers via a service layer.
|
|
79
110
|
services: (c) => Layer.succeed(BindingsService, {
|
|
80
111
|
KV: c.env.MY_KV,
|
|
81
112
|
}),
|
|
82
113
|
},
|
|
114
|
+
// Optional: extra Hono middleware in the same chain.
|
|
83
115
|
middleware: [
|
|
84
116
|
logger(),
|
|
85
117
|
// register additional middleware here...
|
|
@@ -99,18 +131,24 @@ import type { Env } from './types'
|
|
|
99
131
|
import { effectRoutes } from 'honertia/effect'
|
|
100
132
|
import { effectAuthRoutes, RequireAuthLayer } from 'honertia/auth'
|
|
101
133
|
import { showDashboard, listProjects, createProject, showProject, deleteProject } from './actions'
|
|
134
|
+
import { loginUser, registerUser, logoutUser } from './actions/auth'
|
|
102
135
|
|
|
103
136
|
export function registerRoutes(app: Hono<Env>) {
|
|
104
|
-
// Auth routes
|
|
137
|
+
// Auth routes: pages, form actions, logout, and API handler in one place.
|
|
105
138
|
effectAuthRoutes(app, {
|
|
106
139
|
loginComponent: 'Auth/Login',
|
|
107
140
|
registerComponent: 'Auth/Register',
|
|
141
|
+
// Form actions (automatically wrapped with RequireGuestLayer)
|
|
142
|
+
loginAction: loginUser,
|
|
143
|
+
registerAction: registerUser,
|
|
144
|
+
logoutAction: logoutUser,
|
|
108
145
|
})
|
|
109
146
|
|
|
110
|
-
//
|
|
147
|
+
// Effect routes give you typed, DI-friendly handlers (no direct Hono ctx).
|
|
111
148
|
effectRoutes(app)
|
|
112
149
|
.provide(RequireAuthLayer)
|
|
113
150
|
.group((route) => {
|
|
151
|
+
// Grouped routes share layers and path prefixes.
|
|
114
152
|
route.get('/', showDashboard) // GET example.com
|
|
115
153
|
|
|
116
154
|
route.prefix('/projects').group((route) => {
|
|
@@ -157,10 +195,220 @@ export const listProjects = Effect.gen(function* () {
|
|
|
157
195
|
const db = yield* DatabaseService
|
|
158
196
|
const user = yield* AuthUserService
|
|
159
197
|
const props = yield* fetchProjects(db as Database, user)
|
|
160
|
-
return yield* render('
|
|
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
|
+
|
|
284
|
+
### Environment Variables
|
|
285
|
+
|
|
286
|
+
Honertia reads these from `c.env` (Cloudflare Workers bindings):
|
|
287
|
+
|
|
288
|
+
```toml
|
|
289
|
+
# wrangler.toml
|
|
290
|
+
ENVIRONMENT = "production"
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
If you prefer `wrangler.jsonc`, the same binding looks like:
|
|
294
|
+
|
|
295
|
+
```jsonc
|
|
296
|
+
{
|
|
297
|
+
"vars": {
|
|
298
|
+
"ENVIRONMENT": "production"
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Set secrets like `DATABASE_URL` and `BETTER_AUTH_SECRET` via Wrangler (not in source control):
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
wrangler secret put DATABASE_URL
|
|
307
|
+
wrangler secret put BETTER_AUTH_SECRET
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Client Setup (React + Inertia)
|
|
311
|
+
|
|
312
|
+
Honertia uses the standard Inertia React client. You'll need a client entry
|
|
313
|
+
point and a Vite build that emits a manifest (for `createVersion`).
|
|
314
|
+
|
|
315
|
+
Install client dependencies:
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
bun add react react-dom @inertiajs/react
|
|
319
|
+
bun add -d @vitejs/plugin-react tailwindcss @tailwindcss/vite
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Create a Vite config that enables Tailwind v4, sets up an alias used in the
|
|
323
|
+
examples, and emits `dist/manifest.json`:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
// vite.config.ts
|
|
327
|
+
import { defineConfig } from 'vite'
|
|
328
|
+
import react from '@vitejs/plugin-react'
|
|
329
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
330
|
+
import path from 'path'
|
|
331
|
+
|
|
332
|
+
export default defineConfig({
|
|
333
|
+
plugins: [tailwindcss(), react()],
|
|
334
|
+
build: {
|
|
335
|
+
outDir: 'dist',
|
|
336
|
+
// Use an explicit filename so imports match build output.
|
|
337
|
+
manifest: 'manifest.json',
|
|
338
|
+
emptyOutDir: true,
|
|
339
|
+
},
|
|
340
|
+
resolve: {
|
|
341
|
+
alias: {
|
|
342
|
+
'~': path.resolve(__dirname, 'src'),
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Create a Tailwind CSS entry file:
|
|
349
|
+
|
|
350
|
+
```css
|
|
351
|
+
/* src/styles.css */
|
|
352
|
+
@import "tailwindcss";
|
|
353
|
+
|
|
354
|
+
@layer base {
|
|
355
|
+
body {
|
|
356
|
+
margin: 0;
|
|
357
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
358
|
+
background-color: #f8fafc;
|
|
359
|
+
color: #0f172a;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Set up the Inertia client entry point (default path matches `vite.script()`):
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
// src/main.tsx
|
|
368
|
+
import './styles.css'
|
|
369
|
+
|
|
370
|
+
import { createInertiaApp } from '@inertiajs/react'
|
|
371
|
+
import { createRoot } from 'react-dom/client'
|
|
372
|
+
|
|
373
|
+
const pages = import.meta.glob('./pages/**/*.tsx')
|
|
374
|
+
|
|
375
|
+
createInertiaApp({
|
|
376
|
+
resolve: (name) => {
|
|
377
|
+
const page = pages[`./pages/${name}.tsx`]
|
|
378
|
+
if (!page) {
|
|
379
|
+
throw new Error(`Page not found: ${name}`)
|
|
380
|
+
}
|
|
381
|
+
return page()
|
|
382
|
+
},
|
|
383
|
+
setup({ el, App, props }) {
|
|
384
|
+
createRoot(el).render(<App {...props} />)
|
|
385
|
+
},
|
|
161
386
|
})
|
|
162
387
|
```
|
|
163
388
|
|
|
389
|
+
The `resolve` function maps `render('Projects/Index')` to
|
|
390
|
+
`src/pages/Projects/Index.tsx`.
|
|
391
|
+
|
|
392
|
+
Optional: add a `tailwind.config.ts` only if you need theme extensions or
|
|
393
|
+
custom content globs.
|
|
394
|
+
|
|
395
|
+
### Build & Deploy Notes
|
|
396
|
+
|
|
397
|
+
The server imports `dist/manifest.json`, so it must exist at build time. In
|
|
398
|
+
production, read scripts and styles from the manifest (Tailwind's CSS is listed
|
|
399
|
+
under your entry's `css` array). When deploying with Wrangler, build the client
|
|
400
|
+
assets first:
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
# build client assets before deploying the worker
|
|
404
|
+
bun run build:client
|
|
405
|
+
wrangler deploy
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Optional dev convenience: if you want to run the worker without building the
|
|
409
|
+
client, you can keep a stub `dist/manifest.json` (ignored by git) and replace it
|
|
410
|
+
once you run `vite build`.
|
|
411
|
+
|
|
164
412
|
### Vite Helpers
|
|
165
413
|
|
|
166
414
|
The `vite` helper provides dev/prod asset management:
|
|
@@ -172,6 +420,15 @@ vite.script() // 'http://localhost:5173/src/main.tsx'
|
|
|
172
420
|
vite.hmrHead() // HMR preamble script tags for React Fast Refresh
|
|
173
421
|
```
|
|
174
422
|
|
|
423
|
+
## Requirements
|
|
424
|
+
|
|
425
|
+
- **Runtime**: Node.js 18+ or Bun 1.0+
|
|
426
|
+
- **Peer Dependencies**:
|
|
427
|
+
- `hono` >= 4.0.0
|
|
428
|
+
- `better-auth` >= 1.0.0
|
|
429
|
+
- **Dependencies**:
|
|
430
|
+
- `effect` >= 3.12.0
|
|
431
|
+
|
|
175
432
|
## Core Concepts
|
|
176
433
|
|
|
177
434
|
### Effect-Based Handlers
|
|
@@ -554,19 +811,159 @@ const user = yield* currentUser // AuthUser | null
|
|
|
554
811
|
### Built-in Auth Routes
|
|
555
812
|
|
|
556
813
|
```typescript
|
|
557
|
-
import { effectAuthRoutes } from 'honertia'
|
|
814
|
+
import { effectAuthRoutes } from 'honertia/auth'
|
|
815
|
+
import { loginUser, registerUser, logoutUser, verify2FA, forgotPassword } from './actions/auth'
|
|
558
816
|
|
|
559
817
|
effectAuthRoutes(app, {
|
|
818
|
+
// Page routes
|
|
560
819
|
loginPath: '/login', // GET: show login page
|
|
561
820
|
registerPath: '/register', // GET: show register page
|
|
562
821
|
logoutPath: '/logout', // POST: logout and redirect
|
|
563
822
|
apiPath: '/api/auth', // Better-auth API handler
|
|
564
823
|
logoutRedirect: '/login',
|
|
824
|
+
loginRedirect: '/',
|
|
565
825
|
loginComponent: 'Auth/Login',
|
|
566
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
|
+
},
|
|
567
838
|
})
|
|
568
839
|
```
|
|
569
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
|
+
|
|
845
|
+
To enable CORS for the auth API handler (`/api/auth/*`), pass a `cors` config.
|
|
846
|
+
By default, no CORS headers are added (recommended when your UI and API share the same origin).
|
|
847
|
+
Use this when your frontend is on a different origin (local dev, separate domain, mobile app, etc.).
|
|
848
|
+
|
|
849
|
+
```typescript
|
|
850
|
+
effectAuthRoutes(app, {
|
|
851
|
+
apiPath: '/api/auth',
|
|
852
|
+
cors: {
|
|
853
|
+
origin: ['http://localhost:5173', 'http://localhost:3000'],
|
|
854
|
+
credentials: true,
|
|
855
|
+
},
|
|
856
|
+
})
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
This sets the appropriate `Access-Control-*` headers and handles `OPTIONS` preflight for the auth API routes.
|
|
860
|
+
Always keep the `origin` list tight; avoid `'*'` for auth endpoints, especially with `credentials: true`.
|
|
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
|
+
|
|
570
967
|
## Action Factories
|
|
571
968
|
|
|
572
969
|
For common patterns, use action factories:
|
|
@@ -675,6 +1072,13 @@ On Cloudflare Workers, database connections and other I/O objects are isolated p
|
|
|
675
1072
|
|
|
676
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.
|
|
677
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
|
+
|
|
678
1082
|
## Contributing
|
|
679
1083
|
|
|
680
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';
|
package/dist/helpers.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ import type { PageObject } from './types.js';
|
|
|
6
6
|
export interface PageProps {
|
|
7
7
|
errors?: Record<string, string>;
|
|
8
8
|
}
|
|
9
|
+
export interface AssetManifestEntry {
|
|
10
|
+
file?: string;
|
|
11
|
+
css?: string[];
|
|
12
|
+
assets?: string[];
|
|
13
|
+
}
|
|
14
|
+
export type AssetManifest = Record<string, string | AssetManifestEntry>;
|
|
9
15
|
export interface TemplateOptions {
|
|
10
16
|
title?: string;
|
|
11
17
|
scripts?: string[];
|
|
@@ -26,16 +32,23 @@ export interface TemplateOptions {
|
|
|
26
32
|
*
|
|
27
33
|
* @example Dynamic config based on environment
|
|
28
34
|
* ```ts
|
|
35
|
+
* const entry = manifest['src/main.tsx']
|
|
36
|
+
* const assetPath = (path: string) => `/${path}`
|
|
37
|
+
*
|
|
29
38
|
* createTemplate((ctx) => ({
|
|
30
39
|
* title: 'App',
|
|
31
40
|
* scripts: ctx.env.ENVIRONMENT === 'production'
|
|
32
|
-
* ? [
|
|
33
|
-
* : [
|
|
41
|
+
* ? [assetPath(entry.file)]
|
|
42
|
+
* : [vite.script()],
|
|
43
|
+
* styles: ctx.env.ENVIRONMENT === 'production'
|
|
44
|
+
* ? (entry.css ?? []).map(assetPath)
|
|
45
|
+
* : [],
|
|
46
|
+
* head: ctx.env.ENVIRONMENT === 'production' ? '' : vite.hmrHead(),
|
|
34
47
|
* }))
|
|
35
48
|
* ```
|
|
36
49
|
*/
|
|
37
50
|
export declare function createTemplate(options: TemplateOptions | ((ctx: Context) => TemplateOptions)): (page: PageObject, ctx?: Context) => string;
|
|
38
|
-
export declare function createVersion(manifest:
|
|
51
|
+
export declare function createVersion(manifest: AssetManifest): string;
|
|
39
52
|
/**
|
|
40
53
|
* Vite development configuration helpers.
|
|
41
54
|
*/
|
|
@@ -57,7 +70,7 @@ export declare const vite: {
|
|
|
57
70
|
* @param port - Vite dev server port (default: 5173)
|
|
58
71
|
* @example
|
|
59
72
|
* ```ts
|
|
60
|
-
* scripts: isProd ? ['/
|
|
73
|
+
* scripts: isProd ? [manifest['src/main.tsx'].file] : [vite.script()]
|
|
61
74
|
* ```
|
|
62
75
|
*/
|
|
63
76
|
script(entry?: string, port?: number): string;
|
package/dist/helpers.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../src/helpers.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED
|
|
1
|
+
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../src/helpers.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAChC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,EAAE,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,kBAAkB,CAAC,CAAA;AAEvE,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,eAAe,GAAG,CAAC,CAAC,GAAG,EAAE,OAAO,KAAK,eAAe,CAAC,GAC7D,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,CAAC,EAAE,OAAO,KAAK,MAAM,CA6C7C;AAWD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CA8B7D;AAED;;GAEG;AACH,eAAO,MAAM,IAAI;IACf;;;;;;;;OAQG;4BACmB,MAAM;IAa5B;;;;;;;;;OASG;2CAC2C,MAAM;CAGrD,CAAA"}
|
package/dist/helpers.js
CHANGED
|
@@ -14,11 +14,18 @@
|
|
|
14
14
|
*
|
|
15
15
|
* @example Dynamic config based on environment
|
|
16
16
|
* ```ts
|
|
17
|
+
* const entry = manifest['src/main.tsx']
|
|
18
|
+
* const assetPath = (path: string) => `/${path}`
|
|
19
|
+
*
|
|
17
20
|
* createTemplate((ctx) => ({
|
|
18
21
|
* title: 'App',
|
|
19
22
|
* scripts: ctx.env.ENVIRONMENT === 'production'
|
|
20
|
-
* ? [
|
|
21
|
-
* : [
|
|
23
|
+
* ? [assetPath(entry.file)]
|
|
24
|
+
* : [vite.script()],
|
|
25
|
+
* styles: ctx.env.ENVIRONMENT === 'production'
|
|
26
|
+
* ? (entry.css ?? []).map(assetPath)
|
|
27
|
+
* : [],
|
|
28
|
+
* head: ctx.env.ENVIRONMENT === 'production' ? '' : vite.hmrHead(),
|
|
22
29
|
* }))
|
|
23
30
|
* ```
|
|
24
31
|
*/
|
|
@@ -66,7 +73,26 @@ function escapeHtml(str) {
|
|
|
66
73
|
.replace(/'/g, ''');
|
|
67
74
|
}
|
|
68
75
|
export function createVersion(manifest) {
|
|
69
|
-
const
|
|
76
|
+
const assetFiles = [];
|
|
77
|
+
for (const value of Object.values(manifest)) {
|
|
78
|
+
if (typeof value === 'string') {
|
|
79
|
+
assetFiles.push(value);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!value || typeof value !== 'object') {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (typeof value.file === 'string') {
|
|
86
|
+
assetFiles.push(value.file);
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(value.css)) {
|
|
89
|
+
assetFiles.push(...value.css);
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(value.assets)) {
|
|
92
|
+
assetFiles.push(...value.assets);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const combined = assetFiles.sort().join('');
|
|
70
96
|
let hash = 0;
|
|
71
97
|
for (let i = 0; i < combined.length; i++) {
|
|
72
98
|
const char = combined.charCodeAt(i);
|
|
@@ -107,7 +133,7 @@ export const vite = {
|
|
|
107
133
|
* @param port - Vite dev server port (default: 5173)
|
|
108
134
|
* @example
|
|
109
135
|
* ```ts
|
|
110
|
-
* scripts: isProd ? ['/
|
|
136
|
+
* scripts: isProd ? [manifest['src/main.tsx'].file] : [vite.script()]
|
|
111
137
|
* ```
|
|
112
138
|
*/
|
|
113
139
|
script(entry = '/src/main.tsx', port = 5173) {
|