npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

effectful-better-auth

v0.1.1

Published

Effect v4 integration for Better Auth: plugin-aware service/Layer factory and an effectful auth.api proxy with tagged errors.

Downloads

297

Readme

effectful-better-auth

npm license

Effect v4 integration for Better Auth. ESM-only, zero runtime dependencies; effect and better-auth are peers.

Installation

bun add effectful-better-auth
# or: npm install / pnpm add / yarn add

Requires better-auth ^1.6.0 and effect ^4.0.0-beta.93 as peer dependencies.

Quickstart

import { memoryAdapter } from 'better-auth/adapters/memory'
import { admin } from 'better-auth/plugins/admin'
import { Effect } from 'effect'
import { service } from 'effectful-better-auth'

// Mint a service: a context Tag plus a Layer. Keep options literal —
// the plugins array is what types your api surface.
export const Auth = service('app/Auth', {
  secret: 'a-secret-at-least-32-characters-long!!',
  baseURL: 'http://localhost:3000',
  emailAndPassword: { enabled: true },
  database: memoryAdapter({}),
  plugins: [admin({ adminRoles: ['admin'] })]
})

// Every auth.api endpoint is an Effect, failing with BetterAuthApiError.
export const firstAdmins = Effect.gen(function* () {
  const auth = yield* Auth.Tag
  const { users } = yield* auth.api.listUsers({ query: { limit: 10 } })
  return users
})

export const main = firstAdmins.pipe(Effect.provide(Auth.layer))

Failures carry statusCode, code (matching $ERROR_CODES), message, and headers — discriminate on statusCode/code, never message:

firstAdmins.pipe(
  Effect.catchTag('BetterAuthApiError', (e) =>
    e.statusCode === 401 ? Effect.succeed([]) : Effect.fail(e)
  )
)

How plugins work

service (and make) infer the instance type from your literal options, so plugin endpoints (auth.api.listUsers, auth.api.signInUsername, …) are fully typed with zero per-plugin code — and absent when the plugin is not in plugins.

Escape hatch

The proxy is the one invocation idiom. For raw Response/headers (asResponse, returnHeaders) or auth.handler, use the raw instance: auth.instance.api.getSession({ headers, asResponse: true }).

Options as an Effect

service(id, options) and make(options) also accept an effectful options builder (Effect<Options, E, R>); its requirements flow into the layer, so you can read your own config and construct your database adapter from your own services. The library reads no environment and defines no Config keys.

When options are built in a function (including an effectful builder), wrap the plugin array with the plugins(...) helper — a bare array literal widens to a union array there, which silently drops plugin schema inference (plugin-added user/session fields like the admin plugin's user.role vanish from Session):

import { plugins, service } from 'effectful-better-auth'

const build = Effect.gen(function* () {
  const config = yield* MyConfig
  return {
    secret: config.secret,
    baseURL: config.baseURL,
    emailAndPassword: { enabled: true },
    database: myAdapter(config),
    plugins: plugins(username(), admin({ adminRoles: ['admin'] }))
  }
})

export const Auth = service('app/Auth', build)

Mounting the auth routes

route(Tag) is a Layer that registers '*' <basePath>/* on the v4 router, forwarding everything under the base path to Better Auth's own handler. The base path derives from your better-auth options (options.basePath ?? '/api/auth'); route(Tag, { basePath }) is the single override point. No node: imports anywhere — the mount runs on Cloudflare Workers unchanged.

import { Layer } from 'effect'
import { HttpRouter } from 'effect/unstable/http'
import { route } from 'effectful-better-auth'
import { Auth } from './auth.js'

const routes = Layer.mergeAll(
  route(Auth.Tag)
  // ...your other routes / HttpApiBuilder.layer(...)
).pipe(Layer.provide(Auth.layer))

// Worker / web-standard entrypoint:
export const { handler, dispose } = HttpRouter.toWebHandler(routes)

File-route frameworks (TanStack Start and friends) skip the router and materialize the primitive directly — toHttpEffect(Tag) is a plain v4 HTTP effect (toWeb the request → auth.handlerfromWeb the response, streaming bodies pass through untouched):

import { Effect } from 'effect'
import { HttpEffect } from 'effect/unstable/http'
import { toHttpEffect } from 'effectful-better-auth'
import { Auth } from './auth.js'

const handle = HttpEffect.toWebHandler(
  toHttpEffect(Auth.Tag).pipe(Effect.provide(Auth.layer))
)

export const ServerRoute = { GET: handle, POST: handle }

Rate limiting, logging, and audit wrap the plain Effect with standard Effect/HttpRouter middleware on your side — the mount has no hooks of its own.

Protecting endpoints

sessionMiddleware(id, Tag) mints two HttpApiMiddleware variants, both typed from your instance's $Infer session (plugin-widened fields flow through):

  • CurrentSession — provides the session to handlers; fails a typed Unauthorized (rendered 401) when there is no session.
  • CurrentSessionOption — provides Option<Session>; never fails on a missing session.

Transport failures surface as BetterAuthApiError, untouched. The middleware never redirects — navigation gates belong to your application.

import { Effect, Layer, Option, Schema } from 'effect'
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi'
import { sessionMiddleware } from 'effectful-better-auth'
import { Auth } from './auth.js'

export const AuthSession = sessionMiddleware('app/AuthSession', Auth.Tag)

const api = HttpApi.make('app')
  .add(
    HttpApiGroup.make('account')
      .add(HttpApiEndpoint.get('me', '/me', { success: Schema.String }))
      .middleware(AuthSession.CurrentSession)
  )
  .add(
    HttpApiGroup.make('pages')
      .add(HttpApiEndpoint.get('home', '/home', { success: Schema.String }))
      .middleware(AuthSession.CurrentSessionOption)
  )

const accountLive = HttpApiBuilder.group(api, 'account', (handlers) =>
  handlers.handle('me', () =>
    Effect.gen(function* () {
      const session = yield* AuthSession.Session // typed, from $Infer
      return session.user.email
    })
  )
)

const pagesLive = HttpApiBuilder.group(api, 'pages', (handlers) =>
  handlers.handle('home', () =>
    Effect.gen(function* () {
      const session = yield* AuthSession.SessionOption
      return Option.match(session, {
        onNone: () => 'hello, stranger',
        onSome: (s) => `hello, ${s.user.name}`
      })
    })
  )
)

export const apiLive = HttpApiBuilder.layer(api).pipe(
  Layer.provide(accountLive),
  Layer.provide(pagesLive),
  Layer.provide(AuthSession.layer),
  Layer.provide(Auth.layer)
)

Cookie-cache freshness is a constructor concern: sessionMiddleware(id, Tag, { disableCookieCache: true, disableRefresh: true }) forwards the flags to getSession. Routes needing different freshness get a second instance under a distinct id.

To materialize apiLive with HttpRouter.toWebHandler, HttpApiBuilder still needs the platform services; on Workers (no Node runtime) satisfy them with the no-op filesystem:

import { FileSystem, Layer, Path } from 'effect'
import { Etag, HttpPlatform } from 'effect/unstable/http'

export const PlatformLive = Layer.mergeAll(
  Path.layer,
  Etag.layer,
  FileSystem.layerNoop({}),
  HttpPlatform.layer.pipe(Layer.provide(FileSystem.layerNoop({})))
)

See SPEC.md for the full design.

Contributing

Issues and pull requests are welcome at brandhaug/effectful-better-auth. Run the checks locally before submitting:

bun run typecheck && bun run test

License

MIT