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 CHANGED
@@ -9,20 +9,13 @@
9
9
  [![Cloudflare Workers](https://img.shields.io/badge/Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=fff)](https://workers.cloudflare.com/)
10
10
  [![Effect](https://img.shields.io/badge/Effect-TS-black)](https://effect.website/)
11
11
 
12
- ## Raison d'être
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.js integration. Build full-stack applications with type-safe server actions, Laravel-inspired validation, and seamless React rendering.
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
- ## Requirements
16
+ ## Raison d'être
19
17
 
20
- - **Runtime**: Node.js 18+ or Bun 1.0+
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
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/PatrickOgilvie/honertia-worker-demo)
29
+
30
+ ### Recommended File Structure
31
+
32
+ ```
33
+ .
34
+ ├── src/
35
+ │ ├── index.ts # Hono app setup (setupHonertia)
36
+ │ ├── routes.ts # effectRoutes / effectAuthRoutes
37
+ │ ├── main.tsx # Inertia + React client entry
38
+ │ ├── styles.css # Tailwind CSS entry
39
+ │ ├── actions/
40
+ │ │ └── projects/
41
+ │ │ └── list.ts # listProjects action
42
+ │ ├── pages/
43
+ │ │ └── Projects/
44
+ │ │ └── Index.tsx # render('Projects/Index')
45
+ │ ├── db/
46
+ │ │ └── db.ts
47
+ │ │ └── schema.ts
48
+ │ ├── lib/
49
+ │ │ └── auth.ts
50
+ │ └── types.ts
51
+ ├── dist/
52
+ │ └── manifest.json # generated by Vite build
53
+ ├── vite.config.ts
54
+ ├── wrangler.toml # or wrangler.jsonc
55
+ ├── package.json
56
+ └── tsconfig.json
57
+ ```
58
+
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
- // Database & Auth
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 (bundles core middleware, auth loading, and Effect bridge)
93
+ // Honertia bundles the core middleware + auth loading + Effect runtime setup.
66
94
  app.use('*', setupHonertia<Env, BindingsService>({
67
95
  honertia: {
68
- version: '1.0.0',
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: 'Dashboard',
73
- scripts: isProd ? ['/assets/main.js'] : [vite.script()],
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 (login, register, logout, API handler)
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
- // Routes that require the user to be authenticated
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('Dashboard/Projects/Index', props)
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
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,QAAQ,EACR,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EACL,WAAW,EACX,eAAe,EACf,KAAK,QAAQ,GACd,MAAM,sBAAsB,CAAA;AAG7B,OAAO,EACL,iBAAiB,EACjB,cAAc,GACf,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,EACtB,QAAQ,EACR,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EACL,WAAW,EACX,eAAe,EACf,KAAK,QAAQ,GACd,MAAM,sBAAsB,CAAA;AAG7B,OAAO,EACL,iBAAiB,EACjB,cAAc,GACf,MAAM,oBAAoB,CAAA"}
package/dist/auth.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Re-exports all authentication and authorization functionality.
5
5
  * Import from 'honertia/auth' for auth-related functionality.
6
6
  */
7
- export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, loadUser, } from './effect/auth.js';
7
+ export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, betterAuthFormAction, betterAuthLogoutAction, loadUser, } from './effect/auth.js';
8
8
  // Re-export auth-related services
9
9
  export { AuthService, AuthUserService, } from './effect/services.js';
10
10
  // Re-export auth-related errors
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Authentication and authorization via Effect Layers.
5
5
  */
6
- import { Effect, Layer } from 'effect';
6
+ import { Effect, Layer, Schema as S } from 'effect';
7
7
  import type { Hono, MiddlewareHandler, Env } from 'hono';
8
- import { AuthUserService, HonertiaService, type AuthUser } from './services.js';
9
- import { UnauthorizedError } from './errors.js';
8
+ import { AuthUserService, AuthService, HonertiaService, RequestService, type AuthUser } from './services.js';
9
+ import { UnauthorizedError, ValidationError } from './errors.js';
10
+ import { type EffectHandler } from './routing.js';
10
11
  /**
11
12
  * Layer that requires an authenticated user.
12
13
  * Fails with UnauthorizedError if no user is present.
@@ -51,6 +52,11 @@ export declare const shareAuth: Effect.Effect<void, never, HonertiaService>;
51
52
  * Middleware version of shareAuth for use with app.use().
52
53
  */
53
54
  export declare function shareAuthMiddleware<E extends Env>(): MiddlewareHandler<E>;
55
+ /**
56
+ * An auth action effect that returns a Response.
57
+ * Used for loginAction, registerAction, logoutAction, and guestActions.
58
+ */
59
+ export type AuthActionEffect<R = never, E extends Error = Error> = EffectHandler<R, E>;
54
60
  /**
55
61
  * Configuration for auth routes.
56
62
  */
@@ -60,6 +66,9 @@ export interface AuthRoutesConfig<E extends Env> {
60
66
  logoutPath?: string;
61
67
  apiPath?: string;
62
68
  logoutRedirect?: string;
69
+ /**
70
+ * Redirect path for authenticated users hitting login/register pages.
71
+ */
63
72
  loginRedirect?: string;
64
73
  loginComponent?: string;
65
74
  registerComponent?: string;
@@ -72,6 +81,37 @@ export interface AuthRoutesConfig<E extends Env> {
72
81
  origin: string | string[] | ((origin: string) => string | undefined | null);
73
82
  credentials?: boolean;
74
83
  };
84
+ /**
85
+ * POST handler for login form submission.
86
+ * Automatically wrapped with RequireGuestLayer.
87
+ * Use betterAuthFormAction to create this.
88
+ */
89
+ loginAction?: AuthActionEffect;
90
+ /**
91
+ * POST handler for registration form submission.
92
+ * Automatically wrapped with RequireGuestLayer.
93
+ * Use betterAuthFormAction to create this.
94
+ */
95
+ registerAction?: AuthActionEffect;
96
+ /**
97
+ * POST handler for logout.
98
+ * If not provided, uses a default handler that calls auth.api.signOut.
99
+ * Use betterAuthLogoutAction to create this.
100
+ */
101
+ logoutAction?: AuthActionEffect;
102
+ /**
103
+ * Additional guest-only POST routes for extended auth flows.
104
+ * Keys are paths (e.g., '/forgot-password'), values are Effect handlers.
105
+ * All routes are wrapped with RequireGuestLayer.
106
+ *
107
+ * @example
108
+ * guestActions: {
109
+ * '/forgot-password': forgotPasswordAction,
110
+ * '/reset-password': resetPasswordAction,
111
+ * '/login/2fa': verify2FAAction,
112
+ * }
113
+ */
114
+ guestActions?: Record<string, AuthActionEffect>;
75
115
  }
76
116
  /**
77
117
  * Register standard auth routes.
@@ -80,6 +120,8 @@ export interface AuthRoutesConfig<E extends Env> {
80
120
  * effectAuthRoutes(app, {
81
121
  * loginComponent: 'Auth/Login',
82
122
  * registerComponent: 'Auth/Register',
123
+ * loginAction: loginUser,
124
+ * registerAction: registerUser,
83
125
  * })
84
126
  */
85
127
  export declare function effectAuthRoutes<E extends Env>(app: Hono<E>, config?: AuthRoutesConfig<E>): void;
@@ -91,4 +133,38 @@ export declare function loadUser<E extends Env>(config?: {
91
133
  userKey?: string;
92
134
  sessionCookie?: string;
93
135
  }): MiddlewareHandler<E>;
136
+ /**
137
+ * Result types from better-auth calls that expose headers.
138
+ */
139
+ export type BetterAuthActionResult = Response | Headers | {
140
+ headers?: Headers | HeadersInit;
141
+ };
142
+ /**
143
+ * Config for better-auth form actions (login/register).
144
+ */
145
+ export interface BetterAuthFormActionConfig<A, I, AuthClient = unknown> {
146
+ schema: S.Schema<A, I>;
147
+ errorComponent: string;
148
+ call: (auth: AuthClient, input: A, request: Request) => Promise<BetterAuthActionResult>;
149
+ errorMapper?: (error: unknown) => Record<string, string>;
150
+ redirectTo?: string | ((input: A, result: BetterAuthActionResult) => string);
151
+ }
152
+ /**
153
+ * Create a better-auth form action with Honertia-friendly responses.
154
+ *
155
+ * Copies Set-Cookie headers from better-auth and redirects with 303.
156
+ * Maps errors into ValidationError so the standard error handler can render.
157
+ */
158
+ export declare function betterAuthFormAction<A, I, AuthClient = unknown>(config: BetterAuthFormActionConfig<A, I, AuthClient>): Effect.Effect<Response, ValidationError, RequestService | AuthService>;
159
+ /**
160
+ * Config for better-auth logout actions.
161
+ */
162
+ export interface BetterAuthLogoutConfig {
163
+ redirectTo?: string;
164
+ cookieNames?: string[];
165
+ }
166
+ /**
167
+ * Create a better-auth logout action that clears cookies and redirects.
168
+ */
169
+ export declare function betterAuthLogoutAction(config?: BetterAuthLogoutConfig): Effect.Effect<Response, never, RequestService | AuthService>;
94
170
  //# sourceMappingURL=auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/effect/auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,KAAK,EAAU,MAAM,QAAQ,CAAA;AAC9C,OAAO,KAAK,EAAE,IAAI,EAAE,iBAAiB,EAAE,GAAG,EAAE,MAAM,MAAM,CAAA;AACxD,OAAO,EAAE,eAAe,EAAe,eAAe,EAAkB,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAA;AAC5G,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAI/C;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,wDAiB5B,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,8CAe7B,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,KAAK,EAAE,KAAK,CACM,CAAA;AAEvE;;GAEG;AACH,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,CAGlE,CAAA;AAEH;;GAEG;AACH,eAAO,MAAM,WAAW,GACtB,mBAAqB,KACpB,MAAM,CAAC,MAAM,CAAC,QAAQ,EAAE,iBAAiB,EAAE,KAAK,CAQhD,CAAA;AAEH;;GAEG;AACH,eAAO,MAAM,YAAY,GACvB,mBAAgB,KACf,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,iBAAiB,EAAE,KAAK,CAQ5C,CAAA;AAEH;;GAEG;AACH,eAAO,MAAM,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,eAAe,CAK9D,CAAA;AAEJ;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,GAAG,KAAK,iBAAiB,CAAC,CAAC,CAAC,CASzE;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,GAAG;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;OAGG;IACH,IAAI,CAAC,EAAE;QACL,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC,CAAA;QAC3E,WAAW,CAAC,EAAE,OAAO,CAAA;KACtB,CAAA;CACF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,GAAG,EAC5C,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,EACZ,MAAM,GAAE,gBAAgB,CAAC,CAAC,CAAM,GAC/B,IAAI,CAoGN;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,GAAG,EACpC,MAAM,GAAE;IACN,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,MAAM,CAAA;CAClB,GACL,iBAAiB,CAAC,CAAC,CAAC,CAwBtB"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/effect/auth.ts"],"names":[],"mappings":"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"}
@@ -5,9 +5,10 @@
5
5
  */
6
6
  import { Effect, Layer, Option } from 'effect';
7
7
  import { AuthUserService, AuthService, HonertiaService, RequestService } from './services.js';
8
- import { UnauthorizedError } from './errors.js';
8
+ import { UnauthorizedError, ValidationError } from './errors.js';
9
9
  import { effectRoutes } from './routing.js';
10
10
  import { render } from './responses.js';
11
+ import { validateRequest } from './validation.js';
11
12
  /**
12
13
  * Layer that requires an authenticated user.
13
14
  * Fails with UnauthorizedError if no user is present.
@@ -101,39 +102,65 @@ export function shareAuthMiddleware() {
101
102
  * effectAuthRoutes(app, {
102
103
  * loginComponent: 'Auth/Login',
103
104
  * registerComponent: 'Auth/Register',
105
+ * loginAction: loginUser,
106
+ * registerAction: registerUser,
104
107
  * })
105
108
  */
106
109
  export function effectAuthRoutes(app, config = {}) {
107
- const { loginPath = '/login', registerPath = '/register', logoutPath = '/logout', apiPath = '/api/auth', logoutRedirect = '/login', loginComponent = 'Auth/Login', registerComponent = 'Auth/Register', } = config;
110
+ const { loginPath = '/login', registerPath = '/register', logoutPath = '/logout', apiPath = '/api/auth', logoutRedirect = '/login', loginRedirect = '/', loginComponent = 'Auth/Login', registerComponent = 'Auth/Register', } = config;
108
111
  const routes = effectRoutes(app);
109
112
  // Login page (guest only)
110
113
  routes.get(loginPath, Effect.gen(function* () {
111
- yield* requireGuest(loginPath === '/login' ? '/' : loginPath);
114
+ yield* requireGuest(loginRedirect);
112
115
  return yield* render(loginComponent);
113
116
  }));
114
117
  // Register page (guest only)
115
118
  routes.get(registerPath, Effect.gen(function* () {
116
- yield* requireGuest(registerPath === '/register' ? '/' : registerPath);
119
+ yield* requireGuest(loginRedirect);
117
120
  return yield* render(registerComponent);
118
121
  }));
119
- // Logout (POST)
120
- routes.post(logoutPath, Effect.gen(function* () {
121
- const auth = yield* AuthService;
122
- const request = yield* RequestService;
123
- // Revoke session server-side
124
- yield* Effect.tryPromise(() => auth.api.signOut({
125
- headers: request.headers,
122
+ // Login action (POST) - guest only
123
+ if (config.loginAction) {
124
+ routes
125
+ .provide(RequireGuestLayer)
126
+ .post(loginPath, config.loginAction);
127
+ }
128
+ // Register action (POST) - guest only
129
+ if (config.registerAction) {
130
+ routes
131
+ .provide(RequireGuestLayer)
132
+ .post(registerPath, config.registerAction);
133
+ }
134
+ // Logout (POST) - use provided action or default
135
+ if (config.logoutAction) {
136
+ routes.post(logoutPath, config.logoutAction);
137
+ }
138
+ else {
139
+ routes.post(logoutPath, Effect.gen(function* () {
140
+ const auth = yield* AuthService;
141
+ const request = yield* RequestService;
142
+ // Revoke session server-side
143
+ yield* Effect.tryPromise(() => auth.api.signOut({
144
+ headers: request.headers,
145
+ }));
146
+ // Clear cookie and redirect
147
+ const sessionCookie = config.sessionCookie ?? 'better-auth.session_token';
148
+ return new Response(null, {
149
+ status: 303,
150
+ headers: {
151
+ 'Location': logoutRedirect,
152
+ 'Set-Cookie': `${sessionCookie}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax`,
153
+ },
154
+ });
126
155
  }));
127
- // Clear cookie and redirect
128
- const sessionCookie = config.sessionCookie ?? 'better-auth.session_token';
129
- return new Response(null, {
130
- status: 303,
131
- headers: {
132
- 'Location': logoutRedirect,
133
- 'Set-Cookie': `${sessionCookie}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax`,
134
- },
135
- });
136
- }));
156
+ }
157
+ // Additional guest-only actions (2FA, forgot password, etc.)
158
+ if (config.guestActions) {
159
+ const guestRoutes = routes.provide(RequireGuestLayer);
160
+ for (const [path, action] of Object.entries(config.guestActions)) {
161
+ guestRoutes.post(path, action);
162
+ }
163
+ }
137
164
  // Better-auth API handler (handles sign-in, sign-up, etc.)
138
165
  // Apply CORS if configured
139
166
  if (config.cors) {
@@ -202,3 +229,146 @@ export function loadUser(config = {}) {
202
229
  await next();
203
230
  };
204
231
  }
232
+ /**
233
+ * Create a better-auth form action with Honertia-friendly responses.
234
+ *
235
+ * Copies Set-Cookie headers from better-auth and redirects with 303.
236
+ * Maps errors into ValidationError so the standard error handler can render.
237
+ */
238
+ export function betterAuthFormAction(config) {
239
+ return Effect.gen(function* () {
240
+ const auth = yield* AuthService;
241
+ const request = yield* RequestService;
242
+ const input = yield* validateRequest(config.schema, {
243
+ errorComponent: config.errorComponent,
244
+ });
245
+ const result = yield* Effect.tryPromise({
246
+ try: () => config.call(auth, input, buildAuthRequest(request)),
247
+ catch: (error) => error,
248
+ }).pipe(Effect.mapError((error) => new ValidationError({
249
+ errors: (config.errorMapper ?? defaultAuthErrorMapper)(error),
250
+ component: config.errorComponent,
251
+ })));
252
+ const redirectTo = resolveRedirect(config.redirectTo, input, result);
253
+ const responseHeaders = new Headers({ Location: redirectTo });
254
+ const resultHeaders = getHeaders(result);
255
+ if (resultHeaders) {
256
+ appendSetCookies(responseHeaders, resultHeaders);
257
+ }
258
+ return new Response(null, {
259
+ status: 303,
260
+ headers: responseHeaders,
261
+ });
262
+ });
263
+ }
264
+ /**
265
+ * Create a better-auth logout action that clears cookies and redirects.
266
+ */
267
+ export function betterAuthLogoutAction(config = {}) {
268
+ return Effect.gen(function* () {
269
+ const auth = yield* AuthService;
270
+ const request = yield* RequestService;
271
+ const result = yield* Effect.tryPromise({
272
+ try: () => auth.api.signOut({
273
+ headers: request.headers,
274
+ request: buildAuthRequest(request),
275
+ returnHeaders: true,
276
+ }),
277
+ catch: () => undefined,
278
+ }).pipe(Effect.catchAll(() => Effect.succeed(undefined)));
279
+ const responseHeaders = new Headers({
280
+ Location: config.redirectTo ?? '/login',
281
+ });
282
+ const resultHeaders = getHeaders(result);
283
+ if (resultHeaders) {
284
+ appendSetCookies(responseHeaders, resultHeaders);
285
+ }
286
+ if (!responseHeaders.has('set-cookie')) {
287
+ appendLogoutCookies(responseHeaders, config.cookieNames);
288
+ }
289
+ return new Response(null, {
290
+ status: 303,
291
+ headers: responseHeaders,
292
+ });
293
+ });
294
+ }
295
+ function buildAuthRequest(request) {
296
+ return new Request(request.url, {
297
+ method: request.method,
298
+ headers: request.headers,
299
+ });
300
+ }
301
+ function resolveRedirect(target, input, result) {
302
+ if (typeof target === 'function') {
303
+ return target(input, result);
304
+ }
305
+ return target ?? '/';
306
+ }
307
+ function getHeaders(result) {
308
+ if (!result)
309
+ return undefined;
310
+ if (result instanceof Headers)
311
+ return result;
312
+ if (result instanceof Response)
313
+ return result.headers;
314
+ if (typeof result === 'object' && 'headers' in result && result.headers) {
315
+ return coerceHeaders(result.headers);
316
+ }
317
+ return undefined;
318
+ }
319
+ function coerceHeaders(value) {
320
+ return value instanceof Headers ? value : new Headers(value);
321
+ }
322
+ function defaultAuthErrorMapper(error) {
323
+ const message = getAuthErrorMessage(error) ?? 'Unable to complete request. Please try again.';
324
+ return { form: message };
325
+ }
326
+ function getAuthErrorMessage(error) {
327
+ if (!error || typeof error !== 'object')
328
+ return undefined;
329
+ const candidate = error;
330
+ if (typeof candidate.body?.message === 'string')
331
+ return candidate.body.message;
332
+ if (typeof candidate.message === 'string')
333
+ return candidate.message;
334
+ return undefined;
335
+ }
336
+ function appendSetCookies(target, source) {
337
+ const sourceWithSetCookie = source;
338
+ if (typeof sourceWithSetCookie.getSetCookie === 'function') {
339
+ for (const cookie of sourceWithSetCookie.getSetCookie()) {
340
+ target.append('set-cookie', cookie);
341
+ }
342
+ return;
343
+ }
344
+ const setCookie = source.get('set-cookie');
345
+ if (!setCookie) {
346
+ return;
347
+ }
348
+ // Split on cookie boundaries without breaking Expires attributes.
349
+ const parts = setCookie
350
+ .split(/,(?=[^;]+?=)/g)
351
+ .map((part) => part.trim())
352
+ .filter(Boolean);
353
+ for (const cookie of parts) {
354
+ target.append('set-cookie', cookie);
355
+ }
356
+ }
357
+ function appendExpiredCookie(target, name, options = {}) {
358
+ const base = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax`;
359
+ const value = options.secure ? `${base}; Secure` : base;
360
+ target.append('set-cookie', value);
361
+ }
362
+ function appendLogoutCookies(target, cookieNames) {
363
+ const defaults = [
364
+ 'better-auth.session_token',
365
+ 'better-auth.session_data',
366
+ 'better-auth.account_data',
367
+ 'better-auth.dont_remember',
368
+ ];
369
+ const names = cookieNames?.length ? cookieNames : defaults;
370
+ for (const name of names) {
371
+ appendExpiredCookie(target, name);
372
+ appendExpiredCookie(target, `__Secure-${name}`, { secure: true });
373
+ }
374
+ }
@@ -12,5 +12,5 @@ export { effectHandler, effect, handle, errorToResponse, } from './handler.js';
12
12
  export { effectAction, dbAction, authAction, simpleAction, injectUser, dbOperation, prepareData, preparedAction, } from './action.js';
13
13
  export { redirect, render, renderWithErrors, json, text, notFound, forbidden, httpError, prefersJson, jsonOrRender, share, } from './responses.js';
14
14
  export { EffectRouteBuilder, effectRoutes, type EffectHandler, type BaseServices, } from './routing.js';
15
- export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, loadUser, type AuthRoutesConfig, } from './auth.js';
15
+ export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, betterAuthFormAction, betterAuthLogoutAction, loadUser, type AuthRoutesConfig, type BetterAuthFormActionConfig, type BetterAuthLogoutConfig, type BetterAuthActionResult, } from './auth.js';
16
16
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/effect/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EACL,eAAe,EACf,WAAW,EACX,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,KAAK,QAAQ,EACb,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AAGtB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,SAAS,EACT,QAAQ,EACR,KAAK,QAAQ,GACd,MAAM,aAAa,CAAA;AAGpB,cAAc,aAAa,CAAA;AAG3B,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,QAAQ,EACR,eAAe,GAChB,MAAM,iBAAiB,CAAA;AAGxB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,aAAa,EACb,MAAM,EACN,MAAM,EACN,eAAe,GAChB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,SAAS,EACT,WAAW,EACX,YAAY,EACZ,KAAK,GACN,MAAM,gBAAgB,CAAA;AAGvB,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,QAAQ,EACR,KAAK,gBAAgB,GACtB,MAAM,WAAW,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/effect/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EACL,eAAe,EACf,WAAW,EACX,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,KAAK,QAAQ,EACb,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AAGtB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,SAAS,EACT,QAAQ,EACR,KAAK,QAAQ,GACd,MAAM,aAAa,CAAA;AAGpB,cAAc,aAAa,CAAA;AAG3B,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,QAAQ,EACR,eAAe,GAChB,MAAM,iBAAiB,CAAA;AAGxB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,aAAa,EACb,MAAM,EACN,MAAM,EACN,eAAe,GAChB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,SAAS,EACT,WAAW,EACX,YAAY,EACZ,KAAK,GACN,MAAM,gBAAgB,CAAA;AAGvB,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,EACtB,QAAQ,EACR,KAAK,gBAAgB,EACrB,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,WAAW,CAAA"}
@@ -22,4 +22,4 @@ export { redirect, render, renderWithErrors, json, text, notFound, forbidden, ht
22
22
  // Routing
23
23
  export { EffectRouteBuilder, effectRoutes, } from './routing.js';
24
24
  // Auth
25
- export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, loadUser, } from './auth.js';
25
+ export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, betterAuthFormAction, betterAuthLogoutAction, loadUser, } from './auth.js';
package/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
- * ? ['/assets/main.js']
33
- * : ['http://localhost:5173/src/main.tsx'],
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: Record<string, string>): string;
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 ? ['/assets/main.js'] : [vite.script()]
73
+ * scripts: isProd ? [manifest['src/main.tsx'].file] : [vite.script()]
61
74
  * ```
62
75
  */
63
76
  script(entry?: string, port?: number): string;
@@ -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;;;;;;;;;;;;;;;;;;;;GAoBG;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,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAStE;AAED;;GAEG;AACH,eAAO,MAAM,IAAI;IACf;;;;;;;;OAQG;4BACmB,MAAM;IAa5B;;;;;;;;;OASG;2CAC2C,MAAM;CAGrD,CAAA"}
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
- * ? ['/assets/main.js']
21
- * : ['http://localhost:5173/src/main.tsx'],
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, '&#39;');
67
74
  }
68
75
  export function createVersion(manifest) {
69
- const combined = Object.values(manifest).sort().join('');
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 ? ['/assets/main.js'] : [vite.script()]
136
+ * scripts: isProd ? [manifest['src/main.tsx'].file] : [vite.script()]
111
137
  * ```
112
138
  */
113
139
  script(entry = '/src/main.tsx', port = 5173) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honertia",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Inertia.js-style server-driven SPA adapter for Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",