npm package: @lastshotlabs/snapshot
Description: React frontend framework for bunshot-powered backends
Latest version: 0.1.0

# @lastshotlabs/snapshot

React frontend framework for [bunshot](https://github.com/lastshotlabs/bunshot)-powered backends.

Provides auth, API client, WebSocket, routing guards, and theme — all wired via a single factory call.

---

## Installation

```bash
bun add @lastshotlabs/snapshot
```

**Peer dependencies** (install separately):

```bash
bun add react react-dom @tanstack/react-router @tanstack/react-query jotai @unhead/react
```

---

## Scaffolding

The fastest way to start is with the scaffold CLI — it generates a complete Vite + TanStack Router + shadcn app pre-wired to snapshot.

```bash
bunx @lastshotlabs/snapshot init "My App"
```

**With a custom output directory:**

```bash
bunx @lastshotlabs/snapshot init "My App" my-app-dir
```

**Skip all prompts and accept defaults:**

```bash
bunx @lastshotlabs/snapshot init "My App" --yes
```

### Prompts

| Prompt | Options | Default |
|---|---|---|
| Project name | free text | — |
| Package name | free text | derived from project name |
| Security profile | `hardened` · `prototype` | `hardened` |
| Layout | `minimal` · `top-nav` · `sidebar` | `top-nav` |
| Theme | `default` · `dark` · `minimal` · `vibrant` | `default` |
| Auth pages | yes · no | yes |
| MFA pages | yes · no (shown if auth pages: yes) | no |
| Passkey pages | yes · no (shown if auth pages: yes) | no |
| shadcn components | multi-select | recommended set |
| WebSocket support | yes · no | yes |
| Git init | yes · no | yes |

### What gets generated

```
my-app/
  src/
    routes/
      __root.tsx
      _authenticated.tsx
      _authenticated/index.tsx
      _authenticated/mfa-setup.tsx  ← (if MFA pages: yes)
      _authenticated/passkey.tsx    ← (if passkey pages: yes)
      _authenticated/settings/
        index.tsx                   ← (if auth pages: yes)
        password.tsx                ← (if auth pages: yes)
        sessions.tsx                ← (if auth pages: yes)
        delete-account.tsx          ← (if auth pages: yes)
        email-otp.tsx               ← (if auth pages + mfa pages: yes)
      _guest.tsx
      _guest/auth/           ← login, register, forgot-password,
                               reset-password, verify-email,
                               oauth/callback (if auth pages: yes)
                               mfa-verify (if MFA pages: yes)
    pages/
      auth/                  ← LoginPage, RegisterPage, ForgotPasswordPage,
                               ResetPasswordPage, VerifyEmailPage,
                               OAuthCallbackPage (if auth pages: yes)
                               MfaVerifyPage, MfaSetupPage (if MFA pages: yes)
      PasskeyManagePage.tsx  ← (if passkey pages: yes)
      settings/
        SettingsPage.tsx              ← (if auth pages: yes)
        SettingsPasswordPage.tsx      ← (if auth pages: yes)
        SettingsSessionsPage.tsx      ← (if auth pages: yes)
        SettingsDeleteAccountPage.tsx ← (if auth pages: yes)
        SettingsEmailOtpPage.tsx      ← (if auth pages + mfa pages: yes)
    components/
      layout/                ← RootLayout, AuthLayout, shared components (layout-specific)
      ui/                    ← shadcn components
      shared/
    api/                     ← plain async functions (populated by snapshot sync)
    hooks/                   ← custom hooks (your code)
      api/                   ← generated TanStack Query hooks (snapshot sync)
    lib/
      snapshot.ts            ← createSnapshot() call, all hooks exported
      router.ts
      utils.ts
    store/ui.ts
    styles/globals.css       ← theme-specific CSS variables
    types/api.ts             ← generated types (snapshot sync)
    main.tsx
  public/
    vite.svg
  vite.config.ts
  tsconfig.json              ← project references root
  tsconfig.app.json          ← app compiler options + path aliases
  tsconfig.node.json         ← vite.config.ts compiler options
  snapshot.config.json       ← sync output directories (edit to customise)
  components.json
  package.json
  index.html
  .env
  .gitignore                 ← includes routeTree.gen.ts
```

> **Note:** `routeTree.gen.ts` is auto-generated by TanStack Router on the first `bun dev` run. TypeScript will show an error for it until you start the dev server once.

### Layouts

- **Minimal** — bare `div` wrapper, no navigation
- **Top nav** — header with app name, theme toggle, sign in/out
- **Sidebar** — collapsible sidebar (mobile overlay + desktop fixed), top bar with hamburger

### Themes

All themes include both `:root` (light) and `.dark` variable sets — dark mode always works regardless of theme.

- **Default** — shadcn neutral palette, light mode default
- **Dark** — same palette, dark mode default (seeds `localStorage` on first visit to prevent FOUC)
- **Minimal** — reduced border radius, muted/low-contrast palette
- **Vibrant** — saturated violet/indigo palette, higher contrast

### After scaffolding

```bash
cd my-app

# Fill in .env:
# VITE_API_URL  — your bunshot backend URL
# VITE_WS_URL   — your WebSocket URL (if WS enabled)

bun dev         # start the dev server (also generates routeTree.gen.ts)
bun run sync    # generate src/api/, src/hooks/api/, src/types/api.ts from your backend
```

`snapshot.config.json` is pre-generated with the default output paths. Edit it if you need to rename directories or point sync at a different backend.

---

## Quick Start

### 1. Create the snapshot instance

> **Note:** The examples below use `@lib/snapshot` — a path alias pointing to `src/lib/snapshot.ts`. All aliases are configured in `tsconfig.app.json` and `vite.config.ts` in the generated scaffold. Available aliases: `@` → `src`, `@lib`, `@components`, `@hooks`, `@api`, `@store`, `@styles`, `@types`. All hooks and primitives flow through `@lib/snapshot`, not through direct package imports.

```ts
// src/lib/snapshot.ts
import { createSnapshot } from '@lastshotlabs/snapshot'

export const snapshot = createSnapshot({
  apiUrl: import.meta.env.VITE_API_URL,
  loginPath: '/login',
  homePath: '/dashboard',
})

export const {
  // Core auth
  useUser,
  useLogin,
  useLogout,
  useRegister,
  useForgotPassword,
  // Account management
  useSetPassword,
  useDeleteAccount,
  useCancelDeletion,
  useRefreshToken,
  useSessions,
  useRevokeSession,
  useResetPassword,
  useVerifyEmail,
  useResendVerification,
  // OAuth
  getOAuthUrl,
  getLinkUrl,
  useOAuthExchange,
  useOAuthUnlink,
  // MFA (opt-in — only needed if your bunshot backend has MFA enabled)
  useMfaVerify,
  useMfaSetup,
  useMfaVerifySetup,
  useMfaDisable,
  useMfaRecoveryCodes,
  useMfaResend,
  useMfaMethods,
  usePendingMfaChallenge,
  isMfaChallenge,
  // WebAuthn (opt-in — only needed if bunshot has webauthn MFA enabled)
  useWebAuthnRegisterOptions,
  useWebAuthnRegister,
  useWebAuthnCredentials,
  useWebAuthnRemoveCredential,
  useWebAuthnDisable,
  // Passkey login (opt-in — only when allowPasswordlessLogin: true on server)
  usePasskeyLoginOptions,
  usePasskeyLogin,
  // WebSocket
  useSocket,
  useRoom,
  useRoomEvent,
  useWebSocketManager,
  // UI / routing
  useTheme,
  protectedBeforeLoad,
  guestBeforeLoad,
  QueryProvider,
  // Primitives
  api,
  queryClient,
  tokenStorage,
} = snapshot
```

### 2. Set up the router

```ts
// src/lib/router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from '../routeTree.gen'
import { snapshot } from './snapshot'

export const router = createRouter({
  routeTree,
  context: { queryClient: snapshot.queryClient },
  defaultPreload: 'intent',
  defaultPreloadStaleTime: 0,
  scrollRestoration: true,
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}
```

### 3. Wire up providers in main.tsx

```tsx
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { QueryProvider } from '@lib/snapshot'
import { router } from '@lib/router'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryProvider>
      <RouterProvider router={router} />
    </QueryProvider>
  </StrictMode>
)
```

### 4. Set up the root route (manual setup only)

> **Using the scaffold?** `__root.tsx`, `_authenticated.tsx`, `_guest.tsx`, and all layout components are pre-generated. Skip to step 5.



```tsx
// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
import { HeadProvider } from '@unhead/react'
import { Outlet } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'

function RootDocument() {
  return <Outlet />
}

export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
  component: () => (
    <HeadProvider>
      <RootDocument />
    </HeadProvider>
  ),
})
```

### 5. Generate typed API hooks

Once your bunshot backend is running, sync its schema into your app:

```bash
bun run sync
```

This generates plain async functions in `src/api/`, TanStack Query hooks in `src/hooks/api/`, and updates `src/types/api.ts`. See [API Sync](#api-sync) for details.

---

## Configuration

```ts
createSnapshot({
  // Required
  apiUrl: 'https://api.example.com',

  // Auth mode — default: 'cookie' (recommended for browser apps)
  auth: 'cookie',  // 'cookie' | 'token'

  // Static API credential — not a user session token.
  // Do not use in browser deployments. Emits a runtime warning in browser contexts.
  bearerToken: 'my-api-key',

  // Redirect paths — dev error thrown if missing when a guarded route fires
  loginPath: '/login',
  homePath: '/dashboard',
  forbiddenPath: '/403',
  mfaPath: '/auth/mfa-verify',      // redirect when login returns MFA challenge
  mfaSetupPath: '/mfa-setup',       // redirect when backend requires MFA setup (403)

  // Callbacks — fire alongside redirects (analytics, state cleanup, etc.)
  onUnauthenticated: () => console.log('not logged in'),
  onForbidden: () => console.log('access denied'),

  // Token storage
  tokenStorage: 'sessionStorage',  // 'sessionStorage' | 'memory' | 'localStorage' — default: 'sessionStorage' (token mode only)
  tokenKey: 'x-user-token',      // default: 'x-user-token'

  // TanStack Query defaults
  staleTime: 5 * 60 * 1000,  // default: 5 minutes
  gcTime: 10 * 60 * 1000,    // default: 10 minutes
  retry: 1,                   // default: 1

  // WebSocket — entire block optional; WS is disabled if omitted
  ws: {
    url: 'wss://api.example.com/ws',

    autoReconnect: true,           // default: true
    reconnectOnLogin: true,        // default: true — reconnects after login succeeds
    reconnectOnFocus: true,        // default: true — reconnects when tab regains focus
    maxReconnectAttempts: Infinity, // default: Infinity
    reconnectBaseDelay: 1000,      // default: 1000ms
    reconnectMaxDelay: 30000,      // default: 30000ms

    onConnected: () => {},
    onDisconnected: () => {},
    onReconnecting: (attempt) => console.log(`Reconnect attempt ${attempt}`),
    onReconnectFailed: () => console.log('Gave up reconnecting'),
  },
})
```

---

## Security Model

Snapshot is the hardened browser client for Bunshot-backed apps. The security contract between snapshot and Bunshot is normative — not optional guidance.

### Default: cookie auth

Cookie auth is the default. Browser apps use `HttpOnly` session cookies managed by Bunshot. Tokens are never exposed to JavaScript.

The Bunshot browser contract requires:

**Session cookie:**
- `HttpOnly=true` — not readable by JS
- `Secure=true` in production
- `SameSite=Lax` minimum
- `Path=/`
- No broad `Domain` unless subdomain sharing is intentional

**CSRF cookie:**
- `HttpOnly=false` — must be readable by JS (snapshot reads it to send `x-csrf-token` header)
- `Secure=true` in production
- `SameSite=Lax`
- `Path=/`
- Rotated on login and logout

**CORS:**
- `Access-Control-Allow-Credentials: true`
- Exact-match origin allowlist — never `*`
- `Vary: Origin` on responses with dynamic `Access-Control-Allow-Origin`
- Allowed headers include `x-csrf-token`

**OAuth:**
- Bunshot validates `state` and completes the provider exchange server-side
- Session cookie is established during the server-side callback
- Browser callback page receives only success/error status — no provider code or intermediate exchange code
- Redirect allowlist (`allowedRedirectUrls`) is required and must be non-empty; unset or empty fails closed

**WebSocket:**
- Auth uses cookies, not query params (query params appear in server logs)
- CSRF protection is `Origin` header validation on upgrade — exact-match against an allowlist
- Missing or mismatched Origin is rejected

**Transport:**
- `https:` and `wss:` required in production
- localhost is the only exception for local development

### Token mode (explicit opt-in)

Token mode is available for non-browser clients or unusual browser cases. Set `auth: 'token'` explicitly. It is not the recommended Bunshot web deployment model.

- Default storage is `'sessionStorage'` (tab-scoped, not shared across tabs)
- `'memory'` is available as a stricter opt-in (state lost on page reload)
- Auth state is not shared across tabs in either storage mode

### Scaffold security profiles

The scaffold CLI offers two profiles:

**`hardened` (default):** Production-safe defaults. No static credentials in env. In-memory MFA challenge. Passive OAuth callback. No `useOAuthExchange` in exports.

**`prototype`:** Local dev ergonomics. Includes `VITE_BEARER_TOKEN` (with warning). Uses legacy OAuth exchange. Includes a startup guard that throws if the app runs on a non-localhost origin unless `VITE_ALLOW_PROTOTYPE_DEPLOYMENT=true` is set.

> Prototype mode is for local development only. The startup guard is a safety net, not a deployment strategy.

### `bearerToken`

`bearerToken` in `createSnapshot` config is a static API credential — not a user session token. It is intended for machine-to-machine or API gateway auth, not browser user sessions. Using it in a browser context emits a runtime warning in all environments. It is not included in hardened scaffold output.

---

## Auth

### Reading the current user

```tsx
import { useUser } from '@lib/snapshot'

function ProfileBadge() {
  const { user, isLoading, isError } = useUser()

  if (isLoading) return <Spinner />
  if (!user) return null
  return <span>{user.email}</span>
}
```

`useUser` returns `null` (not an error) when the user is not logged in. It caches the `/auth/me` response via TanStack Query.

### Login

```tsx
import { useLogin } from '@lib/snapshot'

function LoginForm() {
  const login = useLogin()

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const data = new FormData(e.currentTarget)
    login.mutate(
      { email: data.get('email') as string, password: data.get('password') as string },
      {
        onSuccess: (user) => console.log('logged in as', user.email),
        onError: (err) => console.error(err.status, err.body),
      }
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" />
      <input name="password" type="password" />
      <button disabled={login.isPending}>Login</button>
      {login.isError && <p>{login.error.message}</p>}
    </form>
  )
}
```

### Logout

```tsx
import { useLogout } from '@lib/snapshot'

function LogoutButton() {
  const logout = useLogout()
  return <button onClick={() => logout.mutate()}>Logout</button>
}
```

Logout clears the stored token and the entire query cache — no stale user data remains.

### Register

```tsx
import { useRegister } from '@lib/snapshot'

const register = useRegister()
register.mutate({ email, password })
```

### Forgot Password

```tsx
import { useForgotPassword } from '@lib/snapshot'

const forgotPassword = useForgotPassword()
forgotPassword.mutate({ email })
```

All auth hooks return TanStack Query mutation results. Use `onSuccess`, `onError`, `onSettled` natively.

### Cookie-based auth

Cookie auth is the default and recommended mode for browser apps. Tokens are never exposed to JavaScript, eliminating XSS token theft.

```ts
export const snapshot = createSnapshot({
  apiUrl: import.meta.env.VITE_API_URL,
  loginPath: '/login',
  homePath: '/dashboard',
  // auth: 'cookie' is the default — no need to set it explicitly
})
```

When cookie mode is active:

- All requests include `credentials: 'include'` so the browser sends the auth cookie automatically
- Mutating requests (POST, PUT, PATCH, DELETE) attach the `x-csrf-token` header, read from the `csrf_token` cookie set by bunshot
- Token storage becomes a no-op — `tokenStorage.get()` returns `null`, `set()` and `clear()` do nothing
- Login and register responses no longer extract a token from the response body
- The `bearerToken`, `tokenStorage`, and `tokenKey` config options are ignored

> **CORS requirement:** When using cookie auth cross-origin, the bunshot backend must set `Access-Control-Allow-Credentials: true`, `Vary: Origin`, and use an exact-match `Access-Control-Allow-Origin` allowlist (never `*`).

### Token-based auth (explicit opt-in)

Token mode is available for non-browser clients or unusual browser cases where cookie auth is not appropriate. It is not the recommended Bunshot web deployment model.

```ts
export const snapshot = createSnapshot({
  apiUrl: import.meta.env.VITE_API_URL,
  auth: 'token',
  loginPath: '/login',
  homePath: '/dashboard',
})
```

Token mode behavior:

- The access token is stored client-side and sent as `x-user-token` on every request
- Default storage is `'sessionStorage'` (tab-scoped — survives page refresh, cleared on tab close, does not share across tabs)
- `'memory'` is available as a stricter opt-in (state lost on page reload, does not share across tabs)
- `'localStorage'` is available but not recommended for auth tokens

> Token mode is tab-scoped by default. A user logged in on one tab will not be authenticated in a new tab opened from the same browser.

---

## MFA (Multi-Factor Authentication)

MFA is fully opt-in. If your bunshot backend has MFA configured, snapshot provides hooks to handle every step of the flow. Apps that don't use MFA see zero changes.

### Login with MFA

When a user with MFA enabled logs in, `useLogin` returns an `MfaChallenge` instead of an `AuthUser`. Use `isMfaChallenge` to distinguish:

```tsx
import { useLogin, isMfaChallenge } from '@lib/snapshot'

function LoginPage() {
  const login = useLogin()

  // If mfaPath is configured, useLogin auto-redirects on MFA challenge.
  // The challenge is stored in memory — read it on the MFA page with usePendingMfaChallenge().
  // For manual handling:
  useEffect(() => {
    if (login.data && isMfaChallenge(login.data)) {
      // login.data.mfaMethods — ['totp', 'emailOtp', etc.]
      // mfaToken is stored internally — use usePendingMfaChallenge() on the MFA page
    }
  }, [login.data])

  // ... form
}
```

If `mfaPath` is set in `createSnapshot` config, the redirect happens automatically — no manual handling needed.

### Verifying MFA during login

The MFA challenge is held in memory by the snapshot instance after `useLogin` redirects. Read it on the MFA page with `usePendingMfaChallenge`:

```tsx
import { useMfaVerify, useMfaResend, usePendingMfaChallenge } from '@lib/snapshot'
import { Link } from '@tanstack/react-router'

function MfaVerifyPage() {
  const pendingChallenge = usePendingMfaChallenge()
  const verify = useMfaVerify()
  const resend = useMfaResend()

  // Challenge is gone if the user navigated here directly or refreshed the page
  if (!pendingChallenge) {
    return <p>Session expired. <Link to="/auth/login">Sign in again.</Link></p>
  }

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const code = new FormData(e.currentTarget).get('code') as string
    verify.mutate({ code }) // mfaToken is read internally from the pending challenge
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="code" inputMode="numeric" maxLength={6} />
      <button disabled={verify.isPending}>Verify</button>
      {verify.isError && <p>{verify.error.message}</p>}
      <button type="button" onClick={() => resend.mutate({ mfaToken: pendingChallenge.mfaToken })}>
        Resend email code
      </button>
    </form>
  )
}
```

`useMfaVerify` completes the login — it stores the session (cookie mode) or token (token mode), fetches `/auth/me`, updates the auth cache, clears the pending challenge, and navigates to `homePath`.

The pending challenge is automatically cleared on successful verify, logout, and auth reset. If the user refreshes mid-flow, `usePendingMfaChallenge()` returns `null` — show an expired message and link back to login.

### Setting up MFA

```tsx
import { useMfaSetup, useMfaVerifySetup } from '@lib/snapshot'

function MfaSetupPage() {
  const setup = useMfaSetup()
  const verifySetup = useMfaVerifySetup()

  // Step 1: Generate TOTP secret
  // setup.mutate() → { secret, uri }

  // Step 2: User scans QR code, enters code
  // verifySetup.mutate({ code }) → { message, recoveryCodes }

  // Step 3: Display recovery codes
}
```

### Disabling MFA

```tsx
import { useMfaDisable } from '@lib/snapshot'

const disable = useMfaDisable()
disable.mutate({ code: '123456' }) // requires current TOTP code
```

### Recovery codes

```tsx
import { useMfaRecoveryCodes } from '@lib/snapshot'

const regenerate = useMfaRecoveryCodes()
regenerate.mutate({ code: '123456' }) // requires TOTP code
// regenerate.data.recoveryCodes — new codes (old ones invalidated)
```

### Email OTP

```tsx
import { useMfaEmailOtpEnable, useMfaEmailOtpVerifySetup, useMfaEmailOtpDisable } from '@lib/snapshot'

// Enable: sends verification code to user's email
const enable = useMfaEmailOtpEnable()
enable.mutate() // → { message, setupToken }

// Verify: confirm with the code from email
const verifySetup = useMfaEmailOtpVerifySetup()
verifySetup.mutate({ setupToken: enable.data.setupToken, code: '123456' })

// Disable
const disable = useMfaEmailOtpDisable()
disable.mutate({ code: '123456' }) // TOTP code if TOTP enabled, or { password } if only method
```

### Checking enabled MFA methods

```tsx
import { useMfaMethods } from '@lib/snapshot'

function SecuritySettings() {
  const { methods, isLoading } = useMfaMethods()
  // methods: ['totp', 'emailOtp'] | null
}
```

### MFA setup required (forced enrollment)

When bunshot is configured with `mfa.required: true`, authenticated users without MFA receive a `403` with code `MFA_SETUP_REQUIRED` on any API call. If `mfaSetupPath` is set in `createSnapshot`, snapshot automatically redirects to that page.

```ts
createSnapshot({
  apiUrl: import.meta.env.VITE_API_URL,
  mfaSetupPath: '/mfa-setup', // auto-redirect on MFA_SETUP_REQUIRED
})
```

---

## Account Management

```tsx
import {
  useSetPassword, useDeleteAccount, useCancelDeletion, useRefreshToken,
  useSessions, useRevokeSession,
  useResetPassword, useVerifyEmail, useResendVerification,
} from '@lib/snapshot'

// Set or change password
const setPassword = useSetPassword()
setPassword.mutate({ password: 'new-pass' })
setPassword.mutate({ password: 'new-pass', currentPassword: 'old-pass' })

// Delete account — clears token, flushes query cache, navigates to loginPath
const deleteAccount = useDeleteAccount()
deleteAccount.mutate()                   // OAuth-only accounts (no password)
deleteAccount.mutate({ password: '…' }) // credential accounts

// Cancel a queued deletion (within the grace period configured on the backend)
const cancelDeletion = useCancelDeletion()
cancelDeletion.mutate()

// Manually refresh the access token
const refresh = useRefreshToken()
refresh.mutate()                          // uses cookie or stored refresh token
refresh.mutate({ refreshToken: '…' })    // explicit token

// List active sessions
const { sessions, isLoading } = useSessions()
// sessions: Session[] — { sessionId, createdAt, lastActiveAt, expiresAt, ipAddress?, userAgent?, isActive }

// Revoke a session (sign out of another device)
const revokeSession = useRevokeSession()
revokeSession.mutate(session.sessionId)

// Password reset flow (token from email link)
const resetPassword = useResetPassword()
resetPassword.mutate({ token, password })

// Email verification flow (token from email link)
const verifyEmail = useVerifyEmail()
verifyEmail.mutate({ token })

const resendVerification = useResendVerification()
resendVerification.mutate({ email })
```

---

## OAuth

OAuth initiation is a simple redirect — no hook needed. Call `getOAuthUrl` and navigate:

```ts
import { getOAuthUrl, getLinkUrl } from '@lib/snapshot'

// Redirect to OAuth provider sign-in
window.location.href = getOAuthUrl('google')    // → {apiUrl}/auth/google

// Link an additional OAuth provider to an existing account
window.location.href = getLinkUrl('github')     // → {apiUrl}/auth/github/link

// Supported providers: 'google' | 'apple' | 'microsoft' | 'github'
```

After the OAuth flow completes, the provider redirects back to your callback page. In the default (hardened) browser flow, Bunshot establishes the session cookie server-side during the OAuth callback and redirects back with only a success or error indicator — no code exchange is needed in the browser:

```tsx
// Hardened OAuth callback — passive, no exchange step
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useUser, queryClient } from '@lib/snapshot'

function OAuthCallbackPage() {
  const { error } = Route.useSearch() // only { success?, error? } in search params
  const { user } = useUser()
  const navigate = useNavigate()

  useEffect(() => {
    if (!error) queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
  }, [])

  useEffect(() => {
    if (user) navigate({ to: '/' })
  }, [user])

  if (error) return <p>Sign in failed: {error}</p>
  return <p>Signing in...</p>
}
```

The scaffolded `OAuthCallbackPage` is generated this way automatically. No `useOAuthExchange` call — the session is already established by the time the browser lands on this page.

**Legacy exchange (prototype scaffold only):**

`useOAuthExchange` is available for compatibility with non-browser or prototype flows where Bunshot's one-time code pattern is used client-side:

```ts
// @deprecated — use the hardened cookie flow above for browser apps
import { useOAuthExchange } from '@lib/snapshot'

const exchange = useOAuthExchange()
exchange.mutate({ code })
```

> `useOAuthExchange` will be removed in the next major version. It is not included in hardened scaffold output.

Unlink a connected provider:

```ts
import { useOAuthUnlink } from '@lib/snapshot'

const unlink = useOAuthUnlink()
unlink.mutate('google')    // invalidates /auth/me cache on success
```

---

## WebAuthn

WebAuthn registration requires `@simplewebauthn/browser` on the client side to call the browser's credential APIs. snapshot provides the hooks; you wire them to the browser API.

```ts
import {
  useWebAuthnRegisterOptions, useWebAuthnRegister,
  useWebAuthnCredentials, useWebAuthnRemoveCredential, useWebAuthnDisable,
} from '@lib/snapshot'
import { startRegistration } from '@simplewebauthn/browser'

// Registration flow
function useRegisterSecurityKey(name?: string) {
  const getOptions = useWebAuthnRegisterOptions()
  const register = useWebAuthnRegister()

  async function registerKey() {
    // Step 1: get challenge from server
    const { options, registrationToken } = await getOptions.mutateAsync()

    // Step 2: browser prompts user to tap security key / use Touch ID
    const attestationResponse = await startRegistration(options)

    // Step 3: send result back to server
    register.mutate({ registrationToken, attestationResponse, name })
  }

  return { registerKey, isPending: getOptions.isPending || register.isPending }
}

// List registered credentials
const { credentials, isLoading } = useWebAuthnCredentials()
// credentials: { credentialId, name?, createdAt, transports? }[]

// Remove a specific credential
const remove = useWebAuthnRemoveCredential()
remove.mutate(credentialId)

// Disable WebAuthn entirely
const disable = useWebAuthnDisable()
disable.mutate()
```

---

## Passkey Login

Passkeys (Windows Hello, Face ID, Touch ID) as a **passwordless first-factor** — no password, no MFA prompt. Requires bunshot `mfa.webauthn.allowPasswordlessLogin: true` on the server.

```ts
import {
  usePasskeyLoginOptions, usePasskeyLogin,
  isMfaChallenge,
} from '@lib/snapshot'
import { startAuthentication } from '@simplewebauthn/browser'

function usePasskeySignIn() {
  const getOptions = usePasskeyLoginOptions()
  const login = usePasskeyLogin()

  async function signInWithPasskey(email?: string) {
    // Step 1 — get challenge (enumeration-safe: safe to pass unknown email)
    const { options, passkeyToken } = await getOptions.mutateAsync({ email })

    // Step 2 — OS prompt (Windows Hello / Face ID / Touch ID)
    // Throws NotAllowedError if user cancels — catch it and fall back to password
    const assertionResponse = await startAuthentication(options)

    // Step 3 — verify server-side; hook stores token + navigates on success
    const result = await login.mutateAsync({ passkeyToken, assertionResponse })

    // isMfaChallenge only when server has passkeyMfaBypass: false
    if (isMfaChallenge(result)) {
      // redirect to MFA page with result.mfaToken
    }
  }

  return {
    signInWithPasskey,
    isPending: getOptions.isPending || login.isPending,
    error: login.error,
  }
}
```

#### Handling cancellation and retries

```ts
async function handlePasskeyLogin(email?: string) {
  // Check browser support first — hide button if unsupported
  if (!window.PublicKeyCredential) return

  try {
    const { options, passkeyToken } = await getOptions.mutateAsync({ email })
    const assertionResponse = await startAuthentication(options)
    await login.mutateAsync({ passkeyToken, assertionResponse })
  } catch (err: any) {
    if (err.name === 'NotAllowedError') {
      // User cancelled the OS prompt — not an error, just fall back to password
      return
    }
    // Network error or token expiry (410 / challenge-not-found) — retry once with fresh challenge
    if (err.status === 410 || err.name === 'NetworkError') {
      const { options: freshOptions, passkeyToken: freshToken } = await getOptions.mutateAsync({ email })
      const assertionResponse = await startAuthentication(freshOptions)
      await login.mutateAsync({ passkeyToken: freshToken, assertionResponse })
      return
    }
    // 401 authentication failure — surface to user, do not retry
    throw err
  }
}
```

`usePasskeyLogin` stores the session token and navigates to `homePath` on success, identical to `useLogin`.

---

## Route Guards

Assign `protectedBeforeLoad` and `guestBeforeLoad` in your route files:

```ts
// src/routes/dashboard.tsx — authenticated users only
import { createFileRoute } from '@tanstack/react-router'
import { protectedBeforeLoad } from '@lib/snapshot'

export const Route = createFileRoute('/dashboard')({
  beforeLoad: protectedBeforeLoad,
  component: DashboardPage,
})
```

```ts
// src/routes/login.tsx — redirect to home if already logged in
import { createFileRoute } from '@tanstack/react-router'
import { guestBeforeLoad } from '@lib/snapshot'

export const Route = createFileRoute('/login')({
  beforeLoad: guestBeforeLoad,
  component: LoginPage,
})
```

Both guards fetch `/auth/me` via the router context's `queryClient` (configured in step 2). TanStack Query serves from cache if the result is fresh.

---

## API Client

The `api` primitive gives direct access to the HTTP client — useful outside React (Jotai atoms, event handlers, utilities):

```ts
import { api } from '@lib/snapshot'

// Typed response
const user = await api.get<User>('/users/123')

// With body
const post = await api.post<Post>('/posts', { title: 'Hello', body: '...' })

// With custom headers
const data = await api.get<Data>('/protected', {
  headers: { 'x-custom-header': 'value' },
})

// With abort signal
const controller = new AbortController()
const data = await api.get<Data>('/slow-endpoint', { signal: controller.signal })
```

Available methods: `get`, `post`, `put`, `patch`, `delete` — all return `Promise<T>`.

### Error handling

Non-2xx responses throw `ApiError`:

```ts
import { ApiError } from '@lastshotlabs/snapshot'

try {
  await api.post('/posts', body)
} catch (err) {
  if (err instanceof ApiError) {
    console.log(err.status) // HTTP status code
    console.log(err.body)   // parsed JSON response body
    console.log(err.message) // "HTTP 422"
  }
}
```

In TanStack Query mutations, errors are typed automatically when you annotate the mutation:

```ts
const mutation = useMutation<Post, ApiError, CreatePostBody>({
  mutationFn: (body) => api.post('/posts', body),
})
```

> `ApiError` is the one thing imported directly from the package. Everything else (`api`, `useUser`, etc.) comes from `@lib/snapshot`.

---

## WebSocket

### Basic usage

```tsx
import { useSocket } from '@lib/snapshot'

function StatusIndicator() {
  const socket = useSocket()
  return <span>{socket.isConnected ? 'Live' : 'Offline'}</span>
}
```

`useSocket()` returns a `SocketHook` with:
- `isConnected: boolean`
- `send(type, payload)` — send a message to the server
- `on(event, handler)` / `off(event, handler)` — raw event listeners
- `subscribe(room)` / `unsubscribe(room)` — available but prefer `useRoom` / `useRoomEvent`, which handle cleanup and auto-resubscription on reconnect
- `reconnect()` — manual reconnect trigger

If `ws` is not configured in `createSnapshot`, `useSocket()` is a no-op: `isConnected` is always `false` and all methods are safe to call (they do nothing).

### Typed events

```ts
// src/types/ws.ts
export interface WebSocketEvents {
  'chat:message': { roomId: string; content: string; author: string }
  'presence:update': { roomId: string; members: string[] }
  'notification': { id: string; text: string }
}

// src/lib/snapshot.ts
export const snapshot = createSnapshot<WebSocketEvents>({ ... })
```

With the type parameter, `useSocket<WebSocketEvents>()` is fully typed.

### Room hooks

```tsx
import { useRoom, useRoomEvent } from '@lib/snapshot'

function ChatRoom({ roomId }: { roomId: string }) {
  const { isSubscribed } = useRoom(`chat:${roomId}`)
  const [messages, setMessages] = useState<ChatMessage[]>([])

  useRoomEvent(`chat:${roomId}`, 'chat:message', (msg) => {
    setMessages(prev => [...prev, msg])
  })

  if (!isSubscribed) return <Spinner />
  return <MessageList messages={messages} />
}
```

`useRoom` subscribes on mount and unsubscribes on unmount. The WebSocket manager automatically re-subscribes to all rooms after reconnect — no manual handling needed.

`useRoomEvent` is scoped — the handler only fires when the event name matches AND the message was received from the specified room. Events from other rooms with the same name are ignored.

### Building custom hooks

Use `useWebSocketManager` for direct access to the `WebSocketManager` instance:

```ts
import { useWebSocketManager } from '@lib/snapshot'
import { useState, useEffect } from 'react'

export function usePresence(roomId: string) {
  const manager = useWebSocketManager()
  const [members, setMembers] = useState<string[]>([])

  useEffect(() => {
    if (!manager) return
    manager.subscribe(`presence:${roomId}`)
    const handler = (data: { roomId: string; members: string[] }) => {
      if (data.roomId === roomId) setMembers(data.members)
    }
    manager.on('presence:update', handler)
    return () => {
      manager.unsubscribe(`presence:${roomId}`)
      manager.off('presence:update', handler)
    }
  }, [roomId, manager])

  return members
}
```

### WebSocket auth

The browser sends the auth cookie automatically on the WebSocket upgrade request — no token in query params (which appear in server logs). After login, snapshot automatically reconnects the WebSocket so the new connection carries the authenticated cookie (when `reconnectOnLogin: true`, which is the default).

---

## Theme

```tsx
import { useTheme } from '@lib/snapshot'

function ThemeToggle() {
  const { theme, toggle } = useTheme()
  return (
    <button onClick={toggle}>
      {theme === 'dark' ? 'Light mode' : 'Dark mode'}
    </button>
  )
}
```

`useTheme` returns:
- `theme: 'light' | 'dark'`
- `toggle()` — switches between light and dark
- `set(t: 'light' | 'dark')` — set explicitly

Theme is persisted in `localStorage` under the key `snapshot-theme`. The `dark` class is automatically applied to `document.documentElement` (compatible with Tailwind v4's `dark:` variant).

On first load, the theme defaults to the user's OS preference (`prefers-color-scheme`).

---

## Token Storage

Access the token storage directly for custom auth flows:

```ts
import { tokenStorage } from '@lib/snapshot'

tokenStorage.get()         // returns string | null
tokenStorage.set('token')  // stores a token
tokenStorage.clear()       // removes the token
```

### Building custom auth hooks

```ts
import { api, tokenStorage } from '@lib/snapshot'
import { useMutation } from '@tanstack/react-query'

export function useImpersonate() {
  return useMutation({
    mutationFn: (userId: string) =>
      api.post<{ token: string }>('/admin/impersonate', { userId }),
    onSuccess: ({ token }) => tokenStorage.set(token),
  })
}
```

---

## Composition Patterns

All hooks and primitives returned by `createSnapshot` are designed for composition. Apps build domain hooks from them — no reimplementing, no copying package internals.

### Custom API calls with Jotai

```ts
// src/store/products.ts
import { atom } from 'jotai'
import { api } from '@lib/snapshot'
import type { Product } from '@/types/api'

const selectedIdAtom = atom<string | null>(null)

// Works outside React — no hooks required
export const selectedProductAtom = atom(async (get) => {
  const id = get(selectedIdAtom)
  if (!id) return null
  return api.get<Product>(`/products/${id}`)
})
```

### Custom query hooks

```ts
// src/hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@lib/snapshot'
import type { Product, CreateProductBody } from '@/types/api'

export function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: () => api.get<Product[]>('/products'),
  })
}

export function useCreateProduct() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: (body: CreateProductBody) => api.post<Product>('/products', body),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['products'] }),
  })
}
```

---

## Instance Shape

`createSnapshot` returns a `SnapshotInstance<TWSEvents>` with:

| Property | Type | Description |
|---|---|---|
| `useUser` | Hook | Current auth user, loading, error state |
| `useLogin` | Hook | Login mutation |
| `useLogout` | Hook | Logout mutation |
| `useRegister` | Hook | Register mutation |
| `useForgotPassword` | Hook | Forgot password mutation |
| `useSocket` | Hook | WebSocket connection and messaging |
| `useRoom` | Hook | Subscribe to a named room |
| `useRoomEvent` | Hook | Listen to events in a named room |
| `useTheme` | Hook | Light/dark theme toggle |
| `useMfaVerify` | Hook | Complete MFA login with code |
| `useMfaSetup` | Hook | Generate TOTP secret + QR URI |
| `useMfaVerifySetup` | Hook | Confirm TOTP setup, get recovery codes |
| `useMfaDisable` | Hook | Disable MFA (requires TOTP code) |
| `useMfaRecoveryCodes` | Hook | Regenerate recovery codes |
| `useMfaEmailOtpEnable` | Hook | Initiate email OTP setup |
| `useMfaEmailOtpVerifySetup` | Hook | Confirm email OTP setup |
| `useMfaEmailOtpDisable` | Hook | Disable email OTP method |
| `useMfaResend` | Hook | Resend email OTP code during login |
| `useMfaMethods` | Hook | Query enabled MFA methods |
| `isMfaChallenge` | Utility | Type guard for `LoginResult` |
| `usePendingMfaChallenge` | Hook | Read the in-memory MFA challenge on the MFA verify page |
| `useSetPassword` | Hook | Set or change account password |
| `useDeleteAccount` | Hook | Delete account with full session teardown |
| `useCancelDeletion` | Hook | Cancel a queued account deletion |
| `useRefreshToken` | Hook | Exchange refresh token for new access token |
| `useSessions` | Hook | List active sessions for current user |
| `useRevokeSession` | Hook | Revoke a session by ID |
| `useResetPassword` | Hook | Reset password using email token |
| `useVerifyEmail` | Hook | Verify email address using token |
| `useResendVerification` | Hook | Resend email verification link |
| `getOAuthUrl` | Utility | Get OAuth provider redirect URL |
| `getLinkUrl` | Utility | Get OAuth account-linking redirect URL |
| `useOAuthExchange` | Hook | **Legacy.** Exchange OAuth one-time code for session. Not used in hardened browser flow. Removed in next major version. |
| `useOAuthUnlink` | Hook | Unlink an OAuth provider |
| `useWebAuthnRegisterOptions` | Hook | Get WebAuthn registration challenge |
| `useWebAuthnRegister` | Hook | Complete WebAuthn credential registration |
| `useWebAuthnCredentials` | Hook | List registered WebAuthn credentials |
| `useWebAuthnRemoveCredential` | Hook | Remove a WebAuthn credential |
| `useWebAuthnDisable` | Hook | Disable WebAuthn MFA method |
| `usePasskeyLoginOptions` | Hook | Get passkey login challenge options (passkeyPages: true) |
| `usePasskeyLogin` | Hook | Complete passwordless passkey login (passkeyPages: true) |
| `api` | Primitive | `ApiClient` — typed HTTP methods |
| `tokenStorage` | Primitive | Read/write/clear auth token |
| `queryClient` | Primitive | Stable `QueryClient` singleton |
| `useWebSocketManager` | Hook | Raw `WebSocketManager` instance |
| `protectedBeforeLoad` | Loader | Redirect unauthenticated users |
| `guestBeforeLoad` | Loader | Redirect authenticated users |
| `QueryProvider` | Component | Pre-bound `QueryClientProvider` |

---

## Peer Dependencies

| Package | Required Version |
|---|---|
| `react` | `>=19.0.0` |
| `react-dom` | `>=19.0.0` |
| `@tanstack/react-router` | `>=1.0.0` |
| `@tanstack/react-query` | `>=5.0.0` |
| `jotai` | `>=2.0.0` |
| `@unhead/react` | `>=2.0.0` |
| `vite` | `>=5.0.0` · optional — only required for the Vite plugin |
| `zod` | `^3.0.0` · optional — only required for `--zod` flag |

---

## API Sync

Generate fully-typed TanStack Query hooks directly from your bunshot backend's OpenAPI schema:

```bash
bun run sync                                    # reads VITE_API_URL from .env
bunx snapshot sync --api http://localhost:3000  # explicit URL
bunx snapshot sync --file ./openapi.json        # local file
bunx snapshot sync --watch                      # re-run automatically on schema change
bunx snapshot sync --zod                        # also generate Zod validators
```

Run it any time your backend routes or types change. It only touches generated files and is safe to re-run as many times as needed.

### URL resolution

The command resolves the API URL in this order:

1. `--api <url>` flag
2. `VITE_API_URL` environment variable (bun loads `.env` automatically for `bun run sync`)
3. `VITE_API_URL` in the `.env` file in the current directory

The schema is fetched from `{apiUrl}/openapi.json`. Use `--file` to skip the network and read directly from a local file.

### What gets generated

**`src/types/api.ts`** — TypeScript types for every schema in `components.schemas`:

```ts
// Generated by bunx snapshot sync. Do not edit manually.

export interface User {
  id: string
  email: string
  createdAt: string
}

export type UserRole = 'admin' | 'member' | 'guest'
```

**`src/api/{tag}.ts`** — one file per OpenAPI tag containing plain async functions. No React dependencies — callable anywhere (Jotai atoms, event handlers, utilities).

```ts
// Generated by bunx snapshot sync. Do not edit manually.

import { api } from '@lib/snapshot'
import type { User, CreateUserBody } from '../types/api'

/** List all users */
export const listUsers = (): Promise<User[]> =>
  api.get<User[]>('/users')

/** Get user by ID */
export const getUser = (id: string): Promise<User> =>
  api.get<User>(`/users/${id}`)

/** Create a user */
export const createUser = (body: CreateUserBody): Promise<User> =>
  api.post<User>('/users', body)
```

**`src/hooks/api/{tag}.ts`** — one file per OpenAPI tag containing TanStack Query hooks. Imports plain functions from `../../api/{tag}`.

```ts
// Generated by bunx snapshot sync. Do not edit manually.

import { useQuery, useMutation, useQueryClient, type UseQueryOptions, type UseMutationOptions, type QueryKey } from '@tanstack/react-query'
import { ApiError } from '@lastshotlabs/snapshot'
import { listUsers, getUser, createUser } from '../../api/users'
import type { User, CreateUserBody } from '../../types/api'

/** List all users */
export function useListUsersQuery(
  options?: Omit<UseQueryOptions<User[], ApiError>, 'queryKey' | 'queryFn'>
) {
  return useQuery({
    queryKey: ['users'],
    queryFn: listUsers,
    ...options,
  })
}

/** Get user by ID */
export function useGetUserQuery(
  params: { id: string },
  options?: Omit<UseQueryOptions<User, ApiError>, 'queryKey' | 'queryFn'>
) {
  return useQuery({
    queryKey: ['users', params.id],
    queryFn: () => getUser(params.id),
    ...options,
  })
}

/** Create a user */
export function useCreateUserMutation(
  options?: UseMutationOptions<User, ApiError, CreateUserBody> & { invalidateKeys?: QueryKey[] }
) {
  const { invalidateKeys, ...mutationOptions } = options ?? {}
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: createUser,
    ...mutationOptions,
    onSuccess: (...args) => {
      invalidateKeys?.forEach((key) => queryClient.invalidateQueries({ queryKey: key }))
      mutationOptions.onSuccess?.(...args)
    },
  })
}
```

GET operations become `useQuery` hooks. All other methods become `useMutation` hooks. Operations with an `operationId` get clean names (`listUsers` → `useListUsersQuery`). Operations without one fall back to method + path segments. Deprecated operations have `/** @deprecated */` added to both exports.

For mutations that include path parameters (e.g. `PUT /users/{id}`), the mutation variables combine path params and body into a single object:

```ts
// Generated for PUT /users/{id}
export const updateUser = (id: string, body: UpdateUserBody): Promise<User> =>
  api.put<User>(`/users/${id}`, body)

export function useUpdateUserMutation(
  options?: UseMutationOptions<User, ApiError, { id: string; body: UpdateUserBody }> & { invalidateKeys?: QueryKey[] }
) {
  const { invalidateKeys, ...mutationOptions } = options ?? {}
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: (vars) => updateUser(vars.id, vars.body),
    ...mutationOptions,
    onSuccess: (...args) => {
      invalidateKeys?.forEach((key) => queryClient.invalidateQueries({ queryKey: key }))
      mutationOptions.onSuccess?.(...args)
    },
  })
}
```

Usage:

```ts
const update = useUpdateUserMutation()
update.mutate({ id: user.id, body: { email: 'new@example.com' } })
```

### Mutation hook options

Generated mutation hooks accept the full `UseMutationOptions` type (no restrictions), plus an `invalidateKeys` extension:

**Override `mutationFn` to inject context** (e.g. an `accountId` from a parent component or store):

```ts
const { accountId } = useAccount()

const create = useCreateUserMutation({
  mutationFn: (body) => createUser({ ...body, accountId }),
})
```

**Auto-invalidate queries on success:**

```ts
const create = useCreateUserMutation({
  invalidateKeys: [['users'], ['stats']],
})
```

**Both together:**

```ts
const create = useCreateUserMutation({
  mutationFn: (body) => createUser({ ...body, accountId }),
  invalidateKeys: [['users', accountId]],
  onSuccess: () => toast.success('User created'),
})
```

`invalidateKeys` runs before `onSuccess` — by the time your callback fires, the relevant queries are already invalidated.

### Paginated endpoints

When an endpoint returns a bunshot pagination envelope (`{ data: T[], total: number, page: number, perPage: number }`), sync detects it automatically and generates a paginated hook:

```ts
// Generated for GET /users (paginated response)
export const listUsers = (page = 1, perPage = 20): Promise<PaginatedResponse<User>> =>
  api.get<PaginatedResponse<User>>(`/users?page=${page}&perPage=${perPage}`)

export function useListUsersQuery(
  params: { page?: number; perPage?: number } = {},
  options?: Omit<UseQueryOptions<PaginatedResponse<User>, ApiError>, 'queryKey' | 'queryFn'>
) {
  return useQuery({
    queryKey: ['users', params.page ?? 1, params.perPage ?? 20],
    queryFn: () => listUsers(params.page ?? 1, params.perPage ?? 20),
    ...options,
  })
}
```

The `page` and `perPage` values are included in the `queryKey` so TanStack Query caches each page separately. `PaginatedResponse<T>` is exported from `src/types/api.ts`.

### Zod form schemas (`--zod`)

Pass `--zod` to also generate Zod validators for mutation request bodies. These are placed alongside each mutation hook and are ready to use with `react-hook-form` or any Zod-compatible form library:

```ts
/** Zod schema for useCreateUserMutation form validation */
export const createUserSchema = z.object({
  email: z.string(),
  role: z.enum(['admin', 'member']),
})
export type CreateUserInput = z.infer<typeof createUserSchema>
```

Usage with `react-hook-form`:

```ts
import { createUserSchema, type CreateUserInput } from '@api/users'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'

const form = useForm<CreateUserInput>({ resolver: zodResolver(createUserSchema) })
```

`zod` must be installed in your project (`bun add zod`). It is an optional peer dependency of snapshot.

### Watch mode (`--watch` / `-w`)

Keep hooks in sync while developing — sync re-runs automatically whenever the schema changes:

```bash
bunx snapshot sync --watch                      # polls API every 3s
bunx snapshot sync --file ./openapi.json --watch  # polls file every 1s
```

Ctrl+C stops the watcher cleanly. On each change, all affected hook files and `src/types/api.ts` are regenerated.

### Vite plugin

Run sync automatically as part of the Vite dev server lifecycle — no manual `bun run sync` needed on startup:

```ts
// vite.config.ts
import { snapshotSync } from '@lastshotlabs/snapshot/vite'

export default defineConfig({
  plugins: [
    snapshotSync({ file: 'openapi.json' }),  // file mode: re-runs on file change in dev
    // snapshotSync({ apiUrl: 'http://localhost:3000' }),  // API mode: runs once on start
  ],
})
```

The plugin runs `snapshot sync` on `buildStart` (both `vite dev` and `vite build`). In dev mode with `file` option, it also watches the schema file via Vite's watcher and re-generates on changes. Dropping the schema file into your project for the first time also triggers sync automatically — no dev server restart needed.

> **Note:** API URL polling in dev mode is not supported by the Vite plugin. For live schema updates from a running API, use `bunx snapshot sync --watch` in a separate terminal instead.

`vite` must be installed in your project. It is an optional peer dependency of snapshot.

### Using plain functions in Jotai atoms

Plain functions live in `src/api/` with no React dependencies — import them directly anywhere:

```ts
// src/store/users.ts
import { atom } from 'jotai'
import { getUser } from '@api/users'

const selectedIdAtom = atom<string | null>(null)

export const selectedUserAtom = atom(async (get) => {
  const id = get(selectedIdAtom)
  return id ? getUser(id) : null
})
```

---

## Build

```bash
bun run build      # tsup → dist/
bun run typecheck  # tsc --noEmit
bun run dev        # tsup --watch
```

Output:
- `dist/index.mjs` — library ESM
- `dist/index.cjs` — library CommonJS
- `dist/index.d.ts` — TypeScript declarations
- `dist/cli.mjs` — self-contained CLI executable (bundled, no runtime deps)
- `dist/vite.js` — Vite plugin ESM
- `dist/vite.cjs` — Vite plugin CommonJS
- `dist/vite.d.ts` — Vite plugin TypeScript declarations