Skip to main content

Vanilla client

WIP

Framework-agnostic authCreateClient. Promise-based signIn / signOut / getSession + onChange subscription.

Constructor

import { authCreateClient } from '@gentleduck/auth/client/vanilla'

const client = authCreateClient({
  baseUrl: '/auth',              // default
  fetch: globalThis.fetch,       // override for SSR / Node tests
  headers: { 'X-App': 'web' },   // sent on every call
  notifyImmediately: true,       // emit current state on .onChange()
})

The result implements VanillaClient.IClient<Profile>. All methods are async; errors throw AuthError.

Methods

signIn(opts)

Run a provider's complete step:

const { ok, session, identity } = await client.signIn({
  providerId: 'password',
  input: { email, password },
  path: '/signin',  // optional; defaults to `${baseUrl}/signin`
})

On success: returns { ok: true, session, identity, body } and updates the internal session state.

On failure: throws an AuthError. The code field is what the UI should branch on.

signOut()

await client.signOut()

Issues POST /auth/signout, clears the local session state, fires onChange() with null.

getSession()

const { session, identity } = await client.getSession()

Issues GET /auth/session. Used internally on AuthClient construction to hydrate the initial state.

beginProvider(id, input?)

For OAuth: kicks off the redirect. For magic-link: triggers the email/SMS dispatch.

// OAuth
await client.beginProvider('google', {})
// -> browser navigates to Google

// Magic-link
await client.beginProvider('magic-link', { email, channel: 'email' })
// -> 200 { sent: true }

refresh()

Re-fetches the session without ceremony - useful after a server-side mutation that you know changed the session shape:

await mutateProfile(...)
await client.refresh()

onChange(cb)

Subscribe to session-state changes:

const unsubscribe = client.onChange((state) => {
  if (state === null) {
    console.log('signed out')
  } else {
    console.log('signed in as', state.identity.profile.displayName)
  }
})

// later:
unsubscribe()

The callback fires:

  • Immediately on subscription (if notifyImmediately: true).
  • On every successful signIn / signOut / refresh.
  • On storage events from another tab (cross-tab sync).

State shape

type State<Profile> =
  | null
  | {
      session: { id, expiresAt, aal, amr, kind, ... }
      identity: { id, email, profile: Profile, ... }
    }

Constructing a Profile-typed client

import { authCreateClient } from '@gentleduck/auth/client/vanilla'

type Profile = { displayName: string; avatarUrl?: string }

export const client = authCreateClient<Profile>()
// client.signIn -> Profile is inferred
// client.onChange((state) => state?.identity.profile.displayName)

Using in a SPA

// auth.ts
export const client = authCreateClient()

// app.tsx
client.onChange((state) => {
  document.body.dataset.authed = state ? 'true' : 'false'
})

document.querySelector('#signin').addEventListener('click', async () => {
  await client.signIn({
    providerId: 'password',
    input: { email: emailInput.value, password: passwordInput.value },
  })
})

document.querySelector('#signout').addEventListener('click', () => client.signOut())

Server-side rendering

For SSR, pass a custom fetch that forwards cookies from the incoming request:

const ssrClient = authCreateClient({
  baseUrl: 'http://localhost:3000/auth',
  fetch: async (url, init) => {
    return fetch(url, {
      ...init,
      headers: { ...init?.headers, cookie: req.headers.cookie ?? '' },
    })
  },
})

const initialState = await ssrClient.getSession()

The initial state ships down to the client in the HTML, and the client-side authCreateClient rehydrates from it without an extra round trip.